chore: initial commit of Wagtail backend scaffold

This commit is contained in:
2026-08-06 15:56:14 +08:00
commit 7f51a31150
51 changed files with 1239 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# 复制为 .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
@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.sqlite3
.env
/media/
/staticfiles/
.venv/
venv/
*.egg-info/
.pytest_cache/
node_modules/
.next/
# frontend 是独立的 Next.js 仓库(自带 .git),不纳入后端仓库版本控制
/frontend/
View File
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.api"
label = "api"
verbose_name = "API"
+14
View File
@@ -0,0 +1,14 @@
"""
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
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"
label = "blog"
verbose_name = "技术博客"
+72
View File
@@ -0,0 +1,72 @@
# Generated by Django 6.0.5 on 2026-08-06 05:29
import django.db.models.deletion
import modelcluster.contrib.taggit
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='BlogIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '博客栏目页',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('published_at', models.DateTimeField(verbose_name='发布时间')),
('intro', models.CharField(blank=True, max_length=250, verbose_name='摘要')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='封面图')),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '博客文章',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPageTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.blogpage')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='blogpage',
name='tags',
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.BlogPageTag', to='taggit.Tag', verbose_name='标签'),
),
]
View File
+75
View File
@@ -0,0 +1,75 @@
from django.db import models
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel
from wagtail.fields import StreamField
from wagtail.search import index
from apps.core.blocks import COMMON_BLOCKS
from apps.core.models import SEOablePage
class BlogIndexPage(SEOablePage):
"""博客列表页,本身不直接管理内容,子页面为 BlogPage。"""
intro = models.TextField(blank=True, verbose_name="栏目简介")
content_panels = SEOablePage.content_panels + [
FieldPanel("intro"),
]
subpage_types = ["blog.BlogPage"]
parent_page_types = ["home.HomePage"]
class Meta:
verbose_name = "博客栏目页"
def get_context(self, request, *args, **kwargs):
context = super().get_context(request, *args, **kwargs)
context["posts"] = (
BlogPage.objects.live().descendant_of(self).order_by("-published_at")
)
return context
class BlogPageTag(TaggedItemBase):
content_object = ParentalKey(
"BlogPage", related_name="tagged_items", on_delete=models.CASCADE
)
class BlogPage(SEOablePage):
"""技术博客文章页。"""
published_at = models.DateTimeField(verbose_name="发布时间")
intro = models.CharField(max_length=250, blank=True, verbose_name="摘要")
cover_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
verbose_name="封面图",
)
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
tags = ClusterTaggableManager(through=BlogPageTag, blank=True, verbose_name="标签")
search_fields = SEOablePage.search_fields + [
index.SearchField("intro"),
index.SearchField("body"),
]
content_panels = SEOablePage.content_panels + [
FieldPanel("published_at"),
FieldPanel("intro"),
FieldPanel("cover_image"),
FieldPanel("body"),
FieldPanel("tags"),
]
parent_page_types = ["blog.BlogIndexPage"]
subpage_types = []
class Meta:
verbose_name = "博客文章"
@@ -0,0 +1,14 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.intro }}</p>
<ul>
{% for post in posts %}
<li><a href="{% pageurl post %}">{{ post.title }}</a></li>
{% endfor %}
</ul>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
<p>{{ page.published_at }}</p>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
View File
+11
View File
@@ -0,0 +1,11 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
label = "core"
verbose_name = "核心 / 公共基础"
def ready(self):
from apps.core import signals # noqa: F401
+108
View File
@@ -0,0 +1,108 @@
"""
可复用的 StreamField Block 库。
参见 documents/设计方案分析与完善版.md §2.6 组件清单。
"""
from django.utils.translation import gettext_lazy as _
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock
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"
COMMON_BLOCKS = [
("hero", HeroBlock()),
("stats", StatsBlock()),
("feature_grid", FeatureGridBlock()),
("faq", FAQBlock()),
("cta", CTABlock()),
("logo_cloud", LogoCloudBlock()),
("richtext", blocks.RichTextBlock(label=_("富文本"))),
]
View File
+38
View File
@@ -0,0 +1,38 @@
"""
公共抽象模型。所有业务页面应继承 SEOablePage 而非直接继承 wagtail.models.Page
以统一获得 SEO 字段(详见 documents/设计方案分析与完善版.md §2.5)。
"""
from django.db import models
from wagtail.admin.panels import FieldPanel
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
+27
View File
@@ -0,0 +1,27 @@
"""
Wagtail signal handlers:发布内容后通知 Next.js 前端失效 ISR 缓存。
详见 documents/设计方案分析与完善版.md §2.7。
"""
import logging
import requests
from django.conf import settings
from django.dispatch import receiver
from wagtail.signals import page_published
logger = logging.getLogger(__name__)
@receiver(page_published)
def notify_frontend_revalidate(sender, instance, **kwargs):
if not settings.FRONTEND_REVALIDATE_URL:
return
try:
requests.post(
settings.FRONTEND_REVALIDATE_URL,
json={"tags": [f"page:{instance.id}", instance.url_path]},
headers={"Authorization": f"Bearer {settings.REVALIDATE_SECRET}"},
timeout=3,
)
except requests.RequestException:
logger.warning("Failed to notify frontend revalidate webhook", exc_info=True)
@@ -0,0 +1,5 @@
<section class="cta">
<h2>{{ value.heading }}</h2>
{% if value.description %}<p>{{ value.description }}</p>{% endif %}
<a href="{{ value.button.link }}">{{ value.button.text }}</a>
</section>
@@ -0,0 +1,9 @@
{% load wagtailcore_tags %}
<section class="faq">
{% for item in value.items %}
<div class="faq-item">
<h4>{{ item.question }}</h4>
<div>{{ item.answer|richtext }}</div>
</div>
{% endfor %}
</section>
@@ -0,0 +1,4 @@
<section class="feature">
<h3>{{ value.title }}</h3>
<p>{{ value.description }}</p>
</section>
@@ -0,0 +1,8 @@
{% load wagtailcore_tags %}
<section class="hero">
<h1>{{ value.title }}</h1>
{% if value.subtitle %}<p>{{ value.subtitle }}</p>{% endif %}
{% for button in value.buttons %}
<a href="{{ button.link }}">{{ button.text }}</a>
{% endfor %}
</section>
@@ -0,0 +1,10 @@
{% load wagtailimages_tags %}
<section class="logo-cloud">
{% if value.heading %}<h3>{{ value.heading }}</h3>{% endif %}
<div class="logos">
{% for logo in value.logos %}
{% image logo width-160 as logo_img %}
<img src="{{ logo_img.url }}" alt="">
{% endfor %}
</div>
</section>
@@ -0,0 +1,5 @@
<section class="stats">
{% for item in value.items %}
<div class="stat"><strong>{{ item.value }}</strong><span>{{ item.label }}</span></div>
{% endfor %}
</section>
+21
View File
@@ -0,0 +1,21 @@
"""
健康检查端点 Kubernetes liveness/readiness 探针使用
详见 documents/设计方案分析与完善版.md §2.12
"""
from django.db import connection
from django.http import JsonResponse
def healthz(request):
"""存活探针:仅确认进程可响应请求。"""
return JsonResponse({"status": "ok"})
def readyz(request):
"""就绪探针:确认数据库连接可用。"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception as exc: # pragma: no cover - defensive
return JsonResponse({"status": "error", "detail": str(exc)}, status=503)
return JsonResponse({"status": "ok"})
View File
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.home"
label = "home"
verbose_name = "首页"
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-08-06 05:29
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='HomePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
],
options={
'verbose_name': '首页',
},
bases=('wagtailcore.page',),
),
]
View File
+22
View File
@@ -0,0 +1,22 @@
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",
]
class Meta:
verbose_name = "首页"
+11
View File
@@ -0,0 +1,11 @@
{% load wagtailcore_tags %}
<!doctype html>
<html lang="zh-hans">
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
<body>
<h1>{{ page.title }}</h1>
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+361
View File
@@ -0,0 +1,361 @@
# 企业官网 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/ # 当前仓库根目录(后端优先落地)
├── manage.py
├── requirements/
│ ├── base.txt
│ ├── dev.txt
│ └── production.txt
├── config/ # 原 wagtailcms/ 设置包,拆分为分层 settings
│ ├── 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/ # 后续新增:Next.js 项目(独立子目录或独立仓库)
```
> 说明:是否采用 Turborepo/pnpm workspace 做单仓多包管理,取决于前后端是否同仓维护。若团队分工明确(后端/前端各自团队),建议**拆分为两个独立仓库**,通过 OpenAPI/GraphQL Schema 契约解耦,避免单仓耦合过重;本项目当前后端已独立成仓,建议前端也独立建仓。
### 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}] |
| FeatureBlock | 能力/优势 | icon, title, description |
| ProductCardBlock | 产品矩阵 | title, description, image, link |
| LogoCloudBlock | 客户 Logo 墙 | logos: [SnippetChooser(Partner)] |
| CaseStudyBlock | 案例卡片 | case_page: PageChooser, summary |
| PricingBlock | 定价 | plans: [{name, price, features}] |
| FAQBlock | 常见问题 | items: [{question, answer(richtext)}] |
| TimelineBlock | 发展历程 | items: [{year, event}] |
| TeamBlock | 团队展示 | members: [SnippetChooser(TeamMember)] |
| TechStackBlock | 技术架构图 | items: [{icon, label}] |
| VideoBlock | 视频 | video_file/embed_url, poster |
| FormBlock | 线索表单 | form: SnippetChooser(FormDefinition) |
| CTABlock | 行动号召 | heading, button_text, button_link |
所有 Block 统一放在 `apps/core/blocks/`,前后端各维护一份"type → 组件"映射表,并通过 CI 增加一致性校验(后端 Block 清单 vs 前端 `BlockRenderer` 映射表 diff)。
### 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 合规与本地化清单(新增,落地检查表)
- [ ] 完成 ICP 备案(域名 + 服务器均需在国内云厂商完成备案)
- [ ] 页脚展示备案号并链接工信部备案查询
- [ ] 隐私政策 / Cookie 同意条符合 PIPL 要求
- [ ] 字体/地图/验证码/CDN 均替换为可在国内正常访问的服务
- [ ] 表单收集的个人信息需明确告知用途并支持删除请求(PIPL 数据主体权利)
### 2.16 开发路线图(对齐当前仓库状态)
| 阶段 | 目标 | 状态 |
|---|---|---|
| **Phase 0** | 需求与设计文档完善(本文档) | ✅ 已完成 |
| **Phase 1** | Wagtail 基础 CMS + REST API + Next.js 首页渲染 + 基础 SEO | 🚧 即将开始(本次同步启动后端骨架搭建) |
| **Phase 2** | GraphQL、OpenSearch 中文搜索、工作流审核、多语言 | 待规划 |
| **Phase 3** | AI 能力(摘要/翻译/SEO 重写)、SaaS 化评估(视业务需要再决定是否引入多租户) | 待规划 |
### 2.17 风险与备选方案
| 风险 | 应对 |
|---|---|
| 过早引入多租户/微服务导致过度设计 | Phase 1/2 保持 Monolith,指标驱动再拆分 |
| 国内备案周期拖慢上线(通常 7-20 个工作日) | 项目启动即刻同步提交备案,不等开发完成 |
| Elasticsearch/OpenSearch 中文分词效果不佳 | 提前验证 IK 分词插件,或评估阿里云开放搜索托管方案 |
| StreamField Block 前后端字段不一致 | 建立 Block Schema 自动化一致性校验(CI) |
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.dev')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+7
View File
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,6 @@
-r base.txt
django-debug-toolbar>=4.4
pytest>=8.0
pytest-django>=4.9
factory-boy>=3.3
+6
View File
@@ -0,0 +1,6 @@
-r base.txt
psycopg[binary]>=3.2
django-storages[s3]>=1.14
gunicorn>=23.0
sentry-sdk>=2.0
View File
+16
View File
@@ -0,0 +1,16 @@
"""
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
+129
View File
@@ -0,0 +1,129 @@
"""
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.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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,47 @@
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"},
}
+45
View File
@@ -0,0 +1,45 @@
"""
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),
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
@@ -0,0 +1,16 @@
"""
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()