feat(rbac): 落地 Wagtail Group + GroupPagePermission/GroupCollectionPermission/Snippet 权限体系

新增 setup_rbac_groups 幂等管理命令,按设计方案 §2.8 矩阵创建 5 个 Group(站点管理员/编辑/审核员/作者/查看者),配置页面/图片文档/Snippet/用户管理权限;Author 仅编辑自己页面利用 Wagtail 内置 OwnershipPermissionPolicy 实现,无需自定义 hook;新增 7 个单元测试,pytest 33→40 全部通过。
This commit is contained in:
2026-08-07 17:07:39 +08:00
parent 3944e11330
commit f37571f395
3 changed files with 360 additions and 1 deletions
@@ -0,0 +1,201 @@
"""初始化 RBAC 权限组。
根据 documents/设计方案分析与完善版.md §2.8 的角色矩阵,使用 Wagtail 原生
`Group` + `GroupPagePermission` + `GroupCollectionPermission` + Django
`Permission`Snippet/用户管理),不引入额外的 RBAC 框架。
角色与 Django/Wagtail 概念的映射:
- Super Admin -> Django `is_superuser=True`(不对应任何 Group,超级用户天然拥有全部权限)
- Tenant/Site Admin -> Group "站点管理员"
- Editor -> Group "编辑"
- Reviewer -> Group "审核员"
- Author -> Group "作者"
- Viewer -> Group "查看者"
简化与取舍说明:
1. 审批发布:设计方案 §2.9 的 Workflow 审核模块尚未实现,因此本命令暂不配置
Wagtail Workflow/TaskState。而是通过“编辑者只有 add/change、审核员和站点管理员
才有 publish”的权限差异,天然形成“创建草稿 -> 等待发布”的审核闸门。待 Workflow
模块落地后,可在现有 Group 基础上叠加 GroupApprovalTask,无需重新设计权限组。
2. Author“仅自己”:利用 Wagtail 内置的 OwnershipPermissionPolicy —— 只授予
`add_page`(不授予 `change_page`)时,用户可以创建新页面,且只能编辑/删除自己
拥有(owner)的页面,无需额外自定义 wagtail_hooks。
3. Reviewer 的“页面编辑”矩阵标注为“查看”,但由于 Wagtail 的页面编辑视图需要
`change` 权限才能打开并点击“发布”按钮,因此本命令额外授予审核员 `change_page`
属于为了让“审批发布”可用而做的必要放宽,已在此处明确说明。
4. Lead(线索)Snippet 涉及 PIPL 个人信息,出于数据保护考虑做了比矩阵更严格的收紧:
仅“站点管理员”可管理,“审核员”仅可查看,“编辑/作者”不授予任何 Lead 权限。
5. 权限统一挂载在 Wagtail 页面树根节点(depth=1)与根 Collection 上,适用于当前
单站点部署;如未来引入多站点/多租户,需要按 Site 对应的根页面/Collection 分别授权。
6. 用户管理(新建/编辑/删除/查看 Django 用户)仅授予“站点管理员”,对应矩阵中
“用户管理”列;Django Admin 后台访问不通过 Group 授予,只能由拥有
`is_staff=True` 的账号(通常是超级用户)访问。
本命令幂等:每次运行都会将各 Group 的权限重置为矩阵定义的目标状态,可安全重复执行。
"""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from wagtail.models import Collection, GroupCollectionPermission, GroupPagePermission, Page
from apps.core.models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial
from apps.forms.models import FormDefinition, Lead
SITE_ADMIN = "站点管理员"
EDITOR = "编辑"
REVIEWER = "审核员"
AUTHOR = "作者"
VIEWER = "查看者"
ALL_GROUP_NAMES = [SITE_ADMIN, EDITOR, REVIEWER, AUTHOR, VIEWER]
# 页面权限:值为 GroupPagePermission 的 permission_type 简写(自动拼接为 "<type>_page"
PAGE_PERMISSIONS = {
SITE_ADMIN: ["add", "change", "publish", "delete", "bulk_delete", "lock", "unlock", "view"],
EDITOR: ["add", "change", "view"],
REVIEWER: ["change", "publish", "view"],
AUTHOR: ["add", "view"],
VIEWER: ["view"],
}
# Snippet 权限:{ Model: { group_name: [action, ...] } },未列出的角色即不授予任何权限
SNIPPET_PERMISSIONS = {
TeamMember: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
Testimonial: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
Partner: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
NavigationMenu: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
FormDefinition: {
SITE_ADMIN: ["add", "change", "delete", "view"],
EDITOR: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
# Lead 涉及 PIPL 个人信息,收紧至站点管理员可管理、审核员仅查看
Lead: {
SITE_ADMIN: ["add", "change", "delete", "view"],
REVIEWER: ["view"],
},
# SiteSettings 是站点级单例配置,仅站点管理员可修改
SiteSettings: {
SITE_ADMIN: ["change", "view"],
},
}
# Collection(图片/文档)权限:{ app_label: model } 用于定位对应的 ContentType
COLLECTION_CONTENT_TYPES = {
"image": "wagtailimages",
"document": "wagtaildocs",
}
COLLECTION_PERMISSIONS = {
SITE_ADMIN: ["add", "change", "delete", "choose", "view"],
EDITOR: ["add", "change", "choose", "view"],
AUTHOR: ["add", "change", "choose", "view"],
REVIEWER: ["choose", "view"],
}
# 用户管理权限(Django auth.User 的增删改查)仅授予站点管理员
USER_MANAGEMENT_GROUPS = [SITE_ADMIN]
class Command(BaseCommand):
help = "根据 RBAC 角色矩阵(设计方案 §2.8)创建/重置 Wagtail Group 权限,可重复执行。"
def handle(self, *args, **options):
groups = {name: Group.objects.get_or_create(name=name)[0] for name in ALL_GROUP_NAMES}
self._grant_admin_access(groups)
self._setup_page_permissions(groups)
self._setup_snippet_permissions(groups)
self._setup_collection_permissions(groups)
self._setup_user_management_permissions(groups)
self.stdout.write(
self.style.SUCCESS(f"RBAC 权限组已初始化/更新:{', '.join(ALL_GROUP_NAMES)}")
)
def _grant_admin_access(self, groups):
"""所有角色都需要能登录 Wagtail 后台。"""
access_admin = Permission.objects.get(
content_type__app_label="wagtailadmin", codename="access_admin"
)
for group in groups.values():
group.permissions.add(access_admin)
def _setup_page_permissions(self, groups):
root_page = Page.objects.get(depth=1)
for name in ALL_GROUP_NAMES:
group = groups[name]
GroupPagePermission.objects.filter(group=group, page=root_page).delete()
for action in PAGE_PERMISSIONS.get(name, []):
GroupPagePermission.objects.create(
group=group, page=root_page, permission_type=action
)
def _setup_snippet_permissions(self, groups):
for model, group_actions in SNIPPET_PERMISSIONS.items():
content_type = ContentType.objects.get_for_model(model)
model_name = model._meta.model_name
all_perms_for_model = Permission.objects.filter(content_type=content_type)
for name in ALL_GROUP_NAMES:
group = groups[name]
group.permissions.remove(*all_perms_for_model)
actions = group_actions.get(name, [])
if not actions:
continue
codenames = [f"{action}_{model_name}" for action in actions]
perms = Permission.objects.filter(
content_type=content_type, codename__in=codenames
)
group.permissions.add(*perms)
def _setup_collection_permissions(self, groups):
root_collection = Collection.get_first_root_node()
content_types = {
key: ContentType.objects.get(app_label=app_label, model=key)
for key, app_label in COLLECTION_CONTENT_TYPES.items()
}
all_perms = Permission.objects.filter(content_type__in=content_types.values())
for name in ALL_GROUP_NAMES:
group = groups[name]
GroupCollectionPermission.objects.filter(
group=group, collection=root_collection, permission__in=all_perms
).delete()
actions = COLLECTION_PERMISSIONS.get(name, [])
if not actions:
continue
for key, content_type in content_types.items():
codenames = [f"{action}_{key}" for action in actions]
perms = Permission.objects.filter(
content_type=content_type, codename__in=codenames
)
for perm in perms:
GroupCollectionPermission.objects.get_or_create(
group=group, collection=root_collection, permission=perm
)
def _setup_user_management_permissions(self, groups):
user_content_type = ContentType.objects.get_for_model(get_user_model())
all_user_perms = Permission.objects.filter(content_type=user_content_type)
for name in ALL_GROUP_NAMES:
groups[name].permissions.remove(*all_user_perms)
for name in USER_MANAGEMENT_GROUPS:
groups[name].permissions.add(*all_user_perms)
+148
View File
@@ -1,5 +1,6 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。""" """apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.core.blocks import FormBlock, TeamMemberChooserBlock from apps.core.blocks import FormBlock, TeamMemberChooserBlock
@@ -215,3 +216,150 @@ def test_site_settings_api_returns_defaults():
assert response.status_code == 200 assert response.status_code == 200
assert response.data["icp_url"] == "https://beian.miit.gov.cn/" assert response.data["icp_url"] == "https://beian.miit.gov.cn/"
# ---------------------------------------------------------------------------
# RBAC 权限组(setup_rbac_groups 管理命令)
# ---------------------------------------------------------------------------
def test_setup_rbac_groups_creates_all_expected_groups():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
expected_names = {"站点管理员", "编辑", "审核员", "作者", "查看者"}
assert expected_names <= set(Group.objects.values_list("name", flat=True))
def test_setup_rbac_groups_grants_access_admin_to_all_groups():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
for name in ["站点管理员", "编辑", "审核员", "作者", "查看者"]:
group = Group.objects.get(name=name)
assert group.permissions.filter(codename="access_admin").exists()
def test_setup_rbac_groups_page_permissions_match_role_matrix():
from django.contrib.auth.models import Group
from django.core.management import call_command
from wagtail.models import GroupPagePermission, Page
call_command("setup_rbac_groups")
root_page = Page.objects.get(depth=1)
def page_codenames(group_name):
group = Group.objects.get(name=group_name)
return set(
GroupPagePermission.objects.filter(group=group, page=root_page).values_list(
"permission__codename", flat=True
)
)
# 站点管理员拥有全部页面权限
assert page_codenames("站点管理员") == {
"add_page", "change_page", "publish_page", "delete_page",
"bulk_delete_page", "lock_page", "unlock_page", "view_page",
}
# 编辑可以创建/编辑但不能发布
editor_perms = page_codenames("编辑")
assert editor_perms == {"add_page", "change_page", "view_page"}
assert "publish_page" not in editor_perms
# 审核员可以编辑并发布(用于审批发布场景)
assert page_codenames("审核员") == {"change_page", "publish_page", "view_page"}
# 作者只有 add + view:只能创建页面,仅能编辑自己拥有的页面(Wagtail 内置 ownership 策略)
assert page_codenames("作者") == {"add_page", "view_page"}
assert "change_page" not in page_codenames("作者")
# 查看者仅能查看
assert page_codenames("查看者") == {"view_page"}
def test_setup_rbac_groups_author_can_only_edit_own_pages(home_page):
"""验证“Author 仅能编辑自己的页面”这一矩阵要求,由 Wagtail 内置的
OwnershipPermissionPolicy(仅 add 权限)自然实现,无需自定义 hook。"""
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
User = get_user_model()
author = User.objects.create_user(username="author-user", password="pass1234")
other_author = User.objects.create_user(username="other-author", password="pass1234")
author.groups.add(Group.objects.get(name="作者"))
other_author.groups.add(Group.objects.get(name="作者"))
own_page = SimpleContentPage(title="自己的页面", slug="own-page", owner=author)
home_page.add_child(instance=own_page)
others_page = SimpleContentPage(title="他人的页面", slug="others-page", owner=other_author)
home_page.add_child(instance=others_page)
assert own_page.permissions_for_user(author).can_edit() is True
assert others_page.permissions_for_user(author).can_edit() is False
def test_setup_rbac_groups_snippet_permissions_restrict_lead_to_admin_and_reviewer():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
def lead_codenames(group_name):
group = Group.objects.get(name=group_name)
return set(
group.permissions.filter(content_type__app_label="forms", codename__endswith="lead")
.values_list("codename", flat=True)
)
assert lead_codenames("站点管理员") == {"add_lead", "change_lead", "delete_lead", "view_lead"}
assert lead_codenames("审核员") == {"view_lead"}
assert lead_codenames("编辑") == set()
assert lead_codenames("作者") == set()
assert lead_codenames("查看者") == set()
def test_setup_rbac_groups_only_site_admin_has_user_management_permissions():
from django.contrib.auth.models import Group
from django.core.management import call_command
call_command("setup_rbac_groups")
User = get_user_model()
user_ct_app_label = User._meta.app_label
for name in ["编辑", "审核员", "作者", "查看者"]:
group = Group.objects.get(name=name)
assert not group.permissions.filter(
content_type__app_label=user_ct_app_label, content_type__model="user"
).exists()
site_admin = Group.objects.get(name="站点管理员")
site_admin_codenames = set(
site_admin.permissions.filter(
content_type__app_label=user_ct_app_label, content_type__model="user"
).values_list("codename", flat=True)
)
assert site_admin_codenames == {"add_user", "change_user", "delete_user", "view_user"}
def test_setup_rbac_groups_is_idempotent():
from django.contrib.auth.models import Group
from django.core.management import call_command
from wagtail.models import GroupPagePermission, Page
call_command("setup_rbac_groups")
call_command("setup_rbac_groups")
assert Group.objects.filter(name="站点管理员").count() == 1
group = Group.objects.get(name="站点管理员")
root_page = Page.objects.get(depth=1)
# 重复运行不应产生重复的页面权限记录
assert GroupPagePermission.objects.filter(
group=group, page=root_page, permission__codename="add_page"
).count() == 1
assert group.permissions.filter(codename="access_admin").count() == 1
+11 -1
View File
@@ -280,6 +280,16 @@ Next.js 侧对应 `revalidateTag` API Route,收到 webhook 后失效对应 ISR
落地方式:使用 Wagtail 原生 `Group` + `GroupPagePermission` + `collections` 权限,不需要额外引入 RBAC 框架。 落地方式:使用 Wagtail 原生 `Group` + `GroupPagePermission` + `collections` 权限,不需要额外引入 RBAC 框架。
**已落地(`python manage.py setup_rbac_groups`,幂等命令,`apps/core/management/commands/setup_rbac_groups.py`):**
- Super Admin 直接对应 Django `is_superuser=True`,不建单独 Group;其余 5 个角色对应 5 个 Wagtail `Group`:站点管理员/编辑/审核员/作者/查看者。
- 页面权限(`GroupPagePermission`,挂载于页面树根节点 `depth=1`,适用于当前单站点部署):站点管理员拥有全部类型(add/change/publish/delete/bulk_delete/lock/unlock/view);编辑拥有 add/change/view(无 publish,创建的页面停留在草稿态等待发布);审核员拥有 change/publish/view(用于打开页面并执行发布,属于对“页面编辑=查看”矩阵条目的必要放宽);**作者仅拥有 add/view(不含 change**——利用 Wagtail 内置 `OwnershipPermissionPolicy`:只有 `add` 权限时,用户可创建新页面,且只能编辑/删除自己拥有(`owner`)的页面,天然满足“仅自己”要求,无需自定义 `wagtail_hooks`;查看者仅有 view。
- Snippet 权限(`TeamMember`/`Testimonial`/`Partner`/`NavigationMenu`/`FormDefinition`):站点管理员与编辑均为 add/change/delete/view,审核员仅 view,作者/查看者无权限。`Lead`(线索,含 PIPL 个人信息)出于数据保护考虑做了比矩阵更严格的收紧:仅站点管理员可管理,审核员仅 view,编辑/作者均不授予。`SiteSettings` 作为站点级单例配置仅站点管理员可 change/view。
- Collection(图片/文档)权限(`GroupCollectionPermission`,挂载于根 Collection):站点管理员 add/change/delete/choose/view;编辑与作者 add/change/choose/view(便于上传 StreamField 图片);审核员 choose/view;查看者无。
- 用户管理权限(`auth.add_user`/`change_user`/`delete_user`/`view_user`)仅授予站点管理员,对应矩阵“用户管理”列;“Django Admin”列不通过 Group 授予(Django Admin 访问由账号的 `is_staff` 属性决定,仅超级用户具备)。
- 简化说明:设计方案 §2.9 的 Workflow 审核模块尚未实现,因此“提交审核/审批发布”当前通过“编辑者无 publish、审核员/站点管理员有 publish”的权限差异形成审核闸门,而非正式的 Wagtail `Workflow`/`TaskState`;待 Workflow 模块落地后可在现有 Group 基础上叠加 `GroupApprovalTask`,无需重新设计权限组。
- 测试:`apps/core/tests.py` 新增 7 个用例覆盖 Group 创建、`access_admin` 授予、页面权限矩阵、Author 仅编辑自己页面、Lead 权限收紧、用户管理权限范围、命令幂等性。
### 2.9 工作流与内容审核 ### 2.9 工作流与内容审核
采用 Wagtail 5+ 内置 `Workflow` 模块,定义 `草稿 → 编辑自检 → 审核 → 通过 → 定时发布`,并在 `TaskState` 变更时通过企业微信机器人 Webhook 通知责任人(替代原文档 Slack 方案)。 采用 Wagtail 5+ 内置 `Workflow` 模块,定义 `草稿 → 编辑自检 → 审核 → 通过 → 定时发布`,并在 `TaskState` 变更时通过企业微信机器人 Webhook 通知责任人(替代原文档 Slack 方案)。
@@ -376,9 +386,9 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
- [x] Snippet`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem``ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings``wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/``team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入 - [x] Snippet`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem``ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings``wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/``team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入
- [x] 预览模式:后端引入 `wagtail-headless-preview`0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts``getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证) - [x] 预览模式:后端引入 `wagtail-headless-preview`0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts``getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证)
- [x] 剩余 6 个 StreamField Block`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过 - [x] 剩余 6 个 StreamField Block`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过
- [x] RBAC 落地:`python manage.py setup_rbac_groups` 幂等命令创建 5 个 Wagtail Group(站点管理员/编辑/审核员/作者/查看者)并按 §2.8 矩阵配置 `GroupPagePermission`/`GroupCollectionPermission`/Snippet 与用户管理 `Permission`Author“仅自己”利用 Wagtail 内置 `OwnershipPermissionPolicy`(仅 add 权限)原生实现,无需自定义 hook;新增 7 个后端单元测试,全仓库 pytest 从 33 增至 40,全部通过
未完成(待规划排期): 未完成(待规划排期):
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
- [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入 - [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入
- [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持、字体/地图/验证码/CDN 国内可访问排查均已完成,§2.15 合规与本地化清单已全部完成 - [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持、字体/地图/验证码/CDN 国内可访问排查均已完成,§2.15 合规与本地化清单已全部完成