Compare commits

..
1 Commits
Author SHA1 Message Date
Zhengen 0d05af6637 Initial commit 2026-08-13 14:33:39 +08:00
92 changed files with 2 additions and 2441 deletions
-26
View File
@@ -1,26 +0,0 @@
# 复制为 .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=
# 发布后前端 ISR 缓存失效 Webhook
FRONTEND_REVALIDATE_URL=https://www.example.com/api/revalidate
REVALIDATE_SECRET=change-me
-15
View File
@@ -1,15 +0,0 @@
__pycache__/
*.py[cod]
*.sqlite3
.env
/media/
/staticfiles/
.venv/
venv/
*.egg-info/
.pytest_cache/
node_modules/
.next/
# frontend 是独立的 Next.js 仓库(自带 .git),不纳入后端仓库版本控制
/frontend/
+2
View File
@@ -0,0 +1,2 @@
# wagtailcms
View File
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"
label = "api"
verbose_name = "API"
-14
View File
@@ -1,14 +0,0 @@
"""
Wagtail API v2 路由配置。
详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。
"""
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
api_router = WagtailAPIRouter("wagtailapi")
api_router.register_endpoint("pages", PagesAPIViewSet)
api_router.register_endpoint("images", ImagesAPIViewSet)
api_router.register_endpoint("documents", DocumentsAPIViewSet)
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"
label = "blog"
verbose_name = "技术博客"
-72
View File
@@ -1,72 +0,0 @@
# 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='标签'),
),
]
View File
-75
View File
@@ -1,75 +0,0 @@
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 = "博客文章"
@@ -1,14 +0,0 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for post in posts %}
<li><a href="{% pageurl post %}">{{ post.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
-12
View File
@@ -1,12 +0,0 @@
{% 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>
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class CasesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.cases"
label = "cases"
verbose_name = "客户案例"
-55
View File
@@ -1,55 +0,0 @@
# 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',),
),
]
View File
-70
View File
@@ -1,70 +0,0 @@
"""客户案例栏目与案例详情页。详见 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 = "客户案例"
@@ -1,14 +0,0 @@
{% 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>
@@ -1,13 +0,0 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.client_name }} · {{ page.industry }}</p>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
-50
View File
@@ -1,50 +0,0 @@
"""apps.cases 单元测试:页面树结构与列表排序。"""
import pytest
from django.utils import timezone
from apps.cases.models import CaseStudyIndexPage, CaseStudyPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def case_index(home_page):
index = CaseStudyIndexPage(title="客户案例", slug="cases", intro="客户的成功案例")
home_page.add_child(instance=index)
return index
def test_case_study_page_can_be_created_under_index(case_index):
case = CaseStudyPage(
title="某集团数字化转型",
slug="some-group",
client_name="某集团",
industry="制造业",
published_at=timezone.now(),
)
case_index.add_child(instance=case)
assert CaseStudyPage.objects.live().descendant_of(case_index).count() == 1
def test_case_index_context_orders_by_published_at_desc(case_index):
older = CaseStudyPage(
title="案例一",
slug="case-1",
client_name="客户一",
published_at=timezone.now() - timezone.timedelta(days=10),
)
case_index.add_child(instance=older)
newer = CaseStudyPage(
title="案例二",
slug="case-2",
client_name="客户二",
published_at=timezone.now(),
)
case_index.add_child(instance=newer)
context = case_index.get_context(request=None)
titles = [c.title for c in context["case_studies"]]
assert titles == ["案例二", "案例一"]
View File
-11
View File
@@ -1,11 +0,0 @@
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
-164
View File
@@ -1,164 +0,0 @@
"""
可复用的 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
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()
],
}
}
COMMON_BLOCKS = [
("hero", HeroBlock()),
("stats", StatsBlock()),
("feature_grid", FeatureGridBlock()),
("faq", FAQBlock()),
("cta", CTABlock()),
("logo_cloud", LogoCloudBlock()),
("case_study", CaseStudyBlock()),
("form", FormBlock()),
("richtext", blocks.RichTextBlock(label=_("富文本"))),
]
View File
@@ -1,93 +0,0 @@
"""
创建/更新"隐私政策"页面(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}"))
-34
View File
@@ -1,34 +0,0 @@
# 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',),
),
]
View File
-55
View File
@@ -1,55 +0,0 @@
"""
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
"""
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField
from wagtail.models import Page
class SEOablePage(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 = "通用内容页"
-27
View File
@@ -1,27 +0,0 @@
"""
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)
@@ -1,5 +0,0 @@
<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>
@@ -1,9 +0,0 @@
{% 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>
@@ -1,4 +0,0 @@
<section class="feature">
<h3>{{ value.title }}</h3>
<p>{{ value.description }}</p>
</section>
@@ -1,8 +0,0 @@
{% 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>
@@ -1,10 +0,0 @@
{% 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>
@@ -1,5 +0,0 @@
<section class="stats">
{% for item in value.items %}
<div class="stat"><strong>{{ item.value }}</strong><span>{{ item.label }}</span></div>
{% endfor %}
</section>
@@ -1,9 +0,0 @@
{% 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>
-77
View File
@@ -1,77 +0,0 @@
"""apps.core 基础单元测试:SEOablePage 抽象基类字段与公共 Block。"""
import pytest
from apps.core.blocks import FormBlock
from apps.core.models import SimpleContentPage
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
-21
View File
@@ -1,21 +0,0 @@
"""
健康检查端点,供 Kubernetes liveness/readiness 探针使用。
详见 documents/设计方案分析与完善版.md §2.12。
"""
from django.db import connection
from django.http import JsonResponse
def healthz(request):
"""存活探针:仅确认进程可响应请求。"""
return JsonResponse({"status": "ok"})
def readyz(request):
"""就绪探针:确认数据库连接可用。"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception as exc: # pragma: no cover - defensive
return JsonResponse({"status": "error", "detail": str(exc)}, status=503)
return JsonResponse({"status": "ok"})
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class FormsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.forms"
label = "forms"
verbose_name = "线索表单"
-62
View File
@@ -1,62 +0,0 @@
# 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'],
},
),
]
View File
-113
View File
@@ -1,113 +0,0 @@
"""
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
嵌入任意 StreamField 页面;提交结果保存为 Lead。
详见 documents/设计方案分析与完善版.md §2.6FormBlock)与 B2B 官网核心转化路径。
"""
from django.db import models
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}"
-7
View File
@@ -1,7 +0,0 @@
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="")
-64
View File
@@ -1,64 +0,0 @@
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
import pytest
from rest_framework.test import APIClient
from apps.forms.models import FormDefinition, FormDefinitionField, Lead
pytestmark = pytest.mark.django_db
@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
-7
View File
@@ -1,7 +0,0 @@
from django.urls import path
from .views import LeadSubmitView
urlpatterns = [
path("leads/", LeadSubmitView.as_view(), name="lead-submit"),
]
-51
View File
@@ -1,51 +0,0 @@
"""
线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope5/min,防刷)。
"""
import json
from django.conf import settings
from django.core.mail import send_mail
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import FormDefinition, Lead
from .serializers import LeadSubmitSerializer
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
)
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
)
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.home"
label = "home"
verbose_name = "首页"
-34
View File
@@ -1,34 +0,0 @@
# 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',),
),
]
View File
-26
View File
@@ -1,26 +0,0 @@
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class HomePage(SEOablePage):
"""企业官网首页,使用通用组件化 StreamField 拼装页面内容。"""
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
content_panels = SEOablePage.content_panels + [
FieldPanel("body"),
]
subpage_types = [
"blog.BlogIndexPage",
"products.ProductIndexPage",
"solutions.SolutionIndexPage",
"cases.CaseStudyIndexPage",
"core.SimpleContentPage",
]
class Meta:
verbose_name = "首页"
-11
View File
@@ -1,11 +0,0 @@
{% 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>
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"
label = "products"
verbose_name = "产品"
-52
View File
@@ -1,52 +0,0 @@
# 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',),
),
]
-63
View File
@@ -1,63 +0,0 @@
"""产品栏目与产品详情页。详见 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 = "产品页"
@@ -1,14 +0,0 @@
{% 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>
@@ -1,12 +0,0 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.summary }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
-34
View File
@@ -1,34 +0,0 @@
"""apps.products 单元测试:页面树结构与列表上下文。"""
import pytest
from apps.products.models import ProductIndexPage, ProductPage
pytestmark = pytest.mark.django_db
@pytest.fixture
def product_index(home_page):
index = ProductIndexPage(title="产品", slug="products", intro="我们的产品")
home_page.add_child(instance=index)
return index
def test_product_page_can_be_created_under_index(product_index):
product = ProductPage(title="产品 A", slug="product-a", summary="简介 A")
product_index.add_child(instance=product)
assert ProductPage.objects.live().descendant_of(product_index).count() == 1
def test_product_index_context_lists_live_products_only(product_index):
live_product = ProductPage(title="产品 A", slug="product-a")
product_index.add_child(instance=live_product)
draft_product = ProductPage(title="产品 B", slug="product-b", live=False)
product_index.add_child(instance=draft_product)
context = product_index.get_context(request=None)
titles = [p.title for p in context["products"]]
assert "产品 A" in titles
assert "产品 B" not in titles
View File
-8
View File
@@ -1,8 +0,0 @@
from django.apps import AppConfig
class SolutionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.solutions"
label = "solutions"
verbose_name = "解决方案"
-53
View File
@@ -1,53 +0,0 @@
# 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',),
),
]
-66
View File
@@ -1,66 +0,0 @@
"""行业解决方案栏目与详情页。详见 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 = "解决方案页"
@@ -1,14 +0,0 @@
{% 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>
@@ -1,13 +0,0 @@
{% 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>
-19
View File
@@ -1,19 +0,0 @@
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-385
View File
@@ -1,385 +0,0 @@
# 企业官网 Headless CMS 设计方案 —— 分析与完善版
> 本文档基于 `documents/` 目录下现有的 5 份 PDF 设计文档整理分析,并结合当前代码仓库的真实状态(`manage.py` + 裸 Django 6.0 项目骨架,尚未引入 Wagtail),产出一份可直接指导开发的**完善版设计方案**。
---
## 一、现有文档分析
### 1.1 文档清单
| 文档 | 定位 |
|---|---|
| 基于 Wagtail 的现代前后端分离 CMS 设计方案.pdf | 通用架构设计(30 章节,最全面) |
| Next.js + Wagtail 企业级 Headless CMS 完整项目代码架构.pdf | Monorepo + 代码架构细化 |
| Wagtail CMS Backend 完整源码(企业级 Headless CMS.pdf | 后端代码骨架示例 |
| Next.js 企业官网完整源码模板(基于 Wagtail Headless CMS.pdf | 前端代码骨架示例 |
| 软件公司企业官网设计方案(基于 Wagtail Headless CMS.pdf | 官网 IA / 页面 / 视觉设计 |
| 整体设计图.png | 首页高保真视觉稿(TechFlow 品牌示例) |
### 1.2 共性结论
5 份文档高度一致地收敛到同一技术选型:**Django 5 + Wagtail 7 + DRF + Strawberry GraphQL + Next.js 15 (React 19) + PostgreSQL + Redis + Celery + OpenSearch/Elasticsearch + MinIO/S3 + Docker/K8s**。这说明该技术栈选型是合理、成熟的,可以直接作为最终方案基线,**不需要重新论证**。
### 1.3 已发现的不足 / 待完善点
逐份文档都以"罗列技术名词 + 目录结构"为主,**普遍缺少可落地的细节**,具体归纳如下:
**A. 架构完整性缺口**
1. 只给出多租户"共享库 / Schema 隔离"两个方案名词,未给出本项目该如何取舍(本项目是单一企业官网,暂不需要多租户,过度设计)。
2. 没有数据库 ER 图 / 字段级设计,`Page` 模型都只写了 1-2 个示例字段。
3. 没有 Headless 预览(Preview)机制设计 —— 编辑在 Wagtail 后台点"预览"Next.js 如何展示草稿内容(draft mode / preview token)完全没有提及。
4. 没有"发布后前端缓存失效"的具体实现(只画了 `Content Published → Event Bus → Webhook → Frontend Revalidate` 的框图,没有落地到 Wagtail `page_published` signal + Next.js `revalidateTag` 的代码级方案)。
5. API 只给了 URL 列表,没有分页、排序、过滤、错误码、版本弃用策略等规范。
**B. 安全设计不落地**
6. 安全章节只列了名词(HTTPS/CSP/WAF/JWT Rotation…),没有给出 Django 层面应设置的具体 settings 项(`SECURE_HSTS_SECONDS``SESSION_COOKIE_SECURE``CSRF_TRUSTED_ORIGINS`、DRF 节流类等)。
7. `CORS_ALLOW_ALL_ORIGINS = True` 是示例代码里的真实隐患,生产环境必须收敛为白名单。
8. 没有考虑富文本 XSSWagtail RichTextField 需要配置白名单 `WAGTAILADMIN_RICH_TEXT_EDITORS` / `bleach`)。
**C. 本地化 / 合规缺失(重要)**
9. 所有文档默认使用 **AWS S3 + Cloudflare CDN + Google 字体生态**,但"整体设计图.png"高保真稿明确显示目标是**中国大陆企业官网**(简体中文导航、"沪ICP备xxxxxxxx号-1"、中国移动/招商银行/中国平安/上汽集团/美团 等客户 Logo)。这是一个关键但被忽视的落地风险:
- AWS S3/Cloudflare 在中国大陆访问速度和合规性都存在问题,需替换为 **阿里云 OSS/腾讯云 COS + 阿里云 CDN/腾讯云 CDN** 等国内方案。
- 网站备案:需要 **ICP 备案**(个人/企业备案流程、幕布拍照要求等),这会影响上线时间表,必须提前规划。
- 需要 **隐私政策 / Cookie 同意条**,遵循《个人信息保护法》(PIPL),而不是文档里默认的 GDPR 话术。
- Google Fonts / Google Maps 在中国大陆不可用,需要自托管字体(思源黑体/HarmonyOS Sans 子集化)、替换为高德/腾讯地图。
- 人机验证码建议用极验/腾讯云验证码而非 reCAPTCHA。
- 表单营销自动化提到 HubSpot/Salesforce,国内客户更常用企业微信 + 探迹/纷享销客等 CRM,需要补充可选国内方案。
**D. 工程化缺口**
10. 没有测试策略(单元测试、集成测试、E2E),文档提到 `pytest` 仅在 lint 章节一笔带过。
11. 没有环境变量与密钥管理方案(`.env.example`、多环境 settings 分层的实际代码)。
12. 没有健康检查 / 就绪探针(`/healthz``/readyz`),K8s 部署缺了这个会导致滚动更新时无法探活。
13. 日志没有约定格式(结构化 JSON 日志 + request-id 贯穿链路),排障困难。
**E. 内容建模细节不足**
14. StreamField Block 只给了 `HeroBlock``FeatureBlock` 两个例子,官网设计方案里列出的 14 个 BlockStats/CTA/Pricing/FAQ/CaseStudy/Timeline/Team/LogoCloud/TechStack/BlogList/Video/Form 等)都没有代码,容易导致前后端字段不对齐。
15. 没有说明图片如何做响应式(Wagtail `Renditions` + `srcset`)与前端 `next/image` 的对接方式。
### 1.4 与当前代码仓库现状的差距
当前仓库仅是 `django-admin startproject` 生成的裸项目(Django 6.0.5,未安装 Wagtail/DRF`INSTALLED_APPS` 只有 Django 内置应用,`urls.py` 只有 `admin/`)。也就是说,**目前处于"设计文档阶段",代码尚未开始落地**,因此完善设计的同时,应当同步启动 Phase 1 的工程骨架搭建(见本文档第二部分 §18 路线图,以及仓库中已完成的初始化改动)。
---
## 二、完善后的整体设计方案
### 2.1 项目目标
在原有目标基础上明确补充**中国大陆本地化与合规目标**:
- 面向中国大陆企业客户的现代化 B2B 官网 + Headless CMS 内容中台
- 前后端彻底分离:Wagtail(内容与 API+ Next.js(展示层)
- 满足 ICP 备案、PIPL 合规、国内 CDN/存储加速
- 可平滑演进到多语言(中/英/日)、多站点,**暂不做多租户**(YAGNI,待 SaaS 化需求明确后再引入 `django-tenants`
- 内容编辑体验优先(StreamField 组件化、预览、审核流)
### 2.2 总体架构
```mermaid
flowchart TB
subgraph Client
Browser["浏览器 / 移动端"]
end
subgraph Edge["Edge / CDN"]
CDN["国内 CDN(阿里云 CDN / 腾讯云 CDN"]
end
subgraph FE["Next.js 前端(App Router"]
NextApp["React 19 + SSR/ISR"]
end
subgraph Gateway
Nginx["Nginx / Traefik 网关"]
end
subgraph BE["Wagtail 后端"]
Wagtail["Django 5 + Wagtail 7"]
DRF["DRF REST v2"]
GQL["Strawberry GraphQL"]
end
subgraph Infra["基础设施"]
PG[(PostgreSQL)]
Redis[(Redis)]
Search[(OpenSearch)]
OSS[(阿里云 OSS / 腾讯云 COS)]
Celery["Celery Worker"]
end
Browser --> CDN --> NextApp --> Nginx --> Wagtail
Wagtail --> DRF
Wagtail --> GQL
Wagtail --> PG
Wagtail --> Redis
Wagtail --> Search
Wagtail --> OSS
Wagtail -. 异步任务 .-> Celery
Wagtail -- "publish webhook" --> NextApp
```
### 2.3 技术栈(含国内替代方案对照)
| 分层 | 通用方案(原文档) | 中国大陆推荐替代 / 补充 |
|---|---|---|
| 对象存储 | AWS S3 / MinIO | 阿里云 OSS / 腾讯云 COS`django-storages` 均支持 S3 兼容协议) |
| CDN | Cloudflare | 阿里云 CDN、腾讯云 CDN、又拍云 |
| 字体 | Google Fonts | 思源黑体 / HarmonyOS Sans,构建期子集化后自托管,避免运行时依赖 Google |
| 地图 | Google Maps | 高德地图 / 腾讯地图 JS SDK |
| 验证码 | reCAPTCHA | 极验 GeeTest / 腾讯云验证码 |
| 短信/通知 | 无 | 阿里云短信服务 + 企业微信机器人 Webhook(替代 Slack |
| 登录方式(可选) | OAuth2/Auth0/Keycloak | 保留通用 OAuth2;如需 C 端可加"企业微信扫码登录" |
| 搜索 | Elasticsearch/OpenSearch | 同左,国内可用阿里云 OpenSearch 托管版或自建 |
| 备案 | 无 | 需在页脚展示 ICP 备案号,域名/服务器需完成 ICP 备案后方可上线 |
其余后端(Django/Wagtail/DRF/GraphQL/PostgreSQL/Redis/Celery)与前端(Next.js/React/Tailwind/Zustand/React Query)技术栈维持原文档选型不变,属于合理选型。
### 2.4 Monorepo 目录结构(沿用并细化)
```
wagtailcms/ # 当前仓库根目录(后端仓库,独立 git 仓库)
├── manage.py
├── requirements/
│ ├── base.txt
│ ├── dev.txt
│ └── production.txt
├── wagtailcms/ # 设置包(实际未按早期草案重命名为 config/,沿用项目同名包)
│ ├── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
├── apps/
│ ├── core/ # 基础抽象:SEOablePage、健康检查、公共 Block ✅
│ ├── home/ # 首页 ✅
│ ├── blog/ # 技术博客 ✅
│ ├── products/ # 产品 ✅
│ ├── solutions/ # 解决方案(行业)✅
│ ├── cases/ # 客户案例 ✅
│ ├── forms/ # 线索表单 ✅
│ └── api/ # DRF + Wagtail API v2 路由 ✅
├── documents/ # 设计文档(当前目录)
└── (frontend/ 已拆分为独立仓库,不在本仓库内,见下方说明)
```
> 说明:前后端已**拆分为两个独立 git 仓库**(后端本仓库 + `frontend/` 独立仓库),通过 REST APIWagtail API v2 + `/api/v1/custom/`)契约解耦。后端根 `.gitignore` 已排除 `frontend/`,避免嵌套仓库冲突。两个仓库目前均为本地仓库,尚未配置远程/推送。
### 2.5 内容模型与数据库设计
在原有 `Page + StreamField` 思路基础上,补齐字段级设计:
```python
# apps/core/models.py
class SEOablePage(Page):
"""所有业务页面的抽象基类,统一 SEO 字段"""
seo_title = models.CharField(max_length=70, blank=True)
seo_description = models.CharField(max_length=160, blank=True)
og_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
canonical_url = models.URLField(blank=True)
schema_json = models.JSONField(blank=True, default=dict)
promote_panels = Page.promote_panels + [
FieldPanel("seo_title"),
FieldPanel("seo_description"),
FieldPanel("og_image"),
FieldPanel("canonical_url"),
FieldPanel("schema_json"),
]
class Meta:
abstract = True
```
页面树结构(细化到本项目实际官网 IA):
```
HomePage
├── ProductIndexPage
│ └── ProductPage[]
├── SolutionIndexPage
│ └── SolutionPage[]
├── CaseStudyIndexPage
│ └── CaseStudyPage[]
├── BlogIndexPage
│ └── BlogPage[]
├── AboutPage
├── CareerIndexPage
│ └── CareerPage[]
└── ContactPage
```
Snippet(非页面树内容,用于跨页面复用):`TeamMember``Testimonial``Partner/LogoCloud``NavigationMenu``SiteSettings``BaseSiteSetting`,存放公司电话/ICP备案号/社交账号等全局配置)。
### 2.6 StreamField 组件体系(补全清单)
| Block | 用途 | 关键字段 | 状态 |
|---|---|---|---|
| HeroBlock | 首屏 | title, subtitle, cta_buttons(list), background_image/video | ✅ 已实现 |
| StatsBlock | 数据展示 | items: [{value, label}] | ✅ 已实现 |
| FeatureGridBlock | 能力/优势 | icon, title, description | ✅ 已实现(对应原 FeatureBlock |
| ProductCardBlock | 产品矩阵 | title, description, image, link | ⬜ 待开发 |
| LogoCloudBlock | 客户 Logo 墙 | logos: [{url, title}](暂为图片列表,未接入 Partner Snippet | ✅ 已实现(简化版) |
| CaseStudyBlock | 案例卡片 | case_page: PageChooser(cases.CaseStudyPage), summary | ✅ 已实现 |
| PricingBlock | 定价 | plans: [{name, price, features}] | ⬜ 待开发 |
| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] | ✅ 已实现 |
| TimelineBlock | 发展历程 | items: [{year, event}] | ⬜ 待开发 |
| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)] | ⬜ 待开发(依赖 TeamMember Snippet |
| TechStackBlock | 技术架构图 | items: [{icon, label}] | ⬜ 待开发 |
| VideoBlock | 视频 | video_file/embed_url, poster | ⬜ 待开发 |
| FormBlock | 线索表单 | form: SnippetChooser(forms.FormDefinition)`get_api_representation` 展开完整字段定义供前端渲染 | ✅ 已实现 |
| CTABlock | 行动号召 | heading, button_text, button_link | ✅ 已实现 |
所有 Block 统一放在 `apps/core/blocks.py``COMMON_BLOCKS`,前端 `frontend/blocks/BlockRenderer.tsx` 维护对应的"type → 组件"映射表,目前两者手动保持一致;CI 自动化一致性校验仍待建立(见 §2.17 风险表)。
### 2.7 Headless API 设计规范(补全缺失细节)
**RESTWagtail API v2 + DRF 自定义)**
- 统一前缀:`/api/v2/`Wagtail 官方 API v2+ `/api/v1/custom/`(自定义业务接口,如表单提交)
- 分页:统一 `limit` / `offset`,响应体包含 `meta: {total, limit, offset}`
- 排序/过滤:`?order=-first_published_at``?type=blog.BlogPage`
- 错误响应统一结构:
```json
{ "error": { "code": "NOT_FOUND", "message": "..." } }
```
- 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷)
- 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset`
**预览模式(原文档缺失,重要补充)**
- Wagtail 后台"预览"生成一次性 `preview_token`(存 RedisTTL 5 分钟)
- Next.js 提供 `/api/draft` 路由,校验 token 后开启 Next.js Draft Mode,直接向 Wagtail 请求草稿版本 API`?revision=latest`
**发布后缓存失效(原文档只有框图,这里补齐实现)**
```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 框架。
### 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/` 对国内用户免前缀(根路径直接是中文)。
### 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"
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` 白名单清洗。
### 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
- [ ] 字体/地图/验证码/CDN 均替换为可在国内正常访问的服务
- [ ] 表单收集的个人信息需明确告知用途并支持删除请求(PIPL 数据主体权利)
### 2.16 开发路线图(对齐当前仓库状态)
| 阶段 | 目标 | 状态 |
|---|---|---|
| **Phase 0** | 需求与设计文档完善(本文档) | ✅ 已完成 |
| **Phase 1** | Wagtail 基础 CMS + REST API + Next.js 首页渲染 + 基础 SEO | 🚧 进行中(进度见下方清单) |
| **Phase 2** | GraphQL、OpenSearch 中文搜索、工作流审核、多语言 | 待规划 |
| **Phase 3** | AI 能力(摘要/翻译/SEO 重写)、SaaS 化评估(视业务需要再决定是否引入多租户) | 待规划 |
**Phase 1 详细进度:**
已完成:
- [x] 后端 Django + Wagtail 项目骨架,分层 settingsbase/dev/production),独立 git 仓库并已提交
- [x] `apps/core``SEOablePage` 抽象基类、`COMMON_BLOCKS`9/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 页面树)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 11 个用例均通过
- [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行)
未完成(待规划排期):
- [ ] Snippet`TeamMember`/`Testimonial`/`Partner`/`NavigationMenu`/`SiteSettings``BaseSiteSetting`)均未创建
- [ ] 预览模式(Wagtail `preview_token` + Next.js Draft Mode)未实现
- [ ] 剩余 5 个 StreamField BlockPricing/Timeline/Team/TechStack/Video/ProductCard)未开发
- [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
- [ ] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)未在 `production.py` 中逐项落实确认
- [ ] ICP 备案已完成,页脚已展示实际备案号(冀ICP备2025130506号-1);隐私政策内容已上线,Cookie 同意条控件(`frontend/components/layout/CookieConsent.tsx`localStorage 记忆用户选择)已实现并挂载到全局 layout
- [ ] 中文分词、OpenSearch 集成(Phase 2 提前项)未开始
### 2.17 风险与备选方案
| 风险 | 应对 |
|---|---|
| 过早引入多租户/微服务导致过度设计 | Phase 1/2 保持 Monolith,指标驱动再拆分 |
| 国内备案周期拖慢上线(通常 7-20 个工作日) | 项目启动即刻同步提交备案,不等开发完成 |
| Elasticsearch/OpenSearch 中文分词效果不佳 | 提前验证 IK 分词插件,或评估阿里云开放搜索托管方案 |
| StreamField Block 前后端字段不一致 | 建立 Block Schema 自动化一致性校验(CI |
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.dev')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
-4
View File
@@ -1,4 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = wagtailcms.settings.dev
python_files = tests.py test_*.py *_tests.py
addopts = --reuse-db
-7
View File
@@ -1,7 +0,0 @@
Django>=6.0,<6.1
wagtail>=7.4,<8.0
djangorestframework>=3.17,<4.0
django-cors-headers>=4.9,<5.0
django-environ>=0.14,<1.0
django-filter>=26.0,<27.0
requests>=2.34,<3.0
-6
View File
@@ -1,6 +0,0 @@
-r base.txt
django-debug-toolbar>=4.4
pytest>=8.0
pytest-django>=4.9
factory-boy>=3.3
-6
View File
@@ -1,6 +0,0 @@
-r base.txt
psycopg[binary]>=3.2
django-storages[s3]>=1.14
gunicorn>=23.0
sentry-sdk>=2.0
View File
-16
View File
@@ -1,16 +0,0 @@
"""
ASGI config for wagtailcms project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.production')
application = get_asgi_application()
View File
-133
View File
@@ -1,133 +0,0 @@
"""
Base settings shared by all environments.
Environment specific overrides live in ``dev.py`` and ``production.py``.
See documents/设计方案分析与完善版.md for the design rationale behind these settings.
"""
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent.parent
env = environ.Env()
env_file = BASE_DIR / ".env"
if env_file.exists():
environ.Env.read_env(str(env_file))
SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me-in-production")
INSTALLED_APPS = [
# Project apps
"apps.core",
"apps.home",
"apps.blog",
"apps.products",
"apps.solutions",
"apps.cases",
"apps.forms",
"apps.api",
# Wagtail
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
# Django
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"corsheaders",
"django_filters",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "wagtailcms.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "wagtailcms.wsgi.application"
ASGI_APPLICATION = "wagtailcms.asgi.application"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Internationalization
# 面向中国大陆企业官网,默认语言与时区本地化
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
# Static / media files
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Wagtail
WAGTAIL_SITE_NAME = "企业官网 CMS"
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", default="http://localhost:8000")
# Django REST Framework
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 20,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.ScopedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"public": "100/min",
"forms": "5/min",
},
}
# CORS:默认关闭全放开,白名单在各环境中显式声明
CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[])
# 发布后通知前端做 ISR 缓存失效(详见设计文档 §2.7)
FRONTEND_REVALIDATE_URL = env("FRONTEND_REVALIDATE_URL", default="")
REVALIDATE_SECRET = env("REVALIDATE_SECRET", default="")
-19
View File
@@ -1,19 +0,0 @@
from .base import * # noqa: F401,F403
DEBUG = True
ALLOWED_HOSTS = ["*"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# 开发环境放开跨域,便于本地 Next.jslocalhost:3000)联调
CORS_ALLOW_ALL_ORIGINS = True
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
WAGTAIL_CACHE_BACKEND = None
-47
View File
@@ -1,47 +0,0 @@
from .base import * # noqa: F401,F403
DEBUG = False
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
DATABASES = {
"default": env.db("DATABASE_URL"),
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_URL", default="redis://redis:6379/1"),
}
}
# 安全相关设置:详见 documents/设计方案分析与完善版.md §2.11
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
# 国内对象存储(阿里云 OSS / 腾讯云 COS 均兼容 S3 协议)
DEFAULT_FILE_STORAGE = env(
"DEFAULT_FILE_STORAGE",
default="django.core.files.storage.FileSystemStorage",
)
AWS_ACCESS_KEY_ID = env("OSS_ACCESS_KEY_ID", default="")
AWS_SECRET_ACCESS_KEY = env("OSS_SECRET_ACCESS_KEY", default="")
AWS_STORAGE_BUCKET_NAME = env("OSS_BUCKET_NAME", default="")
AWS_S3_ENDPOINT_URL = env("OSS_ENDPOINT_URL", default="")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
-47
View File
@@ -1,47 +0,0 @@
"""
URL configuration for wagtailcms project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls
from wagtail.documents import urls as wagtaildocs_urls
from apps.api.urls import api_router
from apps.core.views import healthz, readyz
urlpatterns = [
path('django-admin/', admin.site.urls),
path('admin/', include(wagtailadmin_urls)),
path('documents/', include(wagtaildocs_urls)),
path('api/v2/', api_router.urls),
# 自定义业务接口(非 Wagtail 页面路由),如线索表单提交
path('api/v1/custom/', include('apps.forms.urls')),
path('healthz', healthz, name='healthz'),
path('readyz', readyz, name='readyz'),
]
if settings.DEBUG:
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += [
# 兜底:交由 Wagtail 页面树渲染(Headless 场景下主要用于预览/管理,
# 对外内容展示由 Next.js 前端通过 /api/v2/ 消费)
path('', include(wagtail_urls)),
]
-16
View File
@@ -1,16 +0,0 @@
"""
WSGI config for wagtailcms project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.production')
application = get_wsgi_application()