Compare commits

18 Commits
Author SHA1 Message Date
Zhengen 306068bc22 完善 .gitignore:补充 IDE/OS/日志/覆盖率等常见忽略规则 2026-08-13 14:39:33 +08:00
Zhengen 147107501b 补充测试覆盖率 + 新增部署引导命令 bootstrap_deployment
- 新增 apps/blog、apps/solutions、apps/home、apps/api 测试文件,覆盖标签/排序/
  草稿过滤、HomePage.subpage_types 回归测试、page_preview 预览接口
- apps/core/tests.py 新增健康检查端点、发布 webhook 信号、bootstrap_deployment
  命令的测试用例,pytest 用例数从 40 增至 65,全部通过
- 修复 apps/api/urls.py 中 PagePreviewAPIViewSet.get_object() 未捕获
  DoesNotExist 导致 500 而非文档承诺 404 的 bug
- 新增 apps/core/management/commands/bootstrap_deployment.py:部署时按环境变量
  幂等创建首个超级管理员账号并联动初始化 RBAC 权限组,.env.example 补充对应
  环境变量说明
- 新增 OpenSearch + 中文分词本地 PoC 脚手架(docker-compose.opensearch.yml +
  docker/opensearch/Dockerfile),调研结论详见设计文档 §2.10
- 更新设计文档 §2.16 Phase 1 进度清单
2026-08-11 14:47:10 +08:00
Zhengen f37571f395 feat(rbac): 落地 Wagtail Group + GroupPagePermission/GroupCollectionPermission/Snippet 权限体系
新增 setup_rbac_groups 幂等管理命令,按设计方案 §2.8 矩阵创建 5 个 Group(站点管理员/编辑/审核员/作者/查看者),配置页面/图片文档/Snippet/用户管理权限;Author 仅编辑自己页面利用 Wagtail 内置 OwnershipPermissionPolicy 实现,无需自定义 hook;新增 7 个单元测试,pytest 33→40 全部通过。
2026-08-07 17:07:39 +08:00
Zhengen 3944e11330 feat(core): 补全剩余 6 个 StreamField Block (ProductCard/Pricing/Timeline/Team/TechStack/Video)
- apps/core/blocks.py 新增 ProductCardBlock/PricingBlock/TimelineBlock/TeamBlock/TechStackBlock/VideoBlock,COMMON_BLOCKS 扩充至 14 个内容 Block
- TeamBlock 新增 TeamMemberChooserBlock(SnippetChooserBlock),复用 FormBlock 已有的 get_api_representation 展开模式,展开 TeamMember 完整字段(含 serialize_image 复用)
- 为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 alter field body 迁移(StreamField block 定义变更会写入迁移 state)
- apps/core/tests.py 新增 TeamMemberChooserBlock 空值/展开单元测试,pytest 33 个用例全部通过
- 设计文档 §2.6/§2.16 同步更新为已实现
2026-08-07 15:58:44 +08:00
Zhengen 8d440b9e4a feat: 实现预览模式(wagtail-headless-preview + Next.js Draft Mode) 2026-08-07 15:34:46 +08:00
Zhengen 16e4f97e74 国内可访问服务替换排查:新增蜜罐反垃圾字段 + CDN 加速域名配置 2026-08-07 14:49:00 +08:00
Zhengen d6503bb46f feat: 实现PIPL数据主体删除权支持功能
- 新增 LeadDeletionRequest 模型,支持邮箱验证的删除申请/确认流程
- POST /api/v1/custom/leads/deletion-requests/ 发起申请(通用响应避免探测邮箱是否存在)
- POST /api/v1/custom/leads/deletion-requests/confirm/ 确认删除(24小时token有效期)
- 按邮箱在 Lead.data 中匹配并删除对应记录,跨SQLite/Postgres使用Python级匹配
- LeadDeletionRequest 通过 Django Admin 只读展示供合规审计
- 新增 FRONTEND_BASE_URL 设置用于拼接邮件确认链接
- apps/forms/tests.py 新增6个测试用例(含节流缓存隔离修复),全仓库30个用例通过
- 更新设计文档 §2.15/§2.16 勾选项
2026-08-07 13:29:20 +08:00
Zhengen 8fbbd2e6b0 feat: 新增Snippet全局内容模型(TeamMember/Testimonial/Partner/NavigationMenu/SiteSettings)
- 新增 apps.core.TeamMember/Testimonial/Partner:简单 register_snippet,含 order 排序字段
- 新增 apps.core.NavigationMenu+NavigationMenuItem:ClusterableModel+Orderable+InlinePanel,
  支持内部页面或外部链接二选一
- 新增 apps.core.SiteSettings:接入 wagtail.contrib.settings + BaseSiteSetting,
  存放公司信息/ICP备案号/社交账号等全局配置
- 新增只读 API:/api/v1/custom/core/{team,testimonials,partners,navigation,site-settings}/
- 补充 apps/core/tests.py 单元测试,pytest 用例由 11 个增至 24 个,全部通过
- 更新设计文档 §2.16 Phase 1 进度清单
2026-08-07 13:06:22 +08:00
Zhengen b04de9f5c1 docs: 修正Phase1进度清单中过期的复选框状态 2026-08-07 11:05:41 +08:00
Zhengen ec2e629db2 fix: 生产环境补充SECURE_PROXY_SSL_HEADER,完善白名单配置文档说明 2026-08-07 09:42:29 +08:00
Zhengen 4e4cdf31ac docs: Cookie同意条控件已完成,更新合规清单 2026-08-07 09:38:05 +08:00
Zhengen b05d2b7f52 feat: 新增隐私政策内容页(apps.core.SimpleContentPage)及seed命令,更新设计文档 2026-08-06 17:17:07 +08:00
Zhengen 9e60cabd5e docs: 页脚已替换为实际ICP备案号,更新合规清单状态 2026-08-06 16:54:34 +08:00
Zhengen e2a0f62ef4 docs: ICP备案已完成,更新合规清单状态 2026-08-06 16:37:40 +08:00
Zhengen e6fd8f62f4 docs: update design doc progress (solutions app + pytest suite completed) 2026-08-06 16:24:59 +08:00
Zhengen 052fff1a40 feat: add solutions app, fix HomePage subpage_types, add pytest test suite
- apps/solutions: SolutionIndexPage/SolutionPage (mirrors products/cases)
- apps/home: HomePage.subpage_types now includes products/solutions/cases index pages (previously only blog, blocking admin page creation)
- pytest.ini + conftest.py (root_page/home_page fixtures)
- unit tests for core (SEOablePage, FormBlock API repr), products, cases, forms (lead submit API)
2026-08-06 16:23:49 +08:00
Zhengen 12f7caa158 feat: add products/cases page models and forms lead-capture app
- apps/products: ProductIndexPage/ProductPage
- apps/cases: CaseStudyIndexPage/CaseStudyPage
- apps/forms: FormDefinition/FormDefinitionField/Lead + submit API
- core/blocks: CaseStudyBlock, FormBlock (with API representation)
- register new apps, custom leads API route, migrations
2026-08-06 16:04:21 +08:00
Zhengen 7f51a31150 chore: initial commit of Wagtail backend scaffold 2026-08-06 15:56:14 +08:00
111 changed files with 4727 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
# 复制为 .env 并按实际环境填写。生产环境密钥请使用密钥管理服务,切勿提交到仓库。
SECRET_KEY=change-me
ALLOWED_HOSTS=example.com,www.example.com
WAGTAILADMIN_BASE_URL=https://cms.example.com
# 数据库(生产环境,PostgreSQL)
DATABASE_URL=postgres://user:password@postgres:5432/enterprise_cms
# 缓存
REDIS_URL=redis://redis:6379/1
# 跨域 / CSRF 白名单(Next.js 前端域名)
CORS_ALLOWED_ORIGINS=https://www.example.com
CSRF_TRUSTED_ORIGINS=https://www.example.com
# 国内对象存储(阿里云 OSS / 腾讯云 COS,S3 兼容协议)
DEFAULT_FILE_STORAGE=storages.backends.s3boto3.S3Boto3Storage
OSS_ACCESS_KEY_ID=
OSS_SECRET_ACCESS_KEY=
OSS_BUCKET_NAME=
OSS_ENDPOINT_URL=
# 绑定到 OSS/COS 源站的 CDN 加速域名(阿里云 CDN / 腾讯云 CDN / 又拍云),留空则直接使用 OSS 源站地址
OSS_CDN_DOMAIN=
# 发布后前端 ISR 缓存失效 Webhook
FRONTEND_REVALIDATE_URL=https://www.example.com/api/revalidate
REVALIDATE_SECRET=change-me
# 前端站点基础 URL(用于邮件中的链接,如 PIPL 数据删除确认链接)
FRONTEND_BASE_URL=https://www.example.com
# 部署引导(python manage.py bootstrap_deployment):首次部署时自动创建的超级管理员账号
# 三项均设置且该用户名不存在时才会创建;账号已存在则跳过,不会重置密码。
DJANGO_SUPERUSER_USERNAME=
DJANGO_SUPERUSER_EMAIL=
DJANGO_SUPERUSER_PASSWORD=
+60
View File
@@ -0,0 +1,60 @@
# ---- Python ----
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
.Python
build/
dist/
pip-wheel-metadata/
# ---- Django / Wagtail ----
*.sqlite3
*.sqlite3-journal
.env
.env.*
!.env.example
/media/
/staticfiles/
/static/
# ---- 虚拟环境 ----
.venv/
venv/
env/
ENV/
# ---- 测试 / 覆盖率 ----
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.mypy_cache/
.ruff_cache/
# ---- 日志 / 本地临时文件 ----
*.log
*.tmp
.git_commit_msg.txt
pytest_output.txt
pytest_full_output.txt
# ---- IDE / 编辑器 ----
.vscode/
.idea/
*.swp
*.swo
# ---- 操作系统 ----
.DS_Store
Thumbs.db
desktop.ini
# ---- 前端(node_modules/.next 以防万一在根目录出现,正常情况下由 frontend/ 独立仓库管理)----
node_modules/
.next/
# frontend 是独立的 Next.js 仓库(自带 .git),不纳入后端仓库版本控制
/frontend/
-2
View File
@@ -1,2 +0,0 @@
# wagtailcms
View File
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"
label = "api"
verbose_name = "API"
+47
View File
@@ -0,0 +1,47 @@
"""apps.api 单元测试:预览专用接口 `page_preview`apps/api/urls.py 的
PagePreviewAPIViewSet),覆盖 §2.7/§2.15 描述的"仅凭有效 content_type+token
才能读取草稿快照"行为。"""
import pytest
from rest_framework.test import APIClient
pytestmark = pytest.mark.django_db
@pytest.fixture
def preview_token(home_page):
"""为 home_page 生成一条预览快照记录,返回可用于查询接口的 token。"""
preview = home_page.create_page_preview()
preview.save()
return preview.token
def test_page_preview_returns_draft_snapshot_for_valid_token(home_page, preview_token):
client = APIClient()
response = client.get(
"/api/v2/page_preview/",
{"content_type": "home.homepage", "token": preview_token},
)
assert response.status_code == 200
assert response.data["title"] == home_page.title
def test_page_preview_returns_404_for_unknown_token(home_page):
client = APIClient()
response = client.get(
"/api/v2/page_preview/",
{"content_type": "home.homepage", "token": "does-not-exist"},
)
assert response.status_code == 404
def test_page_preview_returns_404_for_mismatched_content_type(home_page, preview_token):
"""token 是为 home.homepage 生成的,用错误的 content_type 查询应查不到记录。"""
client = APIClient()
response = client.get(
"/api/v2/page_preview/",
{"content_type": "blog.blogpage", "token": preview_token},
)
assert response.status_code == 404
+62
View File
@@ -0,0 +1,62 @@
"""
Wagtail API v2 路由配置。
详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。
"""
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from rest_framework.response import Response
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
from wagtail_headless_preview.models import PagePreview
api_router = WagtailAPIRouter("wagtailapi")
api_router.register_endpoint("pages", PagesAPIViewSet)
api_router.register_endpoint("images", ImagesAPIViewSet)
api_router.register_endpoint("documents", DocumentsAPIViewSet)
class PagePreviewAPIViewSet(PagesAPIViewSet):
"""
预览模式专用只读接口:根据 `content_type`app_label.model,小写)+ `token`
从 `wagtail_headless_preview` 存储的草稿快照中取出对应页面实例并序列化返回。
`token` 由编辑器在 Wagtail 后台点击"预览"时生成(详见 §2.15/§2.7),
未知或已过期的 token 会返回 404,不会暴露任何非预览用途的草稿数据。
"""
known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
["content_type", "token"]
)
def listing_view(self, request):
# 统一走 detail_view,保证序列化格式与详情接口一致;pk 仅占位,实际查找依赖 content_type+token。
self.action = "detail_view"
return self.detail_view(request, 0)
def detail_view(self, request, pk):
page = self.get_object()
serializer = self.get_serializer(page)
return Response(serializer.data)
def get_object(self):
try:
app_label, model = self.request.GET["content_type"].split(".")
content_type = ContentType.objects.get(app_label=app_label, model=model)
page_preview = PagePreview.objects.get(
content_type=content_type, token=self.request.GET["token"]
)
except (KeyError, ValueError, ContentType.DoesNotExist, PagePreview.DoesNotExist):
# 缺少参数/content_type 格式错误/未知或已过期的 token,统一返回 404,
# 不暴露内部异常细节(详见类文档字符串)。
raise Http404("No matching page preview found.")
page = page_preview.as_page()
if not page.pk:
# 新建(尚未保存)页面的预览没有真实主键,填充占位值避免 API 路由生成 URL 时报错
page.pk = 0
return page
api_router.register_endpoint("page_preview", PagePreviewAPIViewSet)
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"
label = "blog"
verbose_name = "技术博客"
+72
View File
@@ -0,0 +1,72 @@
# Generated by Django 6.0.5 on 2026-08-06 05:29
import django.db.models.deletion
import modelcluster.contrib.taggit
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='BlogIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '博客栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('published_at', models.DateTimeField(verbose_name='发布时间')),
('intro', models.CharField(blank=True, max_length=250, verbose_name='摘要')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='封面图')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '博客文章',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPageTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.blogpage')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='blogpage',
name='tags',
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.BlogPageTag', to='taggit.Tag', verbose_name='标签'),
),
]
File diff suppressed because one or more lines are too long
View File
+75
View File
@@ -0,0 +1,75 @@
from django.db import models
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class BlogIndexPage(SEOablePage):
"""博客列表页,本身不直接管理内容,子页面为 BlogPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["blog.BlogPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "博客栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["posts"] = (
BlogPage.objects.live().descendant_of(self).order_by("-published_at")
)
return context
class BlogPageTag(TaggedItemBase):
content_object = ParentalKey(
"BlogPage", related_name="tagged_items", on_delete=models.CASCADE
)
class BlogPage(SEOablePage):
"""技术博客文章页。"""
published_at = models.DateTimeField(verbose_name="发布时间")
intro = models.CharField(max_length=250, blank=True, verbose_name="摘要")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="封面图",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
tags = ClusterTaggableManager(through=BlogPageTag, blank=True, verbose_name="标签")
search_fields = SEOablePage.search_fields + [
index.SearchField("intro"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("published_at"),
FieldPanel("intro"),
FieldPanel("cover_image"),
FieldPanel("body"),
FieldPanel("tags"),
]
parent_page_types = ["blog.BlogIndexPage"]
subpage_types = []
class Meta:
verbose_name = "博客文章"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for post in posts %}
<li><a href="{% pageurl post %}">{{ post.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.published_at }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
"""apps.blog 单元测试:页面树结构、标签与列表排序。"""
import pytest
from django.utils import timezone
from apps.blog.models import BlogIndexPage, BlogPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def blog_index(home_page):
index = BlogIndexPage(title="博客", slug="blog", intro="技术与行业洞察")
home_page.add_child(instance=index)
return index
def test_blog_page_can_be_created_under_index(blog_index):
post = BlogPage(
title="文章一",
slug="post-1",
published_at=timezone.now(),
intro="摘要一",
)
blog_index.add_child(instance=post)
assert BlogPage.objects.live().descendant_of(blog_index).count() == 1
def test_blog_page_supports_tags(blog_index):
post = BlogPage(
title="文章一",
slug="post-1",
published_at=timezone.now(),
)
blog_index.add_child(instance=post)
post.tags.add("Django", "Wagtail")
post.save()
post.refresh_from_db()
assert {t.name for t in post.tags.all()} == {"Django", "Wagtail"}
def test_blog_index_context_orders_by_published_at_desc(blog_index):
older = BlogPage(
title="较早的文章",
slug="older-post",
published_at=timezone.now() - timezone.timedelta(days=5),
)
blog_index.add_child(instance=older)
newer = BlogPage(
title="较新的文章",
slug="newer-post",
published_at=timezone.now(),
)
blog_index.add_child(instance=newer)
context = blog_index.get_context(request=None)
titles = [p.title for p in context["posts"]]
assert titles == ["较新的文章", "较早的文章"]
def test_blog_index_context_excludes_draft_posts(blog_index):
live_post = BlogPage(
title="已发布文章",
slug="live-post",
published_at=timezone.now(),
)
blog_index.add_child(instance=live_post)
draft_post = BlogPage(
title="草稿文章",
slug="draft-post",
published_at=timezone.now(),
live=False,
)
blog_index.add_child(instance=draft_post)
context = blog_index.get_context(request=None)
titles = [p.title for p in context["posts"]]
assert "已发布文章" in titles
assert "草稿文章" not in titles
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class CasesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.cases"
label = "cases"
verbose_name = "客户案例"
+55
View File
@@ -0,0 +1,55 @@
# Generated by Django 6.0.5 on 2026-08-06 07:59
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='CaseStudyIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '客户案例栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='CaseStudyPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('client_name', models.CharField(max_length=100, verbose_name='客户名称')),
('industry', models.CharField(blank=True, max_length=100, verbose_name='所属行业')),
('published_at', models.DateTimeField(verbose_name='发布时间')),
('summary', models.CharField(blank=True, max_length=250, verbose_name='案例摘要')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('richtext', 37)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='封面图')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '客户案例',
},
bases=('wagtailcore.page',),
),
]
File diff suppressed because one or more lines are too long
View File
+70
View File
@@ -0,0 +1,70 @@
"""客户案例栏目与案例详情页。详见 documents/设计方案分析与完善版.md §2.5 页面树结构。"""
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class CaseStudyIndexPage(SEOablePage):
"""客户案例栏目列表页,子页面为 CaseStudyPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["cases.CaseStudyPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "客户案例栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["case_studies"] = (
CaseStudyPage.objects.live().descendant_of(self).order_by("-published_at")
)
return context
class CaseStudyPage(SEOablePage):
"""单个客户案例详情页。"""
client_name = models.CharField(max_length=100, verbose_name="客户名称")
industry = models.CharField(max_length=100, blank=True, verbose_name="所属行业")
published_at = models.DateTimeField(verbose_name="发布时间")
summary = models.CharField(max_length=250, blank=True, verbose_name="案例摘要")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="封面图",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
search_fields = SEOablePage.search_fields + [
index.SearchField("client_name"),
index.SearchField("summary"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("client_name"),
FieldPanel("industry"),
FieldPanel("published_at"),
FieldPanel("summary"),
FieldPanel("cover_image"),
FieldPanel("body"),
]
parent_page_types = ["cases.CaseStudyIndexPage"]
subpage_types = []
class Meta:
verbose_name = "客户案例"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for case in case_studies %}
<li><a href="{% pageurl case %}">{{ case.title }}{{ case.client_name }}</a></li>
{% endfor %}
</ul>
</body>
</html>
@@ -0,0 +1,13 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.client_name }} · {{ page.industry }}</p>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
"""apps.cases 单元测试:页面树结构与列表排序。"""
import pytest
from django.utils import timezone
from apps.cases.models import CaseStudyIndexPage, CaseStudyPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def case_index(home_page):
index = CaseStudyIndexPage(title="客户案例", slug="cases", intro="客户的成功案例")
home_page.add_child(instance=index)
return index
def test_case_study_page_can_be_created_under_index(case_index):
case = CaseStudyPage(
title="某集团数字化转型",
slug="some-group",
client_name="某集团",
industry="制造业",
published_at=timezone.now(),
)
case_index.add_child(instance=case)
assert CaseStudyPage.objects.live().descendant_of(case_index).count() == 1
def test_case_index_context_orders_by_published_at_desc(case_index):
older = CaseStudyPage(
title="案例一",
slug="case-1",
client_name="客户一",
published_at=timezone.now() - timezone.timedelta(days=10),
)
case_index.add_child(instance=older)
newer = CaseStudyPage(
title="案例二",
slug="case-2",
client_name="客户二",
published_at=timezone.now(),
)
case_index.add_child(instance=newer)
context = case_index.get_context(request=None)
titles = [c.title for c in context["case_studies"]]
assert titles == ["案例二", "案例一"]
View File
+67
View File
@@ -0,0 +1,67 @@
"""
apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/
(详见 apps/core/urls.py 与 documents/设计方案分析与完善版.md §2.7)。
公开只读数据,限流走 DRF ScopedRateThrottle 的 "public" scope100/min)。
"""
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import NavigationMenu, Partner, SiteSettings, TeamMember, Testimonial
from .serializers import (
NavigationMenuSerializer,
PartnerSerializer,
SiteSettingsSerializer,
TeamMemberSerializer,
TestimonialSerializer,
)
class TeamMemberListView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, *args, **kwargs):
members = TeamMember.objects.all()
return Response({"items": TeamMemberSerializer(members, many=True).data})
class TestimonialListView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, *args, **kwargs):
testimonials = Testimonial.objects.all()
return Response({"items": TestimonialSerializer(testimonials, many=True).data})
class PartnerListView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, *args, **kwargs):
partners = Partner.objects.all()
return Response({"items": PartnerSerializer(partners, many=True).data})
class NavigationMenuView(APIView):
"""根据 ?name= 查询指定导航菜单(如 main / footer)。"""
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, *args, **kwargs):
name = request.query_params.get("name", "main")
menu = NavigationMenu.objects.prefetch_related("items").filter(name=name).first()
if menu is None:
return Response({"error": {"code": "NOT_FOUND", "message": "导航菜单不存在"}}, status=404)
return Response(NavigationMenuSerializer(menu).data)
class SiteSettingsView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "public"
def get(self, request, *args, **kwargs):
settings_obj = SiteSettings.for_request(request)
return Response(SiteSettingsSerializer(settings_obj).data)
+11
View File
@@ -0,0 +1,11 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
label = "core"
verbose_name = "核心 / 公共基础"
def ready(self):
from apps.core import signals # noqa: F401
+279
View File
@@ -0,0 +1,279 @@
"""
可复用的 StreamField Block 库。
参见 documents/设计方案分析与完善版.md §2.6 组件清单。
"""
from django.utils.translation import gettext_lazy as _
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock
from wagtail.snippets.blocks import SnippetChooserBlock
from .serializers import serialize_image
class CTAButtonBlock(blocks.StructBlock):
text = blocks.CharBlock(max_length=50, label=_("按钮文案"))
link = blocks.URLBlock(label=_("按钮链接"))
class Meta:
icon = "link"
label = _("按钮")
class HeroBlock(blocks.StructBlock):
title = blocks.CharBlock(max_length=120, label=_("标题"))
subtitle = blocks.TextBlock(required=False, label=_("副标题"))
background_image = ImageChooserBlock(required=False, label=_("背景图"))
buttons = blocks.ListBlock(CTAButtonBlock(), label=_("按钮组"))
class Meta:
icon = "image"
label = _("首屏 Hero")
template = "core/blocks/hero_block.html"
class StatItemBlock(blocks.StructBlock):
value = blocks.CharBlock(max_length=20, label=_("数值"))
label = blocks.CharBlock(max_length=50, label=_("说明文字"))
class StatsBlock(blocks.StructBlock):
items = blocks.ListBlock(StatItemBlock(), label=_("数据项"))
class Meta:
icon = "site"
label = _("数据展示")
template = "core/blocks/stats_block.html"
class FeatureBlock(blocks.StructBlock):
icon = blocks.CharBlock(max_length=50, required=False, label=_("图标"))
title = blocks.CharBlock(max_length=100, label=_("标题"))
description = blocks.TextBlock(label=_("描述"))
class Meta:
icon = "pick"
label = _("能力/优势")
template = "core/blocks/feature_block.html"
class FeatureGridBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
items = blocks.ListBlock(FeatureBlock(), label=_("能力列表"))
class Meta:
icon = "grip"
label = _("能力网格")
class FAQItemBlock(blocks.StructBlock):
question = blocks.CharBlock(max_length=200, label=_("问题"))
answer = blocks.RichTextBlock(label=_("回答"))
class FAQBlock(blocks.StructBlock):
items = blocks.ListBlock(FAQItemBlock(), label=_("问答列表"))
class Meta:
icon = "help"
label = _("常见问题")
template = "core/blocks/faq_block.html"
class CTABlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=120, label=_("标题"))
description = blocks.TextBlock(required=False, label=_("描述"))
button = CTAButtonBlock(label=_("按钮"))
class Meta:
icon = "plus"
label = _("行动号召 CTA")
template = "core/blocks/cta_block.html"
class LogoCloudBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("标题"))
logos = blocks.ListBlock(ImageChooserBlock(), label=_("Logo 列表"))
class Meta:
icon = "image"
label = _("客户 Logo 墙")
template = "core/blocks/logo_cloud_block.html"
class CaseStudyBlock(blocks.StructBlock):
case_page = blocks.PageChooserBlock(
page_type="cases.CaseStudyPage", label=_("案例页面")
)
summary = blocks.CharBlock(max_length=250, required=False, label=_("摘要覆盖"))
class Meta:
icon = "doc-full"
label = _("客户案例卡片")
class FormBlock(blocks.StructBlock):
"""嵌入可复用的线索表单(apps.forms.FormDefinition Snippet)。"""
form = SnippetChooserBlock("forms.FormDefinition", label=_("选择表单"))
class Meta:
icon = "form"
label = _("线索表单")
def get_api_representation(self, value, context=None):
"""
默认 SnippetChooserBlock 只会序列化为主键,前端渲染动态表单需要完整字段定义,
因此这里手动展开 FormDefinition 及其子字段(详见 apps/forms/models.py)。
"""
form_def = value.get("form")
if form_def is None:
return {"form": None}
return {
"form": {
"id": form_def.pk,
"name": form_def.name,
"submit_button_text": form_def.submit_button_text,
"success_message": form_def.success_message,
"fields": [
{
"label": field.label,
"field_key": field.field_key,
"field_type": field.field_type,
"required": field.required,
"choices": [
choice.strip()
for choice in field.choices.split(",")
if choice.strip()
],
}
for field in form_def.fields.all()
],
}
}
class ProductCardItemBlock(blocks.StructBlock):
title = blocks.CharBlock(max_length=100, label=_("标题"))
description = blocks.TextBlock(required=False, label=_("描述"))
image = ImageChooserBlock(required=False, label=_("图片"))
link = blocks.URLBlock(required=False, label=_("链接"))
class ProductCardBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
items = blocks.ListBlock(ProductCardItemBlock(), label=_("产品列表"))
class Meta:
icon = "grip"
label = _("产品矩阵")
class PricingPlanBlock(blocks.StructBlock):
name = blocks.CharBlock(max_length=50, label=_("方案名称"))
price = blocks.CharBlock(max_length=50, label=_("价格"))
features = blocks.ListBlock(
blocks.CharBlock(max_length=100), label=_("功能列表")
)
highlighted = blocks.BooleanBlock(
required=False, default=False, label=_("突出显示")
)
button = CTAButtonBlock(required=False, label=_("按钮"))
class PricingBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
plans = blocks.ListBlock(PricingPlanBlock(), label=_("定价方案"))
class Meta:
icon = "tag"
label = _("定价")
class TimelineItemBlock(blocks.StructBlock):
year = blocks.CharBlock(max_length=20, label=_("年份/时间"))
event = blocks.TextBlock(label=_("事件描述"))
class TimelineBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
items = blocks.ListBlock(TimelineItemBlock(), label=_("时间线条目"))
class Meta:
icon = "date"
label = _("发展历程")
class TeamMemberChooserBlock(SnippetChooserBlock):
"""选择 apps.core.TeamMember Snippet,展开完整字段供前端渲染(而非仅主键)。"""
def __init__(self, **kwargs):
super().__init__("core.TeamMember", **kwargs)
def get_api_representation(self, value, context=None):
if value is None:
return None
return {
"id": value.pk,
"name": value.name,
"role": value.role,
"bio": value.bio,
"photo": serialize_image(value.photo),
}
class TeamBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
members = blocks.ListBlock(TeamMemberChooserBlock(), label=_("团队成员"))
class Meta:
icon = "group"
label = _("团队展示")
class TechStackItemBlock(blocks.StructBlock):
icon = blocks.CharBlock(max_length=50, required=False, label=_("图标"))
label = blocks.CharBlock(max_length=50, label=_("名称"))
class TechStackBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("模块标题"))
items = blocks.ListBlock(TechStackItemBlock(), label=_("技术项"))
class Meta:
icon = "cogs"
label = _("技术架构")
class VideoBlock(blocks.StructBlock):
heading = blocks.CharBlock(max_length=100, required=False, label=_("标题"))
video_url = blocks.URLBlock(
label=_("视频地址"),
help_text=_(
"建议使用自建 OSS/COS 存储的 mp4 直链,或腾讯视频/哔哩哔哩等国内平台的"
"嵌入地址,避免使用 YouTube/Vimeo(详见 §2.15 国内可访问性要求)"
),
)
poster = ImageChooserBlock(required=False, label=_("封面图"))
class Meta:
icon = "media"
label = _("视频")
COMMON_BLOCKS = [
("hero", HeroBlock()),
("stats", StatsBlock()),
("feature_grid", FeatureGridBlock()),
("faq", FAQBlock()),
("cta", CTABlock()),
("logo_cloud", LogoCloudBlock()),
("case_study", CaseStudyBlock()),
("form", FormBlock()),
("product_card", ProductCardBlock()),
("pricing", PricingBlock()),
("timeline", TimelineBlock()),
("team", TeamBlock()),
("tech_stack", TechStackBlock()),
("video", VideoBlock()),
("richtext", blocks.RichTextBlock(label=_("富文本"))),
]
View File
@@ -0,0 +1,56 @@
"""
部署引导命令:首次上线(或每次部署)时自动完成"能登录后台 + 拥有正确权限组"这两件事,
避免每次部署都要人工登录服务器手动执行 `createsuperuser`。
用法:
python manage.py bootstrap_deployment
行为(幂等,可安全重复执行,适合放进部署脚本/容器启动脚本的 migrate 之后):
1. 若环境变量 DJANGO_SUPERUSER_USERNAME / DJANGO_SUPERUSER_EMAIL /
DJANGO_SUPERUSER_PASSWORD 均已设置,且该用户名尚不存在,则创建一个超级管理员账号。
- 若用户名已存在,跳过创建(不会重置密码,避免每次部署都覆盖已被人工修改过的密码)。
- 若三个环境变量未完整设置,跳过此步骤并给出提示(适用于已手动创建过超管、
或本地开发环境无需自动建号的场景)。
2. 调用 `setup_rbac_groups` 命令初始化/重置 RBAC 权限组(详见该命令的说明)。
安全说明:
- 密码只应通过环境变量/密钥管理服务注入,不会被写入日志。
- 本命令不会修改已存在用户的密码或权限,避免误覆盖人工调整过的账号状态。
"""
import os
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "部署引导:按需创建首个超级管理员账号 + 初始化 RBAC 权限组,可重复执行。"
def handle(self, *args, **options):
self._bootstrap_superuser()
call_command("setup_rbac_groups")
def _bootstrap_superuser(self):
username = os.environ.get("DJANGO_SUPERUSER_USERNAME")
email = os.environ.get("DJANGO_SUPERUSER_EMAIL")
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD")
if not (username and email and password):
self.stdout.write(
self.style.WARNING(
"未完整设置 DJANGO_SUPERUSER_USERNAME / DJANGO_SUPERUSER_EMAIL / "
"DJANGO_SUPERUSER_PASSWORD,跳过自动创建超级管理员账号。"
)
)
return
User = get_user_model()
if User.objects.filter(username=username).exists():
self.stdout.write(
self.style.SUCCESS(f"超级管理员账号 '{username}' 已存在,跳过创建。")
)
return
User.objects.create_superuser(username=username, email=email, password=password)
self.stdout.write(self.style.SUCCESS(f"已创建超级管理员账号 '{username}'"))
@@ -0,0 +1,93 @@
"""
创建/更新"隐私政策"页面(apps.core.SimpleContentPage),挂载在首页下。
用于满足 PIPL 合规清单要求(详见 documents/设计方案分析与完善版.md §2.15)。
用法:
python manage.py seed_privacy_policy
"""
from django.core.management.base import BaseCommand, CommandError
from apps.core.models import SimpleContentPage
from apps.home.models import HomePage
PRIVACY_POLICY_BODY = """
<h2>一、引言</h2>
<p>本公司(以下简称"我们")非常重视用户的个人信息保护。本《隐私政策》适用于用户访问本网站及使用本网站提供的各项服务(包括但不限于在线留言、需求咨询表单提交等)。请在使用本网站前仔细阅读并充分理解本政策的全部内容。</p>
<h2>二、我们收集的信息</h2>
<p>当您通过本网站的留资表单(如产品咨询、方案咨询等)主动提交信息时,我们可能收集以下类型的个人信息:</p>
<ul>
<li>姓名</li>
<li>联系电话</li>
<li>电子邮箱</li>
<li>所在公司/单位名称</li>
<li>您在留言/需求描述中主动提供的其他信息</li>
</ul>
<p>除上述信息外,我们不会主动收集与服务无关的个人敏感信息。</p>
<h2>三、我们如何使用您的信息</h2>
<p>我们收集上述信息仅用于以下目的:</p>
<ul>
<li>响应并处理您通过表单提交的咨询或需求;</li>
<li>与您联系以提供售前/售后服务、产品或方案介绍;</li>
<li>改善本网站的内容与用户体验;</li>
<li>履行法律法规要求的其他义务。</li>
</ul>
<p>未经您的同意,我们不会将您的个人信息用于上述目的以外的用途,也不会向无关第三方出售、共享您的个人信息。</p>
<h2>四、信息的存储与保护</h2>
<p>您的个人信息将存储于中国大陆境内的服务器,存储期限为实现收集目的所必需的期限,法律法规另有规定的除外。我们采取符合行业标准的技术措施(如访问控制、传输加密等)保护您的个人信息安全,防止信息被误用、未经授权访问、泄露、篡改或损毁。</p>
<h2>五、Cookie 的使用</h2>
<p>为提升用户体验,本网站可能使用 Cookie 或同类技术记录您的浏览偏好等非身份识别信息。您可以通过浏览器设置拒绝或清除 Cookie,但这可能影响部分网站功能的正常使用。</p>
<h2>六、您的权利</h2>
<p>依据《中华人民共和国个人信息保护法》(PIPL)等相关法律法规,您对我们持有的您的个人信息享有以下权利:</p>
<ul>
<li><strong>查阅权</strong>:您有权查阅我们收集的与您相关的个人信息;</li>
<li><strong>更正权</strong>:如您发现我们持有的个人信息有误,可要求我们更正;</li>
<li><strong>删除权</strong>:在符合法律规定的情形下,您可要求我们删除您的个人信息;</li>
<li><strong>撤回同意权</strong>:您可随时撤回此前提供个人信息时给予的同意。</li>
</ul>
<p>如需行使上述权利,请通过本政策"联系我们"部分列明的方式与我们联系,我们将在收到请求后的合理期限内予以处理。</p>
<h2>七、未成年人信息保护</h2>
<p>本网站主要面向企业客户提供产品与服务信息,不主动面向未成年人收集个人信息。如您是未成年人的监护人并发现其向我们提供了个人信息,请及时与我们联系,我们将尽快采取相应措施删除相关数据。</p>
<h2>八、政策的更新</h2>
<p>我们可能会根据法律法规变化或业务调整不时修订本政策,修订后的政策将在本页面公布并自公布之日起生效。如政策发生重大变更,我们会以网站公告等适当方式通知用户。</p>
<h2>九、联系我们</h2>
<p>如您对本隐私政策有任何疑问、意见或投诉,或需要行使您的个人信息相关权利,请通过网站留言表单与我们联系,我们将尽快回复处理。</p>
<p><em>本政策最近更新日期:2026 年 8 月。</em></p>
""".strip()
class Command(BaseCommand):
help = "创建或更新隐私政策页面(挂载于首页下)"
def handle(self, *args, **options):
home_page = HomePage.objects.live().first() or HomePage.objects.first()
if home_page is None:
raise CommandError(
"未找到 HomePage 实例,请先在 Wagtail 后台创建首页后再运行本命令。"
)
page = SimpleContentPage.objects.child_of(home_page).filter(slug="privacy-policy").first()
if page is None:
page = SimpleContentPage(
title="隐私政策",
slug="privacy-policy",
body=PRIVACY_POLICY_BODY,
seo_title_override="隐私政策",
seo_description_override="了解我们如何收集、使用和保护您的个人信息。",
)
home_page.add_child(instance=page)
page.save_revision().publish()
self.stdout.write(self.style.SUCCESS(f"已创建隐私政策页面:{page.url}"))
else:
page.body = PRIVACY_POLICY_BODY
page.save()
page.save_revision().publish()
self.stdout.write(self.style.SUCCESS(f"已更新隐私政策页面:{page.url}"))
@@ -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)
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-08-06 09:11
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='SimpleContentPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('body', wagtail.fields.RichTextField(blank=True, verbose_name='正文内容')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '通用内容页',
},
bases=('wagtailcore.page',),
),
]
@@ -0,0 +1,110 @@
# Generated by Django 6.0.5 on 2026-08-07 03:14
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='NavigationMenu',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)', max_length=50, unique=True, verbose_name='菜单标识')),
],
options={
'verbose_name': '导航菜单',
'verbose_name_plural': '导航菜单',
},
),
migrations.CreateModel(
name='NavigationMenuItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('label', models.CharField(max_length=50, verbose_name='文案')),
('link_url', models.CharField(blank=True, help_text='留空则使用内部页面链接', max_length=255, verbose_name='外部链接')),
('open_in_new_tab', models.BooleanField(default=False, verbose_name='新标签页打开')),
('link_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page', verbose_name='内部页面')),
('menu', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.navigationmenu')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
migrations.CreateModel(
name='Partner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('website_url', models.URLField(blank=True, verbose_name='官网链接')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Logo')),
],
options={
'verbose_name': '合作伙伴',
'verbose_name_plural': '合作伙伴',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司全称')),
('contact_phone', models.CharField(blank=True, max_length=30, verbose_name='联系电话')),
('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='联系邮箱')),
('address', models.CharField(blank=True, max_length=255, verbose_name='公司地址')),
('icp_number', models.CharField(blank=True, max_length=50, verbose_name='ICP备案号')),
('icp_url', models.URLField(default='https://beian.miit.gov.cn/', verbose_name='备案查询链接')),
('wechat_account', models.CharField(blank=True, max_length=100, verbose_name='微信公众号名称')),
('weibo_url', models.URLField(blank=True, verbose_name='微博主页')),
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')),
('wechat_qrcode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='微信公众号二维码')),
],
options={
'verbose_name': '站点设置',
},
),
migrations.CreateModel(
name='TeamMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, verbose_name='姓名')),
('role', models.CharField(blank=True, max_length=100, verbose_name='职位')),
('bio', models.TextField(blank=True, verbose_name='简介')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='照片')),
],
options={
'verbose_name': '团队成员',
'verbose_name_plural': '团队成员',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='Testimonial',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quote', models.TextField(verbose_name='推荐语')),
('author_name', models.CharField(max_length=50, verbose_name='客户姓名')),
('author_title', models.CharField(blank=True, max_length=100, verbose_name='客户职位/公司')),
('order', models.IntegerField(default=0, verbose_name='排序')),
('author_photo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='客户头像')),
],
options={
'verbose_name': '客户证言',
'verbose_name_plural': '客户证言',
'ordering': ['order', 'id'],
},
),
]
View File
+287
View File
@@ -0,0 +1,287 @@
"""
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
本文件同时存放跨页面复用的全局 SnippetTeamMember/Testimonial/Partner/
NavigationMenu/SiteSettings(详见 documents/设计方案分析与完善版.md §2.5、§2.16)。
"""
from django.db import models
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.contrib.settings.models import BaseSiteSetting
from wagtail.contrib.settings.registry import register_setting
from wagtail.fields import RichTextField
from wagtail.models import Orderable, Page
from wagtail.snippets.models import register_snippet
from wagtail_headless_preview.models import HeadlessPreviewMixin
class SEOablePage(HeadlessPreviewMixin, Page):
seo_title_override = models.CharField(
max_length=70, blank=True, verbose_name="SEO 标题",
help_text="留空则使用页面标题",
)
seo_description_override = models.CharField(
max_length=160, blank=True, verbose_name="SEO 描述",
)
og_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="社交分享图",
)
canonical_url = models.URLField(blank=True, verbose_name="Canonical URL")
schema_json = models.JSONField(blank=True, default=dict, verbose_name="结构化数据 (JSON-LD)")
promote_panels = Page.promote_panels + [
FieldPanel("seo_title_override"),
FieldPanel("seo_description_override"),
FieldPanel("og_image"),
FieldPanel("canonical_url"),
FieldPanel("schema_json"),
]
class Meta:
abstract = True
class SimpleContentPage(SEOablePage):
"""通用富文本内容页,用于隐私政策、服务条款等法务/说明类页面。"""
body = RichTextField(blank=True, verbose_name="正文内容")
content_panels = SEOablePage.content_panels + [
FieldPanel("body"),
]
parent_page_types = ["home.HomePage"]
subpage_types = []
class Meta:
verbose_name = "通用内容页"
@register_snippet
class TeamMember(models.Model):
"""团队成员,用于关于我们/团队介绍等场景复用。"""
name = models.CharField(max_length=50, verbose_name="姓名")
role = models.CharField(max_length=100, blank=True, verbose_name="职位")
photo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="照片",
)
bio = models.TextField(blank=True, verbose_name="简介")
order = models.IntegerField(default=0, verbose_name="排序")
panels = [
FieldPanel("name"),
FieldPanel("role"),
FieldPanel("photo"),
FieldPanel("bio"),
FieldPanel("order"),
]
class Meta:
ordering = ["order", "id"]
verbose_name = "团队成员"
verbose_name_plural = "团队成员"
def __str__(self):
return self.name
@register_snippet
class Testimonial(models.Model):
"""客户证言/推荐语,用于首页、案例页等场景复用。"""
quote = models.TextField(verbose_name="推荐语")
author_name = models.CharField(max_length=50, verbose_name="客户姓名")
author_title = models.CharField(
max_length=100, blank=True, verbose_name="客户职位/公司"
)
author_photo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="客户头像",
)
order = models.IntegerField(default=0, verbose_name="排序")
panels = [
FieldPanel("quote"),
FieldPanel("author_name"),
FieldPanel("author_title"),
FieldPanel("author_photo"),
FieldPanel("order"),
]
class Meta:
ordering = ["order", "id"]
verbose_name = "客户证言"
verbose_name_plural = "客户证言"
def __str__(self):
return f"{self.author_name} - {self.quote[:20]}"
@register_snippet
class Partner(models.Model):
"""合作伙伴/客户 Logo,用于 Logo 墙展示。"""
name = models.CharField(max_length=100, verbose_name="名称")
logo = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="Logo",
)
website_url = models.URLField(blank=True, verbose_name="官网链接")
order = models.IntegerField(default=0, verbose_name="排序")
panels = [
FieldPanel("name"),
FieldPanel("logo"),
FieldPanel("website_url"),
FieldPanel("order"),
]
class Meta:
ordering = ["order", "id"]
verbose_name = "合作伙伴"
verbose_name_plural = "合作伙伴"
def __str__(self):
return self.name
@register_snippet
class NavigationMenu(ClusterableModel):
"""导航菜单容器,通过 name 区分主导航/页脚导航等多套菜单。"""
name = models.CharField(
max_length=50,
unique=True,
verbose_name="菜单标识",
help_text="前端按此标识获取菜单,如 main(主导航)、footer(页脚导航)",
)
panels = [
FieldPanel("name"),
InlinePanel("items", label="菜单项"),
]
class Meta:
verbose_name = "导航菜单"
verbose_name_plural = "导航菜单"
def __str__(self):
return self.name
class NavigationMenuItem(Orderable):
"""导航菜单项:可链接到内部页面,也可填写外部链接。"""
menu = ParentalKey(
NavigationMenu, on_delete=models.CASCADE, related_name="items"
)
label = models.CharField(max_length=50, verbose_name="文案")
link_page = models.ForeignKey(
Page,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="内部页面",
)
link_url = models.CharField(
max_length=255,
blank=True,
verbose_name="外部链接",
help_text="留空则使用内部页面链接",
)
open_in_new_tab = models.BooleanField(default=False, verbose_name="新标签页打开")
panels = [
FieldPanel("label"),
FieldPanel("link_page"),
FieldPanel("link_url"),
FieldPanel("open_in_new_tab"),
]
@property
def url(self):
if self.link_url:
return self.link_url
if self.link_page_id:
return self.link_page.url
return "#"
def __str__(self):
return self.label
@register_setting(icon="cogs")
class SiteSettings(BaseSiteSetting):
"""站点全局配置:公司信息/联系方式/备案号/社交账号,供 Footer 等全局位置消费。"""
company_name = models.CharField(max_length=100, blank=True, verbose_name="公司全称")
contact_phone = models.CharField(max_length=30, blank=True, verbose_name="联系电话")
contact_email = models.EmailField(blank=True, verbose_name="联系邮箱")
address = models.CharField(max_length=255, blank=True, verbose_name="公司地址")
icp_number = models.CharField(max_length=50, blank=True, verbose_name="ICP备案号")
icp_url = models.URLField(
default="https://beian.miit.gov.cn/", verbose_name="备案查询链接"
)
wechat_qrcode = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="微信公众号二维码",
)
wechat_account = models.CharField(
max_length=100, blank=True, verbose_name="微信公众号名称"
)
weibo_url = models.URLField(blank=True, verbose_name="微博主页")
panels = [
MultiFieldPanel(
[
FieldPanel("company_name"),
FieldPanel("contact_phone"),
FieldPanel("contact_email"),
FieldPanel("address"),
],
heading="联系方式",
),
MultiFieldPanel(
[FieldPanel("icp_number"), FieldPanel("icp_url")],
heading="备案信息",
),
MultiFieldPanel(
[
FieldPanel("wechat_qrcode"),
FieldPanel("wechat_account"),
FieldPanel("weibo_url"),
],
heading="社交账号",
),
]
class Meta:
verbose_name = "站点设置"
+74
View File
@@ -0,0 +1,74 @@
"""
apps.core 全局 Snippet 的只读序列化器,供自定义业务接口
/api/v1/custom/core/...)使用。图片字段统一序列化为
{"url": ..., "title": ...},与前端 frontend/types/wagtail.ts
中既有的 background_image/logos 字段结构保持一致。
"""
from rest_framework import serializers
def serialize_image(image):
if not image:
return None
return {"url": image.file.url, "title": image.title}
class TeamMemberSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
role = serializers.CharField()
bio = serializers.CharField()
photo = serializers.SerializerMethodField()
def get_photo(self, obj):
return serialize_image(obj.photo)
class TestimonialSerializer(serializers.Serializer):
id = serializers.IntegerField()
quote = serializers.CharField()
author_name = serializers.CharField()
author_title = serializers.CharField()
author_photo = serializers.SerializerMethodField()
def get_author_photo(self, obj):
return serialize_image(obj.author_photo)
class PartnerSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
website_url = serializers.CharField()
logo = serializers.SerializerMethodField()
def get_logo(self, obj):
return serialize_image(obj.logo)
class NavigationMenuItemSerializer(serializers.Serializer):
label = serializers.CharField()
url = serializers.CharField()
open_in_new_tab = serializers.BooleanField()
class NavigationMenuSerializer(serializers.Serializer):
name = serializers.CharField()
items = serializers.SerializerMethodField()
def get_items(self, obj):
return NavigationMenuItemSerializer(obj.items.all(), many=True).data
class SiteSettingsSerializer(serializers.Serializer):
company_name = serializers.CharField()
contact_phone = serializers.CharField()
contact_email = serializers.CharField()
address = serializers.CharField()
icp_number = serializers.CharField()
icp_url = serializers.CharField()
wechat_account = serializers.CharField()
weibo_url = serializers.CharField()
wechat_qrcode = serializers.SerializerMethodField()
def get_wechat_qrcode(self, obj):
return serialize_image(obj.wechat_qrcode)
+27
View File
@@ -0,0 +1,27 @@
"""
Wagtail signal handlers:发布内容后通知 Next.js 前端失效 ISR 缓存。
详见 documents/设计方案分析与完善版.md §2.7。
"""
import logging
import requests
from django.conf import settings
from django.dispatch import receiver
from wagtail.signals import page_published
logger = logging.getLogger(__name__)
@receiver(page_published)
def notify_frontend_revalidate(sender, instance, **kwargs):
if not settings.FRONTEND_REVALIDATE_URL:
return
try:
requests.post(
settings.FRONTEND_REVALIDATE_URL,
json={"tags": [f"page:{instance.id}", instance.url_path]},
headers={"Authorization": f"Bearer {settings.REVALIDATE_SECRET}"},
timeout=3,
)
except requests.RequestException:
logger.warning("Failed to notify frontend revalidate webhook", exc_info=True)
@@ -0,0 +1,5 @@
<section class="cta">
<h2>{{ value.heading }}</h2>
{% if value.description %}<p>{{ value.description }}</p>{% endif %}
<a href="{{ value.button.link }}">{{ value.button.text }}</a>
</section>
@@ -0,0 +1,9 @@
{% load wagtailcore_tags %}
<section class="faq">
{% for item in value.items %}
<div class="faq-item">
<h4>{{ item.question }}</h4>
<div>{{ item.answer|richtext }}</div>
</div>
{% endfor %}
</section>
@@ -0,0 +1,4 @@
<section class="feature">
<h3>{{ value.title }}</h3>
<p>{{ value.description }}</p>
</section>
@@ -0,0 +1,8 @@
{% load wagtailcore_tags %}
<section class="hero">
<h1>{{ value.title }}</h1>
{% if value.subtitle %}<p>{{ value.subtitle }}</p>{% endif %}
{% for button in value.buttons %}
<a href="{{ button.link }}">{{ button.text }}</a>
{% endfor %}
</section>
@@ -0,0 +1,10 @@
{% load wagtailimages_tags %}
<section class="logo-cloud">
{% if value.heading %}<h3>{{ value.heading }}</h3>{% endif %}
<div class="logos">
{% for logo in value.logos %}
{% image logo width-160 as logo_img %}
<img src="{{ logo_img.url }}" alt="">
{% endfor %}
</div>
</section>
@@ -0,0 +1,5 @@
<section class="stats">
{% for item in value.items %}
<div class="stat"><strong>{{ item.value }}</strong><span>{{ item.label }}</span></div>
{% endfor %}
</section>
@@ -0,0 +1,9 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
{{ page.body|richtext }}
</body>
</html>
+534
View File
@@ -0,0 +1,534 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from apps.core.blocks import FormBlock, TeamMemberChooserBlock
from apps.core.models import (
NavigationMenu,
NavigationMenuItem,
Partner,
SimpleContentPage,
SiteSettings,
TeamMember,
Testimonial,
)
from apps.forms.models import FormDefinition, FormDefinitionField
pytestmark = pytest.mark.django_db
def test_seoable_page_fields_available_on_subclass(home_page):
"""SEOablePage 是抽象基类,其 SEO 字段应出现在具体子类(HomePage)上。"""
home_page.seo_title_override = "自定义标题"
home_page.seo_description_override = "自定义描述"
home_page.canonical_url = "https://example.com/"
home_page.schema_json = {"@type": "Organization"}
home_page.save()
home_page.refresh_from_db()
assert home_page.seo_title_override == "自定义标题"
assert home_page.seo_description_override == "自定义描述"
assert home_page.canonical_url == "https://example.com/"
assert home_page.schema_json == {"@type": "Organization"}
def test_form_block_api_representation_expands_fields():
"""FormBlock.get_api_representation 应展开 FormDefinition 及其子字段,
而不是仅返回 SnippetChooserBlock 默认的主键"""
form_def = FormDefinition.objects.create(
name="联系我们",
submit_button_text="立即提交",
success_message="已收到您的信息",
)
FormDefinitionField.objects.create(
form=form_def,
label="姓名",
field_key="name",
field_type="text",
required=True,
)
FormDefinitionField.objects.create(
form=form_def,
label="意向",
field_key="interest",
field_type="select",
required=False,
choices="产品咨询, 合作洽谈",
)
block = FormBlock()
result = block.get_api_representation({"form": form_def})
assert result["form"]["id"] == form_def.pk
assert result["form"]["name"] == "联系我们"
assert result["form"]["submit_button_text"] == "立即提交"
assert len(result["form"]["fields"]) == 2
assert result["form"]["fields"][1]["choices"] == ["产品咨询", "合作洽谈"]
def test_form_block_api_representation_handles_empty_form():
block = FormBlock()
result = block.get_api_representation({"form": None})
assert result == {"form": None}
def test_simple_content_page_can_be_created_under_home(home_page):
"""隐私政策等法务页面应可作为 HomePage 的子页面创建,并保留富文本内容。"""
page = SimpleContentPage(
title="隐私政策",
slug="privacy-policy",
body="<p>我们重视您的个人信息保护。</p>",
)
home_page.add_child(instance=page)
page.refresh_from_db()
assert SimpleContentPage.objects.live().descendant_of(home_page).count() == 1
assert "个人信息保护" in page.body
def test_team_member_default_ordering():
"""团队成员应按 order 字段升序排列。"""
TeamMember.objects.create(name="李四", order=2)
TeamMember.objects.create(name="张三", order=1)
names = list(TeamMember.objects.values_list("name", flat=True))
assert names == ["张三", "李四"]
def test_testimonial_str_includes_author_and_quote():
testimonial = Testimonial.objects.create(
quote="非常专业的服务,效果超出预期。", author_name="王经理"
)
assert "王经理" in str(testimonial)
def test_partner_str_returns_name():
partner = Partner.objects.create(name="示例合作伙伴")
assert str(partner) == "示例合作伙伴"
def test_navigation_menu_item_url_prefers_external_link_over_page(home_page):
menu = NavigationMenu.objects.create(name="main")
item = NavigationMenuItem.objects.create(
menu=menu, label="首页", link_page=home_page, link_url="https://example.com"
)
assert item.url == "https://example.com"
def test_navigation_menu_item_url_falls_back_to_page_url(home_page):
menu = NavigationMenu.objects.create(name="main")
item = NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page)
assert item.url == home_page.url
def test_navigation_menu_item_url_defaults_to_hash_when_empty():
menu = NavigationMenu.objects.create(name="main")
item = NavigationMenuItem.objects.create(menu=menu, label="占位")
assert item.url == "#"
def test_site_settings_for_site_creates_singleton_with_defaults():
from wagtail.models import Site
site = Site.objects.get(is_default_site=True)
settings_obj = SiteSettings.for_site(site)
assert settings_obj.icp_url == "https://beian.miit.gov.cn/"
# 再次获取应复用同一条记录而非重复创建
assert SiteSettings.for_site(site).pk == settings_obj.pk
def test_team_api_returns_ordered_members():
TeamMember.objects.create(name="李四", role="工程师", order=2)
TeamMember.objects.create(name="张三", role="总经理", order=1)
client = APIClient()
response = client.get("/api/v1/custom/core/team/")
assert response.status_code == 200
items = response.data["items"]
assert [item["name"] for item in items] == ["张三", "李四"]
assert items[0]["role"] == "总经理"
def test_testimonial_api_returns_list():
Testimonial.objects.create(quote="效果很好", author_name="客户A")
client = APIClient()
response = client.get("/api/v1/custom/core/testimonials/")
assert response.status_code == 200
assert response.data["items"][0]["author_name"] == "客户A"
def test_team_member_chooser_block_api_representation_expands_fields():
"""TeamMemberChooserBlock.get_api_representation 应展开 TeamMember 的完整字段,
而不是仅返回 SnippetChooserBlock 默认的主键"""
member = TeamMember.objects.create(name="王五", role="产品经理", bio="十年行业经验")
block = TeamMemberChooserBlock()
result = block.get_api_representation(member)
assert result["id"] == member.pk
assert result["name"] == "王五"
assert result["role"] == "产品经理"
assert result["bio"] == "十年行业经验"
assert result["photo"] is None
def test_team_member_chooser_block_api_representation_handles_none():
block = TeamMemberChooserBlock()
assert block.get_api_representation(None) is None
def test_partner_api_returns_list():
Partner.objects.create(name="伙伴A", website_url="https://partner-a.example.com")
client = APIClient()
response = client.get("/api/v1/custom/core/partners/")
assert response.status_code == 200
assert response.data["items"][0]["name"] == "伙伴A"
def test_navigation_menu_api_returns_items_by_name(home_page):
menu = NavigationMenu.objects.create(name="main")
NavigationMenuItem.objects.create(menu=menu, label="首页", link_page=home_page, sort_order=0)
NavigationMenuItem.objects.create(menu=menu, label="外部链接", link_url="https://example.com", sort_order=1)
client = APIClient()
response = client.get("/api/v1/custom/core/navigation/?name=main")
assert response.status_code == 200
assert response.data["name"] == "main"
assert len(response.data["items"]) == 2
assert response.data["items"][1]["url"] == "https://example.com"
def test_navigation_menu_api_404_when_missing():
client = APIClient()
response = client.get("/api/v1/custom/core/navigation/?name=does-not-exist")
assert response.status_code == 404
def test_site_settings_api_returns_defaults():
client = APIClient()
response = client.get("/api/v1/custom/core/site-settings/")
assert response.status_code == 200
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 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"}
# ---------------------------------------------------------------------------
# 健康检查端点(apps/core/views.py,供 K8s liveness/readiness 探针使用)
# ---------------------------------------------------------------------------
def test_healthz_returns_ok(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_readyz_returns_ok_when_database_available(client):
response = client.get("/readyz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_readyz_returns_503_when_database_unavailable(client, monkeypatch):
from django.db import connection
def broken_cursor(*args, **kwargs):
raise Exception("simulated db outage")
monkeypatch.setattr(connection, "cursor", broken_cursor)
response = client.get("/readyz")
assert response.status_code == 503
assert response.json()["status"] == "error"
# ---------------------------------------------------------------------------
# 发布后前端 revalidate webhookapps/core/signals.py
# ---------------------------------------------------------------------------
def test_notify_frontend_revalidate_skips_when_url_not_configured(
settings, monkeypatch, home_page
):
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = ""
calls = []
monkeypatch.setattr(
"apps.core.signals.requests.post",
lambda *args, **kwargs: calls.append((args, kwargs)),
)
page_published.send(sender=type(home_page), instance=home_page)
assert calls == []
def test_notify_frontend_revalidate_posts_webhook_when_configured(
settings, monkeypatch, home_page
):
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate"
settings.REVALIDATE_SECRET = "test-secret"
calls = []
def fake_post(url, json=None, headers=None, timeout=None):
calls.append({"url": url, "json": json, "headers": headers})
class FakeResponse:
status_code = 200
return FakeResponse()
monkeypatch.setattr("apps.core.signals.requests.post", fake_post)
page_published.send(sender=type(home_page), instance=home_page)
assert len(calls) == 1
assert calls[0]["url"] == "https://frontend.example.com/api/revalidate"
assert calls[0]["headers"] == {"Authorization": "Bearer test-secret"}
assert f"page:{home_page.id}" in calls[0]["json"]["tags"]
def test_notify_frontend_revalidate_swallows_request_exception(
settings, monkeypatch, home_page
):
import requests as requests_module
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate"
def raise_request_exception(*args, **kwargs):
raise requests_module.RequestException("network error")
monkeypatch.setattr("apps.core.signals.requests.post", raise_request_exception)
# 不应向外抛出异常——webhook 失败不能影响页面发布流程。
page_published.send(sender=type(home_page), instance=home_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
# ---------------------------------------------------------------------------
# 部署引导(bootstrap_deployment 管理命令)
# ---------------------------------------------------------------------------
def test_bootstrap_deployment_creates_superuser_from_env_vars(monkeypatch):
from django.core.management import call_command
monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin")
monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "deploy-admin@example.com")
monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "s3cret-pass")
call_command("bootstrap_deployment")
User = get_user_model()
user = User.objects.get(username="deploy-admin")
assert user.is_superuser is True
assert user.is_staff is True
assert user.email == "deploy-admin@example.com"
assert user.check_password("s3cret-pass") is True
def test_bootstrap_deployment_skips_superuser_creation_when_env_vars_missing(monkeypatch):
from django.core.management import call_command
monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False)
User = get_user_model()
count_before = User.objects.count()
call_command("bootstrap_deployment")
assert User.objects.count() == count_before
def test_bootstrap_deployment_does_not_overwrite_existing_superuser_password(monkeypatch):
from django.core.management import call_command
User = get_user_model()
User.objects.create_superuser(
username="deploy-admin", email="old@example.com", password="original-pass"
)
monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin")
monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "new@example.com")
monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "new-pass")
call_command("bootstrap_deployment")
user = User.objects.get(username="deploy-admin")
assert user.email == "old@example.com"
assert user.check_password("original-pass") is True
assert user.check_password("new-pass") is False
def test_bootstrap_deployment_also_initializes_rbac_groups(monkeypatch):
from django.contrib.auth.models import Group
from django.core.management import call_command
monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False)
call_command("bootstrap_deployment")
expected_names = {"站点管理员", "编辑", "审核员", "作者", "查看者"}
assert expected_names <= set(Group.objects.values_list("name", flat=True))
+17
View File
@@ -0,0 +1,17 @@
from django.urls import path
from .api import (
NavigationMenuView,
PartnerListView,
SiteSettingsView,
TeamMemberListView,
TestimonialListView,
)
urlpatterns = [
path("team/", TeamMemberListView.as_view(), name="team-list"),
path("testimonials/", TestimonialListView.as_view(), name="testimonial-list"),
path("partners/", PartnerListView.as_view(), name="partner-list"),
path("navigation/", NavigationMenuView.as_view(), name="navigation-menu"),
path("site-settings/", SiteSettingsView.as_view(), name="site-settings"),
]
+21
View File
@@ -0,0 +1,21 @@
"""
健康检查端点 Kubernetes liveness/readiness 探针使用
详见 documents/设计方案分析与完善版.md §2.12
"""
from django.db import connection
from django.http import JsonResponse
def healthz(request):
"""存活探针:仅确认进程可响应请求。"""
return JsonResponse({"status": "ok"})
def readyz(request):
"""就绪探针:确认数据库连接可用。"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception as exc: # pragma: no cover - defensive
return JsonResponse({"status": "error", "detail": str(exc)}, status=503)
return JsonResponse({"status": "ok"})
View File
+28
View File
@@ -0,0 +1,28 @@
"""
LeadDeletionRequest 通过 Django Admin Wagtail暴露为只读审计视图
供合规/客服人员查看 PIPL 数据删除申请处理记录详见
documents/设计方案分析与完善版.md §2.15
"""
from django.contrib import admin
from .models import LeadDeletionRequest
@admin.register(LeadDeletionRequest)
class LeadDeletionRequestAdmin(admin.ModelAdmin):
list_display = (
"contact",
"status",
"requested_at",
"completed_at",
"deleted_count",
)
list_filter = ("status",)
search_fields = ("contact",)
readonly_fields = [f.name for f in LeadDeletionRequest._meta.fields]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FormsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.forms"
label = "forms"
verbose_name = "线索表单"
+62
View File
@@ -0,0 +1,62 @@
# Generated by Django 6.0.5 on 2026-08-06 07:59
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='FormDefinition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='表单名称')),
('submit_button_text', models.CharField(default='提交', max_length=50, verbose_name='提交按钮文案')),
('success_message', models.CharField(default='提交成功,我们会尽快与您联系。', max_length=200, verbose_name='提交成功提示')),
('notification_email', models.EmailField(blank=True, max_length=254, verbose_name='通知邮箱')),
],
options={
'verbose_name': '线索表单',
'verbose_name_plural': '线索表单',
},
),
migrations.CreateModel(
name='FormDefinitionField',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('label', models.CharField(max_length=100, verbose_name='字段标签')),
('field_key', models.SlugField(help_text='提交数据中使用的 key,例如 name / email / phone', verbose_name='字段标识')),
('field_type', models.CharField(choices=[('text', '单行文本'), ('textarea', '多行文本'), ('email', '邮箱'), ('tel', '电话'), ('select', '下拉选择')], default='text', max_length=20, verbose_name='字段类型')),
('required', models.BooleanField(default=True, verbose_name='是否必填')),
('choices', models.CharField(blank=True, help_text='逗号分隔,仅“下拉选择”类型使用', max_length=255, verbose_name='可选项')),
('form', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='fields', to='forms.formdefinition')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
migrations.CreateModel(
name='Lead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.JSONField(default=dict, verbose_name='提交数据')),
('source_url', models.URLField(blank=True, verbose_name='来源页面')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')),
('form', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='leads', to='forms.formdefinition', verbose_name='来源表单')),
],
options={
'verbose_name': '线索',
'verbose_name_plural': '线索',
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,32 @@
# Generated by Django 6.0.5 on 2026-08-07 05:13
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('forms', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='LeadDeletionRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contact', models.EmailField(max_length=254, verbose_name='联系邮箱')),
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='确认令牌')),
('status', models.CharField(choices=[('pending', '待确认'), ('completed', '已完成'), ('expired', '已过期')], default='pending', max_length=20, verbose_name='状态')),
('requested_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='确认时间')),
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
('deleted_count', models.PositiveIntegerField(default=0, verbose_name='已删除记录数')),
],
options={
'verbose_name': '数据删除申请(PIPL',
'verbose_name_plural': '数据删除申请(PIPL',
'ordering': ['-requested_at'],
},
),
]
View File
+166
View File
@@ -0,0 +1,166 @@
"""
线索表单FormDefinition 是可复用的表单定义 Snippet通过 core.blocks.FormBlock
嵌入任意 StreamField 页面提交结果保存为 Lead
详见 documents/设计方案分析与完善版.md §2.6FormBlock B2B 官网核心转化路径
LeadDeletionRequest 支持 PIPL 数据主体删除权详见 §2.15
"""
import uuid
from datetime import timedelta
from django.db import models
from django.utils import timezone
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel
from wagtail.models import Orderable
from wagtail.snippets.models import register_snippet
FIELD_TYPE_CHOICES = [
("text", "单行文本"),
("textarea", "多行文本"),
("email", "邮箱"),
("tel", "电话"),
("select", "下拉选择"),
]
@register_snippet
class FormDefinition(ClusterableModel):
"""可复用的线索表单定义。"""
name = models.CharField(max_length=100, verbose_name="表单名称")
submit_button_text = models.CharField(
max_length=50, default="提交", verbose_name="提交按钮文案"
)
success_message = models.CharField(
max_length=200,
default="提交成功,我们会尽快与您联系。",
verbose_name="提交成功提示",
)
notification_email = models.EmailField(blank=True, verbose_name="通知邮箱")
panels = [
FieldPanel("name"),
FieldPanel("submit_button_text"),
FieldPanel("success_message"),
FieldPanel("notification_email"),
InlinePanel("fields", label="表单字段"),
]
class Meta:
verbose_name = "线索表单"
verbose_name_plural = "线索表单"
def __str__(self):
return self.name
class FormDefinitionField(Orderable):
"""FormDefinition 的子字段,编辑器在后台可自由增删排序。"""
form = ParentalKey(
FormDefinition, on_delete=models.CASCADE, related_name="fields"
)
label = models.CharField(max_length=100, verbose_name="字段标签")
field_key = models.SlugField(
max_length=50,
verbose_name="字段标识",
help_text="提交数据中使用的 key,例如 name / email / phone",
)
field_type = models.CharField(
max_length=20, choices=FIELD_TYPE_CHOICES, default="text", verbose_name="字段类型"
)
required = models.BooleanField(default=True, verbose_name="是否必填")
choices = models.CharField(
max_length=255,
blank=True,
verbose_name="可选项",
help_text="逗号分隔,仅“下拉选择”类型使用",
)
panels = [
FieldPanel("label"),
FieldPanel("field_key"),
FieldPanel("field_type"),
FieldPanel("required"),
FieldPanel("choices"),
]
@register_snippet
class Lead(models.Model):
"""表单提交产生的线索记录,供销售/市场团队在后台查看。"""
form = models.ForeignKey(
FormDefinition,
on_delete=models.SET_NULL,
null=True,
related_name="leads",
verbose_name="来源表单",
)
data = models.JSONField(default=dict, verbose_name="提交数据")
source_url = models.URLField(blank=True, verbose_name="来源页面")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间")
panels = [
FieldPanel("form"),
FieldPanel("data"),
FieldPanel("source_url"),
]
class Meta:
verbose_name = "线索"
verbose_name_plural = "线索"
ordering = ["-created_at"]
def __str__(self):
form_name = self.form.name if self.form_id else "(表单已删除)"
return f"{form_name} - {self.created_at:%Y-%m-%d %H:%M}"
class LeadDeletionRequest(models.Model):
"""PIPL 数据主体删除权申请记录(合规审计用途)。
匿名用户凭提交表单时留下的邮箱发起申请邮件确认后才会删除匹配的 Lead 记录
避免任意人凭他人邮箱发起删除仅支持邮箱验证暂未接入短信网关
手机号验证需待国内短信/验证码服务落地后再支持
"""
STATUS_PENDING = "pending"
STATUS_COMPLETED = "completed"
STATUS_EXPIRED = "expired"
STATUS_CHOICES = [
(STATUS_PENDING, "待确认"),
(STATUS_COMPLETED, "已完成"),
(STATUS_EXPIRED, "已过期"),
]
TOKEN_VALID_HOURS = 24
contact = models.EmailField(verbose_name="联系邮箱")
token = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, verbose_name="确认令牌"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_PENDING,
verbose_name="状态",
)
requested_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间")
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name="确认时间")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间")
deleted_count = models.PositiveIntegerField(default=0, verbose_name="已删除记录数")
class Meta:
verbose_name = "数据删除申请(PIPL"
verbose_name_plural = "数据删除申请(PIPL"
ordering = ["-requested_at"]
def __str__(self):
return f"{self.contact} - {self.get_status_display()}"
@property
def is_expired(self):
return timezone.now() > self.requested_at + timedelta(hours=self.TOKEN_VALID_HOURS)
+22
View File
@@ -0,0 +1,22 @@
from rest_framework import serializers
class LeadSubmitSerializer(serializers.Serializer):
form_id = serializers.IntegerField()
data = serializers.DictField()
source_url = serializers.URLField(required=False, allow_blank=True, default="")
# 蜂蜜陷阱反垃圾注册字段:正常用户不会看到/填写该字段,机器人填写则视为垃圾提交。
# 无需接入极验/腾讯云等第三方验证码服务即可降低自动化垃圾提交,详见设计文档 §2.15。
website = serializers.CharField(required=False, allow_blank=True, default="")
class LeadDeletionRequestSerializer(serializers.Serializer):
"""PIPL 数据删除权:发起申请时仅需提交联系邮箱。"""
contact = serializers.EmailField()
class LeadDeletionConfirmSerializer(serializers.Serializer):
"""PIPL 数据删除权:确认时提交邮件中的 token。"""
token = serializers.UUIDField()
+221
View File
@@ -0,0 +1,221 @@
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
from datetime import timedelta
import pytest
from django.core.cache import cache
from django.utils import timezone
from rest_framework.test import APIClient
from apps.forms.models import (
FormDefinition,
FormDefinitionField,
Lead,
LeadDeletionRequest,
)
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def _clear_throttle_cache():
"""ScopedRateThrottle 依赖默认缓存记录请求次数,避免测试间相互影响(forms scope 限流 5/min)。"""
cache.clear()
yield
@pytest.fixture
def contact_form():
form_def = FormDefinition.objects.create(
name="联系我们",
success_message="提交成功,我们会尽快联系您。",
)
FormDefinitionField.objects.create(
form=form_def, label="姓名", field_key="name", field_type="text", required=True
)
return form_def
def test_lead_submit_creates_lead_and_returns_success_message(contact_form):
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{
"form_id": contact_form.pk,
"data": {"name": "张三"},
"source_url": "https://example.com/contact",
},
format="json",
)
assert response.status_code == 201
assert response.data["success"] is True
assert response.data["message"] == contact_form.success_message
lead = Lead.objects.get()
assert lead.form_id == contact_form.pk
assert lead.data == {"name": "张三"}
assert lead.source_url == "https://example.com/contact"
def test_lead_submit_returns_404_for_unknown_form():
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{"form_id": 9999, "data": {}},
format="json",
)
assert response.status_code == 404
assert response.data["error"]["code"] == "NOT_FOUND"
def test_lead_submit_requires_form_id():
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{"data": {"name": "张三"}},
format="json",
)
assert response.status_code == 400
def test_lead_submit_honeypot_filled_silently_rejects_without_creating_lead(
contact_form,
):
"""蜜罐字段非空视为机器人:返回“成功”假象但不落库、不发通知邮件。"""
client = APIClient()
response = client.post(
"/api/v1/custom/leads/",
{
"form_id": contact_form.pk,
"data": {"name": "张三"},
"website": "http://spam.example.com",
},
format="json",
)
assert response.status_code == 201
assert response.data["success"] is True
assert not Lead.objects.exists()
# --- PIPL 数据删除权(详见设计文档 §2.15) ---
def test_deletion_request_returns_generic_message_and_sends_email(
contact_form, mailoutbox
):
Lead.objects.create(
form=contact_form,
data={"name": "张三", "email": "zhangsan@example.com"},
)
client = APIClient()
response = client.post(
"/api/v1/custom/leads/deletion-requests/",
{"contact": "zhangsan@example.com"},
format="json",
)
assert response.status_code == 202
assert LeadDeletionRequest.objects.filter(contact="zhangsan@example.com").exists()
assert len(mailoutbox) == 1
assert mailoutbox[0].to == ["zhangsan@example.com"]
def test_deletion_request_returns_same_generic_message_for_unknown_contact(mailoutbox):
"""未提交过表单的邮箱也返回相同提示,避免探测。"""
client = APIClient()
response_known = client.post(
"/api/v1/custom/leads/deletion-requests/",
{"contact": "known@example.com"},
format="json",
)
response_unknown = client.post(
"/api/v1/custom/leads/deletion-requests/",
{"contact": "unknown@example.com"},
format="json",
)
assert response_known.status_code == response_unknown.status_code == 202
assert response_known.data["message"] == response_unknown.data["message"]
def test_deletion_confirm_deletes_matching_leads_only(contact_form):
matching_lead = Lead.objects.create(
form=contact_form,
data={"name": "张三", "email": "zhangsan@example.com"},
)
other_lead = Lead.objects.create(
form=contact_form,
data={"name": "李四", "email": "lisi@example.com"},
)
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
client = APIClient()
response = client.post(
"/api/v1/custom/leads/deletion-requests/confirm/",
{"token": str(deletion_request.token)},
format="json",
)
assert response.status_code == 200
assert response.data["success"] is True
assert response.data["deleted_count"] == 1
assert not Lead.objects.filter(pk=matching_lead.pk).exists()
assert Lead.objects.filter(pk=other_lead.pk).exists()
deletion_request.refresh_from_db()
assert deletion_request.status == LeadDeletionRequest.STATUS_COMPLETED
assert deletion_request.deleted_count == 1
assert deletion_request.completed_at is not None
def test_deletion_confirm_invalid_token_returns_404():
client = APIClient()
response = client.post(
"/api/v1/custom/leads/deletion-requests/confirm/",
{"token": "00000000-0000-0000-0000-000000000000"},
format="json",
)
assert response.status_code == 404
assert response.data["error"]["code"] == "INVALID_TOKEN"
def test_deletion_confirm_expired_token_returns_400():
deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com")
LeadDeletionRequest.objects.filter(pk=deletion_request.pk).update(
requested_at=timezone.now() - timedelta(hours=25)
)
client = APIClient()
response = client.post(
"/api/v1/custom/leads/deletion-requests/confirm/",
{"token": str(deletion_request.token)},
format="json",
)
assert response.status_code == 400
assert response.data["error"]["code"] == "TOKEN_EXPIRED"
deletion_request.refresh_from_db()
assert deletion_request.status == LeadDeletionRequest.STATUS_EXPIRED
def test_deletion_confirm_already_processed_returns_400():
deletion_request = LeadDeletionRequest.objects.create(
contact="zhangsan@example.com",
status=LeadDeletionRequest.STATUS_COMPLETED,
)
client = APIClient()
response = client.post(
"/api/v1/custom/leads/deletion-requests/confirm/",
{"token": str(deletion_request.token)},
format="json",
)
assert response.status_code == 400
assert response.data["error"]["code"] == "ALREADY_PROCESSED"
+17
View File
@@ -0,0 +1,17 @@
from django.urls import path
from .views import LeadDeletionConfirmView, LeadDeletionRequestView, LeadSubmitView
urlpatterns = [
path("leads/", LeadSubmitView.as_view(), name="lead-submit"),
path(
"leads/deletion-requests/",
LeadDeletionRequestView.as_view(),
name="lead-deletion-request",
),
path(
"leads/deletion-requests/confirm/",
LeadDeletionConfirmView.as_view(),
name="lead-deletion-confirm",
),
]
+178
View File
@@ -0,0 +1,178 @@
"""
线索提交接口挂载在 /api/v1/custom/leads/详见 apps/forms/urls.py
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计
公开表单提交接口限流走 DRF ScopedRateThrottle "forms" scope5/min防刷
LeadDeletionRequestView/LeadDeletionConfirmView 支持 PIPL 数据主体删除权§2.15
"""
import json
import logging
from django.conf import settings
from django.core.mail import send_mail
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import FormDefinition, Lead, LeadDeletionRequest
from .serializers import (
LeadDeletionConfirmSerializer,
LeadDeletionRequestSerializer,
LeadSubmitSerializer,
)
logger = logging.getLogger(__name__)
class LeadSubmitView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "forms"
def post(self, request, *args, **kwargs):
serializer = LeadSubmitSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
form_id = serializer.validated_data["form_id"]
try:
form_def = FormDefinition.objects.get(pk=form_id)
except FormDefinition.DoesNotExist:
return Response(
{"error": {"code": "NOT_FOUND", "message": "表单不存在"}}, status=404
)
# 蜜罐反垃圾:honeypot 字段非空说明是自动化脚本提交,直接返回“成功”假象,
# 不落库、不发通知邮件,避免打草惊蛇(详见设计文档 §2.15 验证码/反垃圾方案)。
if serializer.validated_data.get("website"):
return Response(
{"success": True, "message": form_def.success_message}, status=201
)
lead = Lead.objects.create(
form=form_def,
data=serializer.validated_data["data"],
source_url=serializer.validated_data.get("source_url", ""),
)
if form_def.notification_email:
send_mail(
subject=f"【新线索】{form_def.name}",
message=json.dumps(lead.data, ensure_ascii=False, indent=2),
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
recipient_list=[form_def.notification_email],
fail_silently=True,
)
return Response(
{"success": True, "message": form_def.success_message}, status=201
)
# PIPL 数据删除权:出于隐私考虑,无论邮箱是否存在关联数据,均返回相同的通用提示,
# 避免被用来探测某个邮箱是否曾提交过表单。
GENERIC_DELETION_REQUEST_MESSAGE = (
"如果我们持有与该邮箱关联的信息,确认邮件将发送至该邮箱,请查收并点击链接完成删除确认。"
)
class LeadDeletionRequestView(APIView):
"""发起 PIPL 数据删除申请:POST {"contact": "user@example.com"}。"""
throttle_classes = [ScopedRateThrottle]
throttle_scope = "forms"
def post(self, request, *args, **kwargs):
serializer = LeadDeletionRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
contact = serializer.validated_data["contact"]
deletion_request = LeadDeletionRequest.objects.create(contact=contact)
confirm_url = (
f"{settings.FRONTEND_BASE_URL.rstrip('/')}/privacy-policy/delete-confirm"
f"?token={deletion_request.token}"
)
try:
send_mail(
subject="确认删除您的个人信息",
message=(
"我们收到了删除您个人信息的申请。如果这不是您本人操作,请忽略此邮件。\n\n"
f"请在 {LeadDeletionRequest.TOKEN_VALID_HOURS} 小时内点击以下链接确认删除:\n"
f"{confirm_url}"
),
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
recipient_list=[contact],
fail_silently=False,
)
except Exception:
logger.exception("发送删除确认邮件失败:%s", contact)
return Response({"message": GENERIC_DELETION_REQUEST_MESSAGE}, status=202)
class LeadDeletionConfirmView(APIView):
"""确认 PIPL 数据删除申请:POST {"token": "..."},校验通过后删除匹配的 Lead 记录。"""
throttle_classes = [ScopedRateThrottle]
throttle_scope = "forms"
def post(self, request, *args, **kwargs):
serializer = LeadDeletionConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
token = serializer.validated_data["token"]
try:
deletion_request = LeadDeletionRequest.objects.get(token=token)
except LeadDeletionRequest.DoesNotExist:
return Response(
{"error": {"code": "INVALID_TOKEN", "message": "删除链接无效。"}},
status=404,
)
if deletion_request.status != LeadDeletionRequest.STATUS_PENDING:
return Response(
{
"error": {
"code": "ALREADY_PROCESSED",
"message": "该删除申请已处理或已过期。",
}
},
status=400,
)
if deletion_request.is_expired:
deletion_request.status = LeadDeletionRequest.STATUS_EXPIRED
deletion_request.save(update_fields=["status"])
return Response(
{"error": {"code": "TOKEN_EXPIRED", "message": "删除链接已过期,请重新申请。"}},
status=400,
)
contact = deletion_request.contact.strip().lower()
matched_ids = [
lead.pk
for lead in Lead.objects.all()
if any(
isinstance(value, str) and value.strip().lower() == contact
for value in lead.data.values()
)
]
deleted_count = len(matched_ids)
if matched_ids:
Lead.objects.filter(pk__in=matched_ids).delete()
now = timezone.now()
deletion_request.status = LeadDeletionRequest.STATUS_COMPLETED
deletion_request.confirmed_at = now
deletion_request.completed_at = now
deletion_request.deleted_count = deleted_count
deletion_request.save(
update_fields=["status", "confirmed_at", "completed_at", "deleted_count"]
)
return Response(
{
"success": True,
"deleted_count": deleted_count,
"message": "已成功删除与该邮箱关联的个人信息。",
}
)
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.home"
label = "home"
verbose_name = "首页"
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-08-06 05:29
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='HomePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '首页',
},
bases=('wagtailcore.page',),
),
]
File diff suppressed because one or more lines are too long
View File
+26
View File
@@ -0,0 +1,26 @@
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class HomePage(SEOablePage):
"""企业官网首页,使用通用组件化 StreamField 拼装页面内容。"""
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
content_panels = SEOablePage.content_panels + [
FieldPanel("body"),
]
subpage_types = [
"blog.BlogIndexPage",
"products.ProductIndexPage",
"solutions.SolutionIndexPage",
"cases.CaseStudyIndexPage",
"core.SimpleContentPage",
]
class Meta:
verbose_name = "首页"
+11
View File
@@ -0,0 +1,11 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
"""apps.home 单元测试:HomePage 页面树结构。
test_home_page_subpage_types_allows_all_top_level_sections 是一条回归测试
此前 HomePage.subpage_types 曾经只包含 blog.BlogIndexPage导致 products/cases/
solutions IndexPage 无法通过 Wagtail 管理后台"添加子页面"界面创建subpage_types/
parent_page_types 只影响后台创建校验不影响 ORM add_child()问题一度被脚本化测试掩盖
"""
import pytest
from apps.blog.models import BlogIndexPage
from apps.cases.models import CaseStudyIndexPage
from apps.core.models import SimpleContentPage
from apps.home.models import HomePage
from apps.products.models import ProductIndexPage
from apps.solutions.models import SolutionIndexPage
pytestmark = pytest.mark.django_db
def test_home_page_subpage_types_allows_all_top_level_sections():
expected_types = {
"blog.BlogIndexPage",
"products.ProductIndexPage",
"solutions.SolutionIndexPage",
"cases.CaseStudyIndexPage",
"core.SimpleContentPage",
}
assert expected_types.issubset(set(HomePage.subpage_types))
@pytest.mark.parametrize(
"index_model,kwargs",
[
(BlogIndexPage, {"title": "博客", "slug": "blog"}),
(ProductIndexPage, {"title": "产品", "slug": "products"}),
(SolutionIndexPage, {"title": "解决方案", "slug": "solutions"}),
(CaseStudyIndexPage, {"title": "案例", "slug": "cases"}),
(SimpleContentPage, {"title": "隐私政策", "slug": "privacy-policy"}),
],
)
def test_each_top_level_section_can_be_created_under_home_in_admin(
home_page, index_model, kwargs
):
"""`can_create_at` 是 Wagtail 管理后台"添加子页面"按钮实际使用的判断(综合
parent.subpage_types child.parent_page_types 两个方向此前的 bug 正是
仅靠 ORM `add_child()` 无法暴露的它只影响管理后台不影响直接调用 ORM"""
assert index_model.can_create_at(home_page) is True
child = index_model(**kwargs)
home_page.add_child(instance=child)
assert index_model.objects.child_of(home_page).count() == 1
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"
label = "products"
verbose_name = "产品"
+52
View File
@@ -0,0 +1,52 @@
# Generated by Django 6.0.5 on 2026-08-06 07:59
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='ProductIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '产品栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='ProductPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('summary', models.CharField(blank=True, max_length=250, verbose_name='产品简介')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('richtext', 37)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='产品图片')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '产品页',
},
bases=('wagtailcore.page',),
),
]
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
"""产品栏目与产品详情页。详见 documents/设计方案分析与完善版.md §2.5 页面树结构。"""
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class ProductIndexPage(SEOablePage):
"""产品栏目列表页,子页面为 ProductPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["products.ProductPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "产品栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["products"] = (
ProductPage.objects.live().descendant_of(self).order_by("path")
)
return context
class ProductPage(SEOablePage):
"""单个产品详情页。"""
summary = models.CharField(max_length=250, blank=True, verbose_name="产品简介")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="产品图片",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
search_fields = SEOablePage.search_fields + [
index.SearchField("summary"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("summary"),
FieldPanel("cover_image"),
FieldPanel("body"),
]
parent_page_types = ["products.ProductIndexPage"]
subpage_types = []
class Meta:
verbose_name = "产品页"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for product in products %}
<li><a href="{% pageurl product %}">{{ product.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
@@ -0,0 +1,12 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
"""apps.products 单元测试:页面树结构与列表上下文。"""
import pytest
from apps.products.models import ProductIndexPage, ProductPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def product_index(home_page):
index = ProductIndexPage(title="产品", slug="products", intro="我们的产品")
home_page.add_child(instance=index)
return index
def test_product_page_can_be_created_under_index(product_index):
product = ProductPage(title="产品 A", slug="product-a", summary="简介 A")
product_index.add_child(instance=product)
assert ProductPage.objects.live().descendant_of(product_index).count() == 1
def test_product_index_context_lists_live_products_only(product_index):
live_product = ProductPage(title="产品 A", slug="product-a")
product_index.add_child(instance=live_product)
draft_product = ProductPage(title="产品 B", slug="product-b", live=False)
product_index.add_child(instance=draft_product)
context = product_index.get_context(request=None)
titles = [p.title for p in context["products"]]
assert "产品 A" in titles
assert "产品 B" not in titles
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class SolutionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.solutions"
label = "solutions"
verbose_name = "解决方案"
+53
View File
@@ -0,0 +1,53 @@
# Generated by Django 6.0.5 on 2026-08-06 08:14
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='SolutionIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '解决方案栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='SolutionPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('industry', models.CharField(blank=True, max_length=100, verbose_name='适用行业')),
('summary', models.CharField(blank=True, max_length=250, verbose_name='方案简介')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('case_study', 34), ('form', 36), ('richtext', 37)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.PageChooserBlock', (), {'label': '案例页面', 'page_type': ['cases.CaseStudyPage']}), 33: ('wagtail.blocks.CharBlock', (), {'label': '摘要覆盖', 'max_length': 250, 'required': False}), 34: ('wagtail.blocks.StructBlock', [[('case_page', 32), ('summary', 33)]], {}), 35: ('wagtail.snippets.blocks.SnippetChooserBlock', ('forms.FormDefinition',), {'label': '选择表单'}), 36: ('wagtail.blocks.StructBlock', [[('form', 35)]], {}), 37: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='方案配图')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '解决方案页',
},
bases=('wagtailcore.page',),
),
]
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
"""行业解决方案栏目与详情页。详见 documents/设计方案分析与完善版.md §2.5 页面树结构。"""
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class SolutionIndexPage(SEOablePage):
"""解决方案栏目列表页,子页面为 SolutionPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["solutions.SolutionPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "解决方案栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["solutions"] = (
SolutionPage.objects.live().descendant_of(self).order_by("path")
)
return context
class SolutionPage(SEOablePage):
"""单个行业解决方案详情页。"""
industry = models.CharField(max_length=100, blank=True, verbose_name="适用行业")
summary = models.CharField(max_length=250, blank=True, verbose_name="方案简介")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="方案配图",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
search_fields = SEOablePage.search_fields + [
index.SearchField("industry"),
index.SearchField("summary"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("industry"),
FieldPanel("summary"),
FieldPanel("cover_image"),
FieldPanel("body"),
]
parent_page_types = ["solutions.SolutionIndexPage"]
subpage_types = []
class Meta:
verbose_name = "解决方案页"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for solution in solutions %}
<li><a href="{% pageurl solution %}">{{ solution.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
@@ -0,0 +1,13 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.industry }}</p>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
"""apps.solutions 单元测试:页面树结构与列表上下文(与 apps.products/apps.cases 同构)。"""
import pytest
from apps.solutions.models import SolutionIndexPage, SolutionPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def solution_index(home_page):
index = SolutionIndexPage(title="解决方案", slug="solutions", intro="行业解决方案")
home_page.add_child(instance=index)
return index
def test_solution_page_can_be_created_under_index(solution_index):
solution = SolutionPage(
title="制造业解决方案",
slug="manufacturing",
industry="制造业",
summary="面向制造业的数字化转型方案",
)
solution_index.add_child(instance=solution)
assert SolutionPage.objects.live().descendant_of(solution_index).count() == 1
def test_solution_index_context_excludes_draft_solutions(solution_index):
live_solution = SolutionPage(title="方案 A", slug="solution-a")
solution_index.add_child(instance=live_solution)
draft_solution = SolutionPage(title="方案 B", slug="solution-b", live=False)
solution_index.add_child(instance=draft_solution)
context = solution_index.get_context(request=None)
titles = [s.title for s in context["solutions"]]
assert "方案 A" in titles
assert "方案 B" not in titles
+19
View File
@@ -0,0 +1,19 @@
import pytest
@pytest.fixture
def root_page():
"""Wagtail 初始迁移创建的树根页面(depth=1)。"""
from wagtail.models import Page
return Page.objects.get(depth=1)
@pytest.fixture
def home_page(root_page):
"""在树根下创建一个测试用 HomePage 实例。"""
from apps.home.models import HomePage
page = HomePage(title="首页", slug="home-test")
root_page.add_child(instance=page)
return page
+34
View File
@@ -0,0 +1,34 @@
# OpenSearch + 中文分词(IK 插件)本地开发 PoC 环境。
#
# 用途:验证 Wagtail 原生 opensearch2 后端 + infinilabs/analysis-ik 中文分词插件的可行性,
# 详见 documents/设计方案分析与完善版.md §2.10 "OpenSearch + 中文分词调研"。
# 这是一个独立的 compose 文件,不与主应用的部署编排耦合,按需单独启停:
#
# docker-compose -f docker-compose.opensearch.yml up -d
# curl http://localhost:9200/_cat/plugins # 确认 analysis-ik 已加载
# docker-compose -f docker-compose.opensearch.yml down -v # 清理(-v 连数据卷一起删)
#
# 注意:plugins.security.disabled=true 仅用于本地免证书调试,生产环境必须启用安全插件并配置证书。
services:
opensearch:
build:
context: ./docker/opensearch
container_name: wagtailcms-opensearch-dev
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9200:9200"
- "9600:9600"
volumes:
- opensearch-dev-data:/usr/share/opensearch/data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 10
volumes:
opensearch-dev-data:
+11
View File
@@ -0,0 +1,11 @@
# 本地开发/调研用镜像:在官方 OpenSearch 2.x 基础上安装 IK 中文分词插件(infinilabs/analysis-ik)。
# 仅用于本地开发环境的 PoC 验证;生产环境建议评估阿里云"开放搜索 OpenSearch 版"等托管方案,
# 或在自建时用同样的方式基于官方镜像打包(见 documents/设计方案分析与完善版.md §2.10)。
FROM opensearchproject/opensearch:2.19.6
# ⚠️ IK 插件版本必须与 OpenSearch 核心版本完全一致才能加载成功。
# 使用前请先到 https://release.infinilabs.com/ 确认是否存在与上面 FROM 版本号匹配的构建;
# 如没有完全匹配的版本,需要把 FROM 的 OpenSearch 版本号和下面的插件版本号同时改成
# release.infinilabs.com 上实际可用的最新匹配版本,本 Dockerfile 尚未在本地实际构建验证过。
RUN opensearch-plugin install --batch \
https://get.infini.cloud/opensearch/analysis-ik/2.19.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+426
View File
@@ -0,0 +1,426 @@
# 企业官网 Headless CMS 设计方案 —— 分析与完善版
> 本文档基于 `documents/` 目录下现有的 5 份 PDF 设计文档整理分析,并结合当前代码仓库的真实状态(`manage.py` + 裸 Django 6.0 项目骨架,尚未引入 Wagtail),产出一份可直接指导开发的**完善版设计方案**。
---
## 一、现有文档分析
### 1.1 文档清单
| 文档 | 定位 |
|---|---|
| 基于 Wagtail 的现代前后端分离 CMS 设计方案.pdf | 通用架构设计(30 章节,最全面) |
| Next.js + Wagtail 企业级 Headless CMS 完整项目代码架构.pdf | Monorepo + 代码架构细化 |
| Wagtail CMS Backend 完整源码(企业级 Headless CMS.pdf | 后端代码骨架示例 |
| Next.js 企业官网完整源码模板(基于 Wagtail Headless CMS.pdf | 前端代码骨架示例 |
| 软件公司企业官网设计方案(基于 Wagtail Headless CMS.pdf | 官网 IA / 页面 / 视觉设计 |
| 整体设计图.png | 首页高保真视觉稿(TechFlow 品牌示例) |
### 1.2 共性结论
5 份文档高度一致地收敛到同一技术选型:**Django 5 + Wagtail 7 + DRF + Strawberry GraphQL + Next.js 15 (React 19) + PostgreSQL + Redis + Celery + OpenSearch/Elasticsearch + MinIO/S3 + Docker/K8s**。这说明该技术栈选型是合理、成熟的,可以直接作为最终方案基线,**不需要重新论证**。
### 1.3 已发现的不足 / 待完善点
逐份文档都以"罗列技术名词 + 目录结构"为主,**普遍缺少可落地的细节**,具体归纳如下:
**A. 架构完整性缺口**
1. 只给出多租户"共享库 / Schema 隔离"两个方案名词,未给出本项目该如何取舍(本项目是单一企业官网,暂不需要多租户,过度设计)。
2. 没有数据库 ER 图 / 字段级设计,`Page` 模型都只写了 1-2 个示例字段。
3. 没有 Headless 预览(Preview)机制设计 —— 编辑在 Wagtail 后台点"预览"Next.js 如何展示草稿内容(draft mode / preview token)完全没有提及。
4. 没有"发布后前端缓存失效"的具体实现(只画了 `Content Published → Event Bus → Webhook → Frontend Revalidate` 的框图,没有落地到 Wagtail `page_published` signal + Next.js `revalidateTag` 的代码级方案)。
5. API 只给了 URL 列表,没有分页、排序、过滤、错误码、版本弃用策略等规范。
**B. 安全设计不落地**
6. 安全章节只列了名词(HTTPS/CSP/WAF/JWT Rotation…),没有给出 Django 层面应设置的具体 settings 项(`SECURE_HSTS_SECONDS``SESSION_COOKIE_SECURE``CSRF_TRUSTED_ORIGINS`、DRF 节流类等)。
7. `CORS_ALLOW_ALL_ORIGINS = True` 是示例代码里的真实隐患,生产环境必须收敛为白名单。
8. 没有考虑富文本 XSSWagtail RichTextField 需要配置白名单 `WAGTAILADMIN_RICH_TEXT_EDITORS` / `bleach`)。
**C. 本地化 / 合规缺失(重要)**
9. 所有文档默认使用 **AWS S3 + Cloudflare CDN + Google 字体生态**,但"整体设计图.png"高保真稿明确显示目标是**中国大陆企业官网**(简体中文导航、"沪ICP备xxxxxxxx号-1"、中国移动/招商银行/中国平安/上汽集团/美团 等客户 Logo)。这是一个关键但被忽视的落地风险:
- AWS S3/Cloudflare 在中国大陆访问速度和合规性都存在问题,需替换为 **阿里云 OSS/腾讯云 COS + 阿里云 CDN/腾讯云 CDN** 等国内方案。
- 网站备案:需要 **ICP 备案**(个人/企业备案流程、幕布拍照要求等),这会影响上线时间表,必须提前规划。
- 需要 **隐私政策 / Cookie 同意条**,遵循《个人信息保护法》(PIPL),而不是文档里默认的 GDPR 话术。
- Google Fonts / Google Maps 在中国大陆不可用,需要自托管字体(思源黑体/HarmonyOS Sans 子集化)、替换为高德/腾讯地图。
- 人机验证码建议用极验/腾讯云验证码而非 reCAPTCHA。
- 表单营销自动化提到 HubSpot/Salesforce,国内客户更常用企业微信 + 探迹/纷享销客等 CRM,需要补充可选国内方案。
**D. 工程化缺口**
10. 没有测试策略(单元测试、集成测试、E2E),文档提到 `pytest` 仅在 lint 章节一笔带过。
11. 没有环境变量与密钥管理方案(`.env.example`、多环境 settings 分层的实际代码)。
12. 没有健康检查 / 就绪探针(`/healthz``/readyz`),K8s 部署缺了这个会导致滚动更新时无法探活。
13. 日志没有约定格式(结构化 JSON 日志 + request-id 贯穿链路),排障困难。
**E. 内容建模细节不足**
14. StreamField Block 只给了 `HeroBlock``FeatureBlock` 两个例子,官网设计方案里列出的 14 个 BlockStats/CTA/Pricing/FAQ/CaseStudy/Timeline/Team/LogoCloud/TechStack/BlogList/Video/Form 等)都没有代码,容易导致前后端字段不对齐。
15. 没有说明图片如何做响应式(Wagtail `Renditions` + `srcset`)与前端 `next/image` 的对接方式。
### 1.4 与当前代码仓库现状的差距
当前仓库仅是 `django-admin startproject` 生成的裸项目(Django 6.0.5,未安装 Wagtail/DRF`INSTALLED_APPS` 只有 Django 内置应用,`urls.py` 只有 `admin/`)。也就是说,**目前处于"设计文档阶段",代码尚未开始落地**,因此完善设计的同时,应当同步启动 Phase 1 的工程骨架搭建(见本文档第二部分 §18 路线图,以及仓库中已完成的初始化改动)。
---
## 二、完善后的整体设计方案
### 2.1 项目目标
在原有目标基础上明确补充**中国大陆本地化与合规目标**:
- 面向中国大陆企业客户的现代化 B2B 官网 + Headless CMS 内容中台
- 前后端彻底分离:Wagtail(内容与 API+ Next.js(展示层)
- 满足 ICP 备案、PIPL 合规、国内 CDN/存储加速
- 可平滑演进到多语言(中/英/日)、多站点,**暂不做多租户**(YAGNI,待 SaaS 化需求明确后再引入 `django-tenants`
- 内容编辑体验优先(StreamField 组件化、预览、审核流)
### 2.2 总体架构
```mermaid
flowchart TB
subgraph Client
Browser["浏览器 / 移动端"]
end
subgraph Edge["Edge / CDN"]
CDN["国内 CDN(阿里云 CDN / 腾讯云 CDN"]
end
subgraph FE["Next.js 前端(App Router"]
NextApp["React 19 + SSR/ISR"]
end
subgraph Gateway
Nginx["Nginx / Traefik 网关"]
end
subgraph BE["Wagtail 后端"]
Wagtail["Django 5 + Wagtail 7"]
DRF["DRF REST v2"]
GQL["Strawberry GraphQL"]
end
subgraph Infra["基础设施"]
PG[(PostgreSQL)]
Redis[(Redis)]
Search[(OpenSearch)]
OSS[(阿里云 OSS / 腾讯云 COS)]
Celery["Celery Worker"]
end
Browser --> CDN --> NextApp --> Nginx --> Wagtail
Wagtail --> DRF
Wagtail --> GQL
Wagtail --> PG
Wagtail --> Redis
Wagtail --> Search
Wagtail --> OSS
Wagtail -. 异步任务 .-> Celery
Wagtail -- "publish webhook" --> NextApp
```
### 2.3 技术栈(含国内替代方案对照)
| 分层 | 通用方案(原文档) | 中国大陆推荐替代 / 补充 |
|---|---|---|
| 对象存储 | AWS S3 / MinIO | 阿里云 OSS / 腾讯云 COS`django-storages` 均支持 S3 兼容协议) |
| CDN | Cloudflare | 阿里云 CDN、腾讯云 CDN、又拍云 |
| 字体 | Google Fonts | 思源黑体 / HarmonyOS Sans,构建期子集化后自托管,避免运行时依赖 Google |
| 地图 | Google Maps | 高德地图 / 腾讯地图 JS SDK |
| 验证码 | reCAPTCHA | 极验 GeeTest / 腾讯云验证码 |
| 短信/通知 | 无 | 阿里云短信服务 + 企业微信机器人 Webhook(替代 Slack |
| 登录方式(可选) | OAuth2/Auth0/Keycloak | 保留通用 OAuth2;如需 C 端可加"企业微信扫码登录" |
| 搜索 | Elasticsearch/OpenSearch | 同左,国内可用阿里云 OpenSearch 托管版或自建 |
| 备案 | 无 | 需在页脚展示 ICP 备案号,域名/服务器需完成 ICP 备案后方可上线 |
其余后端(Django/Wagtail/DRF/GraphQL/PostgreSQL/Redis/Celery)与前端(Next.js/React/Tailwind/Zustand/React Query)技术栈维持原文档选型不变,属于合理选型。
### 2.4 Monorepo 目录结构(沿用并细化)
```
wagtailcms/ # 当前仓库根目录(后端仓库,独立 git 仓库)
├── manage.py
├── requirements/
│ ├── base.txt
│ ├── dev.txt
│ └── production.txt
├── wagtailcms/ # 设置包(实际未按早期草案重命名为 config/,沿用项目同名包)
│ ├── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
├── apps/
│ ├── core/ # 基础抽象:SEOablePage、健康检查、公共 Block ✅
│ ├── home/ # 首页 ✅
│ ├── blog/ # 技术博客 ✅
│ ├── products/ # 产品 ✅
│ ├── solutions/ # 解决方案(行业)✅
│ ├── cases/ # 客户案例 ✅
│ ├── forms/ # 线索表单 ✅
│ └── api/ # DRF + Wagtail API v2 路由 ✅
├── documents/ # 设计文档(当前目录)
└── (frontend/ 已拆分为独立仓库,不在本仓库内,见下方说明)
```
> 说明:前后端已**拆分为两个独立 git 仓库**(后端本仓库 + `frontend/` 独立仓库),通过 REST APIWagtail API v2 + `/api/v1/custom/`)契约解耦。后端根 `.gitignore` 已排除 `frontend/`,避免嵌套仓库冲突。两个仓库目前均为本地仓库,尚未配置远程/推送。
### 2.5 内容模型与数据库设计
在原有 `Page + StreamField` 思路基础上,补齐字段级设计:
```python
# apps/core/models.py
class SEOablePage(Page):
"""所有业务页面的抽象基类,统一 SEO 字段"""
seo_title = models.CharField(max_length=70, blank=True)
seo_description = models.CharField(max_length=160, blank=True)
og_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
canonical_url = models.URLField(blank=True)
schema_json = models.JSONField(blank=True, default=dict)
promote_panels = Page.promote_panels + [
FieldPanel("seo_title"),
FieldPanel("seo_description"),
FieldPanel("og_image"),
FieldPanel("canonical_url"),
FieldPanel("schema_json"),
]
class Meta:
abstract = True
```
页面树结构(细化到本项目实际官网 IA):
```
HomePage
├── ProductIndexPage
│ └── ProductPage[]
├── SolutionIndexPage
│ └── SolutionPage[]
├── CaseStudyIndexPage
│ └── CaseStudyPage[]
├── BlogIndexPage
│ └── BlogPage[]
├── AboutPage
├── CareerIndexPage
│ └── CareerPage[]
└── ContactPage
```
Snippet(非页面树内容,用于跨页面复用):`TeamMember``Testimonial``Partner/LogoCloud``NavigationMenu``SiteSettings``BaseSiteSetting`,存放公司电话/ICP备案号/社交账号等全局配置)。
### 2.6 StreamField 组件体系(补全清单)
| Block | 用途 | 关键字段 | 状态 |
|---|---|---|---|
| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 |
| StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 |
| FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock |
| ProductCardBlock | 产品矩阵 | title, description, image, link | ✅ 已实现 |
| LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet | ✅ 已实现(简化版) |
| CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 |
| PricingBlock | 定价 | plans: [{name, price, features, highlighted, button}] | ✅ 已实现 |
| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | ✅ 已实现 |
| TimelineBlock | 发展历程 | items: [{year, event}] | ✅ 已实现 |
| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)]`get_api_representation` 展开完整字段(name/role/bio/photo)供前端渲染 | ✅ 已实现(依赖 TeamMember Snippet |
| TechStackBlock | 技术架构图 | items: [{icon, label}] | ✅ 已实现 |
| VideoBlock | 视频 | video_url(建议自建 OSS/COS 直链或腾讯视频/哔哩哔哩等国内平台嵌入地址),poster | ✅ 已实现 |
| FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition)`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 |
| CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 |
所有 Block 统一放在 `apps/core/blocks.py``COMMON_BLOCKS`,前端 `frontend/blocks/BlockRenderer.tsx` 维护对应的"type → 组件"映射表,目前两者手动保持一致;CI 自动化一致性校验仍待建立(见 §2.17 风险表)。
### 2.7 Headless API 设计规范(补全缺失细节)
**RESTWagtail API v2 + DRF 自定义)**
- 统一前缀:`/api/v2/`Wagtail 官方 API v2+ `/api/v1/custom/`(自定义业务接口,如表单提交)
- 分页:统一 `limit` / `offset`,响应体包含 `meta: {total, limit, offset}`
- 排序/过滤:`?order=-first_published_at``?type=blog.BlogPage`
- 错误响应统一结构:
```json
{ "error": { "code": "NOT_FOUND", "message": "..." } }
```
- 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷)
- 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset`
**预览模式(已实现,详见 §2.16)**
- 后端引入 `wagtail-headless-preview`0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`,编辑器点击"预览"时自动将草稿序列化存入 `PagePreview` 表,生成带签名 token 的预览链接并重定向到 `{FRONTEND_BASE_URL}/preview`
- 自定义 `PagePreviewAPIViewSet``apps/api/urls.py`)挂载于 `/api/v2/page_preview/`,根据 `content_type`+`token``PagePreview` 取回草稿快照并序列化返回
- Next.js `/preview` Route Handler 校验 token 有效后开启内置 Draft Modecookie 方式,非 Redis),并重定向到对应内容类型的前端路由;页面组件通过 `getPreviewOrFallback` 优先拉取草稿,否则回退到正常已发布内容
**发布后缓存失效(原文档只有框图,这里补齐实现)**
```python
# apps/core/signals.py
from wagtail.signals import page_published
@receiver(page_published)
def notify_frontend_revalidate(sender, instance, **kwargs):
requests.post(
settings.FRONTEND_REVALIDATE_URL,
json={"tags": [f"page:{instance.id}", instance.url_path]},
headers={"Authorization": f"Bearer {settings.REVALIDATE_SECRET}"},
timeout=3,
)
```
Next.js 侧对应 `revalidateTag` API Route,收到 webhook 后失效对应 ISR 缓存标签。
### 2.8 权限体系(RBAC 矩阵,细化落地)
| 角色 | 页面编辑 | 提交审核 | 审批发布 | 用户管理 | Snippet 管理 | Django Admin |
|---|---|---|---|---|---|---|
| Super Admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Tenant/Site Admin | ✅ | ✅ | ✅ | ✅(本站点) | ✅ | ❌ |
| Editor | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ |
| Reviewer | 查看 | ✅ | ✅ | ❌ | 查看 | ❌ |
| Author | ✅(仅自己) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Viewer | 查看 | ❌ | ❌ | ❌ | ❌ | ❌ |
落地方式:使用 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 工作流与内容审核
采用 Wagtail 5+ 内置 `Workflow` 模块,定义 `草稿 → 编辑自检 → 审核 → 通过 → 定时发布`,并在 `TaskState` 变更时通过企业微信机器人 Webhook 通知责任人(替代原文档 Slack 方案)。
### 2.10 搜索、SEO、多语言
- 搜索:中文分词是关键缺失点,OpenSearch 需配置 **IK 分词插件**(原文档完全没提中文分词,这是国内落地必须解决的问题)。调研结论见下方小节。
- SEO:站点级 `sitemap.xml``wagtail.contrib.sitemaps`+ 页面级 `schema_json`JSON-LD+ `robots.txt` 动态生成。
- 多语言:`WAGTAIL_I18N_ENABLED` + `wagtail-localize`URL 采用 `/zh/``/en/` 前缀,默认 `/zh/` 对国内用户免前缀(根路径直接是中文)。
#### OpenSearch + 中文分词调研(Phase 2 提前调研,新增)
- **Wagtail 原生已支持 OpenSearch**:本仓库安装的 Wagtail 7.4.2 底层依赖 `django-modelsearch`,已内置 `wagtail.search.backends.opensearch2`OpenSearch 2.x)与 `opensearch3`OpenSearch 3.x)两个后端模块,配置方式与 Elasticsearch 后端一致,**不需要任何第三方 Wagtail 插件**:
```python
WAGTAILSEARCH_BACKENDS = {
"default": {
"BACKEND": "wagtail.search.backends.opensearch2",
"URLS": [env("OPENSEARCH_URL", default="http://localhost:9200")],
"INDEX_PREFIX": "wagtailcms_",
}
}
```
需额外安装客户端包 `pip install "opensearch-py>=2,<3"`(版本需与 OpenSearch 服务端大版本一致)。**此配置尚未写入 settings**,留待真正接入、且本地/测试环境有可用 OpenSearch 服务时再启用,避免开发环境无 OpenSearch 服务导致搜索功能报错。
- **中文分词方案**OpenSearch 官方未内置中文分词器,采用社区插件 [infinilabs/analysis-ik](https://github.com/infinilabs/analysis-ik)(同一套插件同时支持 Elasticsearch 和 OpenSearchApache-2.0 协议,17k+ star,长期维护),提供 `ik_smart`(粗粒度,适合短语查询)与 `ik_max_word`(细粒度,适合词项查询)两个分析器,典型字段 mapping 为 `"analyzer": "ik_max_word", "search_analyzer": "ik_smart"`。Wagtail 侧可通过 `WAGTAILSEARCH_BACKENDS['default']['INDEX_SETTINGS']` 覆盖默认 `analysis.analyzer.default` 让 Wagtail 自动生成的索引改用 IK 分词,无需改 Wagtail 源码。
- ⚠️ **插件版本必须与 OpenSearch 核心版本完全一致**才能加载成功,接入前需在 https://release.infinilabs.com/ 或用 `bin/opensearch-plugin install https://get.infini.cloud/opensearch/analysis-ik/<version>` 确认存在对应构建。
- **本地 PoC 脚手架(已创建,未在本环境验证)**:新增 [docker-compose.opensearch.yml](../docker-compose.opensearch.yml) + [docker/opensearch/Dockerfile](../docker/opensearch/Dockerfile),基于官方 `opensearchproject/opensearch:2.19.6` 镜像叠加 IK 插件安装,`plugins.security.disabled=true` 仅用于本地免证书调试。**当前开发环境未安装 Docker CLI,本次调研未能实际拉起容器验证**,需在具备 Docker 的机器上执行 `docker-compose -f docker-compose.opensearch.yml up -d`,并完成以下验证步骤后再正式接入:
1. `curl http://localhost:9200/_cat/plugins` 确认 `analysis-ik` 已加载;
2. 安装 `opensearch-py`,在 settings 中启用上面的 `opensearch2` 后端配置;
3. `python manage.py update_index` 重建索引,用中文短语(如“产品案例”)人工验证检索召回是否符合预期的分词粒度。
- **生产替代方案**:若不想自建/自运维 OpenSearch 集群(插件升级、扩缩容等),可评估阿里云“开放搜索 OpenSearch 版”或腾讯云 ES Serverless 的托管中文分词能力,二者原生支持中文分词、无需自装 IK 插件,代价是绑定云厂商,需按实际预算/团队运维能力二选一(已同步写入 §2.17 风险表)。
### 2.11 安全设计(落地到具体配置)
```python
# config/settings/production.py
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# 阿里云 SLB / 腾讯云 CLB 等负载均衡在边缘终止 TLS 时,需要此项避免 SECURE_SSL_REDIRECT 死循环重定向
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[])
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.ScopedRateThrottle"],
"DEFAULT_THROTTLE_RATES": {"public": "100/min", "forms": "5/min"},
}
```
富文本安全:`WAGTAILADMIN_RICH_TEXT_EDITORS` 限定可用 feature(禁用危险 HTML embed),后端渲染前统一走 `bleach` 白名单清洗。
**白名单配置说明**(三者含义不同,勿混淆):
- `ALLOWED_HOSTS`:填**后端自身**域名(如 `cms.example.com`),校验 HTTP `Host` 头,防 Host 头注入。
- `CORS_ALLOWED_ORIGINS`:填 **Next.js 前端**域名(如 `https://www.example.com`),允许浏览器跨域调用 Wagtail REST API`/api/v1/custom/leads/` 为匿名公开接口,仅受此白名单 + 限流保护,不受 CSRF 校验。
- `CSRF_TRUSTED_ORIGINS`:仅在"浏览器带 session Cookie 发起跨站不安全请求"时生效(如已登录编辑者调用需认证的预览/管理接口),填涉及认证态跨域请求的域名。
- 三者均通过 `.env` 中的 `ALLOWED_HOSTS`/`CORS_ALLOWED_ORIGINS`/`CSRF_TRUSTED_ORIGINS` 环境变量注入,部署时按实际域名填写即可,无需改代码(参见 `.env.example`)。
### 2.12 可观测性与运维
- 健康检查:`/healthz`(存活)、`/readyz`(含数据库/Redis 连通性检查),供 K8s liveness/readiness 探针使用。
- 日志:`structlog` 输出 JSON,贯穿 `request_id`(中间件生成,透传前端 `X-Request-Id`)。
- 监控:Prometheus + Grafana`django-prometheus`),错误监控 Sentry,链路追踪 OpenTelemetry。
### 2.13 测试策略(原文档缺失,新增)
| 层级 | 工具 | 覆盖内容 |
|---|---|---|
| 单元测试 | pytest + pytest-django + factory_boy | Model、Block、Serializer |
| 集成测试 | pytest + DRF APIClient | API 端到端响应结构 |
| 前端组件测试 | Vitest + React Testing Library | Block 组件渲染 |
| E2E | Playwright | 关键转化路径(首页 → 联系我们 → 表单提交) |
| 契约测试 | schemathesis(基于 OpenAPI schema | 防止后端 API 破坏性变更 |
CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线索转化)不低于 90%。
### 2.14 CI/CD 与环境管理
- `.env.example` 统一列出所有必需环境变量,密钥类使用 GitHub Actions Secrets / 云厂商 KMS,不进仓库。
- 分支策略:`main`(生产)/ `staging`(预发)/ 功能分支 PR,合并前必须通过 Lint + Test + Build。
- 部署流水线:`Lint → Test → Build Image → 推送镜像仓库(阿里云 ACR/腾讯云 TCR)→ 部署 K8s(或轻量场景用 Docker Compose + 云服务器)`
### 2.15 合规与本地化清单(新增,落地检查表)
- [x] 完成 ICP 备案(域名 + 服务器均已在国内云厂商完成备案)
- [x] 页脚展示备案号并链接工信部备案查询(`frontend/components/layout/Footer.tsx`,实际备案号 冀ICP备2025130506号-1
- [x] 隐私政策内容已上线(`apps.core.SimpleContentPage` 通用富文本内容页模型 + `seed_privacy_policy` 管理命令 + 前端 `/privacy-policy` 页面 + 页脚链接,内容覆盖 PIPL 要求的收集目的/用途/用户权利/Cookie 说明等)
- [x] Cookie 同意条控件(`frontend/components/layout/CookieConsent.tsx``"use client"` 组件,首次访问展示"接受/拒绝"横幅,选择结果存入 `localStorage`,已挂载到根 layout
- [x] 字体/地图/验证码/CDN 排查:字体已用系统字体栈(`frontend/app/layout.tsx`,未引入 `next/font/google` 等国外字体服务);地图/视频等嵌入类 Block 尚未开发,落地时需选用腾讯地图/高德地图、腾讯视频/B站等国内可访问服务替代 Google Maps/YouTube(见 §2.6 待办);验证码此前仅靠限流(`ScopedRateThrottle` forms scope 5/min)防护,未接入任何第三方验证码,现补充蜜罐反垃圾字段 `website``apps/forms/serializers.py`/`views.py` + `frontend/blocks/form/LeadForm.tsx`,正常用户不可见,机器人误填后静默返回"成功"但不落库不发邮件),无需接入极验/腾讯云验证码 API 即可降低自动化垃圾提交,未来如需更强防护可再接入国内验证码厂商;CDN 方面为 `production.py` 新增 `AWS_S3_CUSTOM_DOMAIN``OSS_CDN_DOMAIN` 环境变量)用于绑定阿里云/腾讯云 CDN 加速域名到 OSS/COS 源站,避免直接暴露源站地址;另确认 `sentry-sdk` 虽在 `requirements/production.txt` 中但代码里从未调用 `sentry_sdk.init()`,为休眠依赖,未来若启用需评估国内可访问性(自建 GlitchTip 或国内 APM 替代 sentry.io
- [x] 表单收集的个人信息支持删除请求(PIPL 数据主体权利):新增 `LeadDeletionRequest` 模型(`apps/forms/models.py`),匿名用户凭提交表单时留下的邮箱在 `/privacy-policy/delete-request` 页面发起申请(`POST /api/v1/custom/leads/deletion-requests/`,限流 forms scope 5/min,无论邮箱是否存在关联数据均返回相同提示以避免探测),系统发送含 24 小时有效 token 的确认邮件,用户在 `/privacy-policy/delete-confirm?token=` 页面点击确认后(`POST /api/v1/custom/leads/deletion-requests/confirm/`)按邮箱在 `Lead.data` 中做匹配并删除对应记录;`LeadDeletionRequest` 处理记录通过 Django Admin(只读)供合规审计;隐私政策页面已加入申请入口链接
### 2.16 开发路线图(对齐当前仓库状态)
| 阶段 | 目标 | 状态 |
|---|---|---|
| **Phase 0** | 需求与设计文档完善(本文档) | ✅ 已完成 |
| **Phase 1** | Wagtail 基础 CMS + REST API + Next.js 首页渲染 + 基础 SEO | 🚧 进行中(进度见下方清单) |
| **Phase 2** | GraphQL、OpenSearch 中文搜索、工作流审核、多语言 | 待规划 |
| **Phase 3** | AI 能力(摘要/翻译/SEO 重写)、SaaS 化评估(视业务需要再决定是否引入多租户) | 待规划 |
**Phase 1 详细进度:**
已完成:
- [x] 后端 Django + Wagtail 项目骨架,分层 settingsbase/dev/production),独立 git 仓库并已提交
- [x] `apps/core``SEOablePage` 抽象基类、`COMMON_BLOCKS`14/14 个 Block 已全部实现,见 §2.6)、健康检查 `/healthz` `/readyz`、发布后 `page_published` signal → 前端 revalidate webhook
- [x] `apps/home``apps/blog`(含标签 `ClusterTaggableManager`
- [x] `apps/products`ProductIndexPage/ProductPage)、`apps/cases`CaseStudyIndexPage/CaseStudyPage)、`apps/solutions`SolutionIndexPage/SolutionPage
- [x] `apps/forms``FormDefinition`/`FormDefinitionField`/`Lead`Snippet 方式)+ 提交 API`/api/v1/custom/leads/`,限流 + 邮件通知)
- [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置)
- [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route`lint`/`build` 已验证通过
- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`root_page/home_page fixtures),为 `apps/core`SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products``apps/cases``apps/forms`(线索提交 API、蜜罐反垃圾字段、PIPL 数据删除权申请/确认流程)编写了基础单元测试,共 31 个用例均通过
- [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行)
- [x] 部署引导 management command `bootstrap_deployment`:读取 `DJANGO_SUPERUSER_USERNAME`/`_EMAIL`/`_PASSWORD` 三个环境变量,若均已设置且用户名不存在则创建首个超级管理员账号(已存在则跳过,不覆盖密码),随后自动调用 `setup_rbac_groups` 初始化 RBAC 权限组;幂等,可放入部署脚本/容器启动流程的 `migrate` 之后执行,解决“首次部署后如何登录 Wagtail 后台”的鸡生蛋问题;`.env.example` 已补充对应三项环境变量说明;新增 4 个单元测试覆盖建号/跳过/不覆盖密码/联动 RBAC 四种场景
- [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] 剩余 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,全部通过
未完成(待规划排期):
- [ ] 测试覆盖率持续提升中:后端 pytest 用例从 40 个增至 65 个,新增覆盖 `apps/blog`(标签、列表排序/草稿过滤)、`apps/solutions`(页面树、草稿过滤)、`apps/home``HomePage.subpage_types` 回归测试,覆盖此前"后台无法添加子页面"的 bug 场景)、`apps/core` 健康检查端点 `/healthz`/`/readyz`、发布后 revalidate webhook 信号(含正常/未配置/网络异常三种路径)、`apps/api` 预览接口 `page_preview`(含无效 token/content_type 场景,过程中顺带修复了一个真实 bug:无效 token 之前会 500 而非文档承诺的 404)、`bootstrap_deployment` 部署引导命令(建号/跳过/不覆盖密码/联动 RBAC);但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线
- [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入
- [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持、字体/地图/验证码/CDN 国内可访问排查均已完成,§2.15 合规与本地化清单已全部完成
- [ ] 中文分词、OpenSearch 集成(Phase 2 提前项):**调研已完成**(结论见 §2.10 “OpenSearch + 中文分词调研”小节:确认 Wagtail 7.4.2 原生内置 `opensearch2`/`opensearch3` 后端 + infinilabs `analysis-ik` 插件的可行方案,并新增本地 Docker Compose PoC 脚手架 `docker-compose.opensearch.yml`),**实际接入(拉起容器验证插件加载、切换 `WAGTAILSEARCH_BACKENDS`、建站内容重建索引)仍未开始**,待具备 Docker 环境后再验证落地
### 2.17 风险与备选方案
| 风险 | 应对 |
|---|---|
| 过早引入多租户/微服务导致过度设计 | Phase 1/2 保持 Monolith,指标驱动再拆分 |
| 国内备案周期拖慢上线(通常 7-20 个工作日) | 项目启动即刻同步提交备案,不等开发完成 |
| Elasticsearch/OpenSearch 中文分词效果不佳 | 提前验证 IK 分词插件,或评估阿里云开放搜索托管方案 |
| StreamField Block 前后端字段不一致 | 建立 Block Schema 自动化一致性校验(CI) |
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.dev')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+4
View File
@@ -0,0 +1,4 @@
[pytest]
DJANGO_SETTINGS_MODULE = wagtailcms.settings.dev
python_files = tests.py test_*.py *_tests.py
addopts = --reuse-db

Some files were not shown because too many files have changed in this diff Show More