Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
306068bc22 | ||
|
|
147107501b | ||
|
|
f37571f395 | ||
|
|
3944e11330 | ||
|
|
8d440b9e4a | ||
|
|
16e4f97e74 | ||
|
|
d6503bb46f | ||
|
|
8fbbd2e6b0 | ||
|
|
b04de9f5c1 | ||
|
|
ec2e629db2 | ||
|
|
4e4cdf31ac | ||
|
|
b05d2b7f52 | ||
|
|
9e60cabd5e | ||
|
|
e2a0f62ef4 | ||
|
|
e6fd8f62f4 | ||
|
|
052fff1a40 | ||
|
|
12f7caa158 | ||
|
|
7f51a31150 |
@@ -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
@@ -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/
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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 = "技术博客"
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 = "客户案例"
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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 == ["案例二", "案例一"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
apps.core 全局 Snippet 只读接口。挂载在 /api/v1/custom/core/
|
||||||
|
(详见 apps/core/urls.py 与 documents/设计方案分析与完善版.md §2.7)。
|
||||||
|
公开只读数据,限流走 DRF ScopedRateThrottle 的 "public" scope(100/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)
|
||||||
@@ -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
|
||||||
@@ -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=_("富文本"))),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page,
|
||||||
|
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
|
||||||
|
|
||||||
|
本文件同时存放跨页面复用的全局 Snippet:TeamMember/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 = "站点设置"
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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 webhook(apps/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))
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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"})
|
||||||
@@ -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
|
||||||
@@ -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 = "线索表单"
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
|
||||||
|
嵌入任意 StreamField 页面;提交结果保存为 Lead。
|
||||||
|
详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 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)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与
|
||||||
|
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。
|
||||||
|
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope(5/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": "已成功删除与该邮箱关联的个人信息。",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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 = "首页"
|
||||||
@@ -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
@@ -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 = "首页"
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 = "产品"
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 = "解决方案"
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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. 没有考虑富文本 XSS(Wagtail 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 个 Block(Stats/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 API(Wagtail 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 设计规范(补全缺失细节)
|
||||||
|
|
||||||
|
**REST(Wagtail 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 Mode(cookie 方式,非 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 和 OpenSearch,Apache-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 项目骨架,分层 settings(base/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) |
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -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
Reference in New Issue
Block a user