chore: initial commit of Wagtail backend scaffold
This commit is contained in:
@@ -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
@@ -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/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.api"
|
||||||
|
label = "api"
|
||||||
|
verbose_name = "API"
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BlogConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.blog"
|
||||||
|
label = "blog"
|
||||||
|
verbose_name = "技术博客"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-08-06 05:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import modelcluster.contrib.taggit
|
||||||
|
import modelcluster.fields
|
||||||
|
import wagtail.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
|
||||||
|
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
|
||||||
|
('wagtailimages', '0027_image_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BlogIndexPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
|
||||||
|
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
|
||||||
|
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
|
||||||
|
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
|
||||||
|
('intro', models.TextField(blank=True, verbose_name='栏目简介')),
|
||||||
|
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '博客栏目页',
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BlogPage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
|
||||||
|
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
|
||||||
|
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
|
||||||
|
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
|
||||||
|
('published_at', models.DateTimeField(verbose_name='发布时间')),
|
||||||
|
('intro', models.CharField(blank=True, max_length=250, verbose_name='摘要')),
|
||||||
|
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
|
||||||
|
('cover_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='封面图')),
|
||||||
|
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '博客文章',
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BlogPageTag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.blogpage')),
|
||||||
|
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='blogpage',
|
||||||
|
name='tags',
|
||||||
|
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.BlogPageTag', to='taggit.Tag', verbose_name='标签'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
from django.db import models
|
||||||
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||||
|
from modelcluster.fields import ParentalKey
|
||||||
|
from taggit.models import TaggedItemBase
|
||||||
|
from wagtail.admin.panels import FieldPanel
|
||||||
|
from wagtail.fields import StreamField
|
||||||
|
from wagtail.search import index
|
||||||
|
|
||||||
|
from apps.core.blocks import COMMON_BLOCKS
|
||||||
|
from apps.core.models import SEOablePage
|
||||||
|
|
||||||
|
|
||||||
|
class BlogIndexPage(SEOablePage):
|
||||||
|
"""博客列表页,本身不直接管理内容,子页面为 BlogPage。"""
|
||||||
|
|
||||||
|
intro = models.TextField(blank=True, verbose_name="栏目简介")
|
||||||
|
|
||||||
|
content_panels = SEOablePage.content_panels + [
|
||||||
|
FieldPanel("intro"),
|
||||||
|
]
|
||||||
|
|
||||||
|
subpage_types = ["blog.BlogPage"]
|
||||||
|
parent_page_types = ["home.HomePage"]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "博客栏目页"
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
context = super().get_context(request, *args, **kwargs)
|
||||||
|
context["posts"] = (
|
||||||
|
BlogPage.objects.live().descendant_of(self).order_by("-published_at")
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPageTag(TaggedItemBase):
|
||||||
|
content_object = ParentalKey(
|
||||||
|
"BlogPage", related_name="tagged_items", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogPage(SEOablePage):
|
||||||
|
"""技术博客文章页。"""
|
||||||
|
|
||||||
|
published_at = models.DateTimeField(verbose_name="发布时间")
|
||||||
|
intro = models.CharField(max_length=250, blank=True, verbose_name="摘要")
|
||||||
|
cover_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
verbose_name="封面图",
|
||||||
|
)
|
||||||
|
body = StreamField(COMMON_BLOCKS, use_json_field=True, blank=True)
|
||||||
|
tags = ClusterTaggableManager(through=BlogPageTag, blank=True, verbose_name="标签")
|
||||||
|
|
||||||
|
search_fields = SEOablePage.search_fields + [
|
||||||
|
index.SearchField("intro"),
|
||||||
|
index.SearchField("body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
content_panels = SEOablePage.content_panels + [
|
||||||
|
FieldPanel("published_at"),
|
||||||
|
FieldPanel("intro"),
|
||||||
|
FieldPanel("cover_image"),
|
||||||
|
FieldPanel("body"),
|
||||||
|
FieldPanel("tags"),
|
||||||
|
]
|
||||||
|
|
||||||
|
parent_page_types = ["blog.BlogIndexPage"]
|
||||||
|
subpage_types = []
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "博客文章"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.intro }}</p>
|
||||||
|
<ul>
|
||||||
|
{% for post in posts %}
|
||||||
|
<li><a href="{% pageurl post %}">{{ post.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-hans">
|
||||||
|
<head><meta charset="utf-8"><title>{{ page.title }}</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
|
<p>{{ page.published_at }}</p>
|
||||||
|
{% for block in page.body %}
|
||||||
|
{% include_block block %}
|
||||||
|
{% endfor %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.core"
|
||||||
|
label = "core"
|
||||||
|
verbose_name = "核心 / 公共基础"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from apps.core import signals # noqa: F401
|
||||||
@@ -0,0 +1,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=_("富文本"))),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
Wagtail signal handlers:发布内容后通知 Next.js 前端失效 ISR 缓存。
|
||||||
|
详见 documents/设计方案分析与完善版.md §2.7。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from wagtail.signals import page_published
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(page_published)
|
||||||
|
def notify_frontend_revalidate(sender, instance, **kwargs):
|
||||||
|
if not settings.FRONTEND_REVALIDATE_URL:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
settings.FRONTEND_REVALIDATE_URL,
|
||||||
|
json={"tags": [f"page:{instance.id}", instance.url_path]},
|
||||||
|
headers={"Authorization": f"Bearer {settings.REVALIDATE_SECRET}"},
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.warning("Failed to notify frontend revalidate webhook", exc_info=True)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<section class="cta">
|
||||||
|
<h2>{{ value.heading }}</h2>
|
||||||
|
{% if value.description %}<p>{{ value.description }}</p>{% endif %}
|
||||||
|
<a href="{{ value.button.link }}">{{ value.button.text }}</a>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<section class="faq">
|
||||||
|
{% for item in value.items %}
|
||||||
|
<div class="faq-item">
|
||||||
|
<h4>{{ item.question }}</h4>
|
||||||
|
<div>{{ item.answer|richtext }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<section class="feature">
|
||||||
|
<h3>{{ value.title }}</h3>
|
||||||
|
<p>{{ value.description }}</p>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% load wagtailcore_tags %}
|
||||||
|
<section class="hero">
|
||||||
|
<h1>{{ value.title }}</h1>
|
||||||
|
{% if value.subtitle %}<p>{{ value.subtitle }}</p>{% endif %}
|
||||||
|
{% for button in value.buttons %}
|
||||||
|
<a href="{{ button.link }}">{{ button.text }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% load wagtailimages_tags %}
|
||||||
|
<section class="logo-cloud">
|
||||||
|
{% if value.heading %}<h3>{{ value.heading }}</h3>{% endif %}
|
||||||
|
<div class="logos">
|
||||||
|
{% for logo in value.logos %}
|
||||||
|
{% image logo width-160 as logo_img %}
|
||||||
|
<img src="{{ logo_img.url }}" alt="">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<section class="stats">
|
||||||
|
{% for item in value.items %}
|
||||||
|
<div class="stat"><strong>{{ item.value }}</strong><span>{{ item.label }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
健康检查端点,供 Kubernetes liveness/readiness 探针使用。
|
||||||
|
详见 documents/设计方案分析与完善版.md §2.12。
|
||||||
|
"""
|
||||||
|
from django.db import connection
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
|
||||||
|
def healthz(request):
|
||||||
|
"""存活探针:仅确认进程可响应请求。"""
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
def readyz(request):
|
||||||
|
"""就绪探针:确认数据库连接可用。"""
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return JsonResponse({"status": "error", "detail": str(exc)}, status=503)
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HomeConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.home"
|
||||||
|
label = "home"
|
||||||
|
verbose_name = "首页"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-08-06 05:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import wagtail.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('wagtailcore', '0097_baselogentry_uuid_action_timestamp_indexes'),
|
||||||
|
('wagtailimages', '0027_image_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HomePage',
|
||||||
|
fields=[
|
||||||
|
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
|
||||||
|
('seo_title_override', models.CharField(blank=True, help_text='留空则使用页面标题', max_length=70, verbose_name='SEO 标题')),
|
||||||
|
('seo_description_override', models.CharField(blank=True, max_length=160, verbose_name='SEO 描述')),
|
||||||
|
('canonical_url', models.URLField(blank=True, verbose_name='Canonical URL')),
|
||||||
|
('schema_json', models.JSONField(blank=True, default=dict, verbose_name='结构化数据 (JSON-LD)')),
|
||||||
|
('body', wagtail.fields.StreamField([('hero', 7), ('stats', 12), ('feature_grid', 19), ('faq', 24), ('cta', 27), ('logo_cloud', 31), ('richtext', 32)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 120}), 1: ('wagtail.blocks.TextBlock', (), {'label': '副标题', 'required': False}), 2: ('wagtail.images.blocks.ImageChooserBlock', (), {'label': '背景图', 'required': False}), 3: ('wagtail.blocks.CharBlock', (), {'label': '按钮文案', 'max_length': 50}), 4: ('wagtail.blocks.URLBlock', (), {'label': '按钮链接'}), 5: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {'label': '按钮组'}), 7: ('wagtail.blocks.StructBlock', [[('title', 0), ('subtitle', 1), ('background_image', 2), ('buttons', 6)]], {}), 8: ('wagtail.blocks.CharBlock', (), {'label': '数值', 'max_length': 20}), 9: ('wagtail.blocks.CharBlock', (), {'label': '说明文字', 'max_length': 50}), 10: ('wagtail.blocks.StructBlock', [[('value', 8), ('label', 9)]], {}), 11: ('wagtail.blocks.ListBlock', (10,), {'label': '数据项'}), 12: ('wagtail.blocks.StructBlock', [[('items', 11)]], {}), 13: ('wagtail.blocks.CharBlock', (), {'label': '模块标题', 'max_length': 100, 'required': False}), 14: ('wagtail.blocks.CharBlock', (), {'label': '图标', 'max_length': 50, 'required': False}), 15: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100}), 16: ('wagtail.blocks.TextBlock', (), {'label': '描述'}), 17: ('wagtail.blocks.StructBlock', [[('icon', 14), ('title', 15), ('description', 16)]], {}), 18: ('wagtail.blocks.ListBlock', (17,), {'label': '能力列表'}), 19: ('wagtail.blocks.StructBlock', [[('heading', 13), ('items', 18)]], {}), 20: ('wagtail.blocks.CharBlock', (), {'label': '问题', 'max_length': 200}), 21: ('wagtail.blocks.RichTextBlock', (), {'label': '回答'}), 22: ('wagtail.blocks.StructBlock', [[('question', 20), ('answer', 21)]], {}), 23: ('wagtail.blocks.ListBlock', (22,), {'label': '问答列表'}), 24: ('wagtail.blocks.StructBlock', [[('items', 23)]], {}), 25: ('wagtail.blocks.TextBlock', (), {'label': '描述', 'required': False}), 26: ('wagtail.blocks.StructBlock', [[('text', 3), ('link', 4)]], {'label': '按钮'}), 27: ('wagtail.blocks.StructBlock', [[('heading', 0), ('description', 25), ('button', 26)]], {}), 28: ('wagtail.blocks.CharBlock', (), {'label': '标题', 'max_length': 100, 'required': False}), 29: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 30: ('wagtail.blocks.ListBlock', (29,), {'label': 'Logo 列表'}), 31: ('wagtail.blocks.StructBlock', [[('heading', 28), ('logos', 30)]], {}), 32: ('wagtail.blocks.RichTextBlock', (), {'label': '富文本'})})),
|
||||||
|
('og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='社交分享图')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '首页',
|
||||||
|
},
|
||||||
|
bases=('wagtailcore.page',),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 = "首页"
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,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. 没有考虑富文本 XSS(Wagtail RichTextField 需要配置白名单 `WAGTAILADMIN_RICH_TEXT_EDITORS` / `bleach`)。
|
||||||
|
|
||||||
|
**C. 本地化 / 合规缺失(重要)**
|
||||||
|
9. 所有文档默认使用 **AWS S3 + Cloudflare CDN + Google 字体生态**,但"整体设计图.png"高保真稿明确显示目标是**中国大陆企业官网**(简体中文导航、"沪ICP备xxxxxxxx号-1"、中国移动/招商银行/中国平安/上汽集团/美团 等客户 Logo)。这是一个关键但被忽视的落地风险:
|
||||||
|
- AWS S3/Cloudflare 在中国大陆访问速度和合规性都存在问题,需替换为 **阿里云 OSS/腾讯云 COS + 阿里云 CDN/腾讯云 CDN** 等国内方案。
|
||||||
|
- 网站备案:需要 **ICP 备案**(个人/企业备案流程、幕布拍照要求等),这会影响上线时间表,必须提前规划。
|
||||||
|
- 需要 **隐私政策 / Cookie 同意条**,遵循《个人信息保护法》(PIPL),而不是文档里默认的 GDPR 话术。
|
||||||
|
- Google Fonts / Google Maps 在中国大陆不可用,需要自托管字体(思源黑体/HarmonyOS Sans 子集化)、替换为高德/腾讯地图。
|
||||||
|
- 人机验证码建议用极验/腾讯云验证码而非 reCAPTCHA。
|
||||||
|
- 表单营销自动化提到 HubSpot/Salesforce,国内客户更常用企业微信 + 探迹/纷享销客等 CRM,需要补充可选国内方案。
|
||||||
|
|
||||||
|
**D. 工程化缺口**
|
||||||
|
10. 没有测试策略(单元测试、集成测试、E2E),文档提到 `pytest` 仅在 lint 章节一笔带过。
|
||||||
|
11. 没有环境变量与密钥管理方案(`.env.example`、多环境 settings 分层的实际代码)。
|
||||||
|
12. 没有健康检查 / 就绪探针(`/healthz`、`/readyz`),K8s 部署缺了这个会导致滚动更新时无法探活。
|
||||||
|
13. 日志没有约定格式(结构化 JSON 日志 + request-id 贯穿链路),排障困难。
|
||||||
|
|
||||||
|
**E. 内容建模细节不足**
|
||||||
|
14. StreamField Block 只给了 `HeroBlock`、`FeatureBlock` 两个例子,官网设计方案里列出的 14 个 Block(Stats/CTA/Pricing/FAQ/CaseStudy/Timeline/Team/LogoCloud/TechStack/BlogList/Video/Form 等)都没有代码,容易导致前后端字段不对齐。
|
||||||
|
15. 没有说明图片如何做响应式(Wagtail `Renditions` + `srcset`)与前端 `next/image` 的对接方式。
|
||||||
|
|
||||||
|
### 1.4 与当前代码仓库现状的差距
|
||||||
|
|
||||||
|
当前仓库仅是 `django-admin startproject` 生成的裸项目(Django 6.0.5,未安装 Wagtail/DRF,`INSTALLED_APPS` 只有 Django 内置应用,`urls.py` 只有 `admin/`)。也就是说,**目前处于"设计文档阶段",代码尚未开始落地**,因此完善设计的同时,应当同步启动 Phase 1 的工程骨架搭建(见本文档第二部分 §18 路线图,以及仓库中已完成的初始化改动)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、完善后的整体设计方案
|
||||||
|
|
||||||
|
### 2.1 项目目标
|
||||||
|
|
||||||
|
在原有目标基础上明确补充**中国大陆本地化与合规目标**:
|
||||||
|
|
||||||
|
- 面向中国大陆企业客户的现代化 B2B 官网 + Headless CMS 内容中台
|
||||||
|
- 前后端彻底分离:Wagtail(内容与 API)+ Next.js(展示层)
|
||||||
|
- 满足 ICP 备案、PIPL 合规、国内 CDN/存储加速
|
||||||
|
- 可平滑演进到多语言(中/英/日)、多站点,**暂不做多租户**(YAGNI,待 SaaS 化需求明确后再引入 `django-tenants`)
|
||||||
|
- 内容编辑体验优先(StreamField 组件化、预览、审核流)
|
||||||
|
|
||||||
|
### 2.2 总体架构
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Client
|
||||||
|
Browser["浏览器 / 移动端"]
|
||||||
|
end
|
||||||
|
subgraph Edge["Edge / CDN"]
|
||||||
|
CDN["国内 CDN(阿里云 CDN / 腾讯云 CDN)"]
|
||||||
|
end
|
||||||
|
subgraph FE["Next.js 前端(App Router)"]
|
||||||
|
NextApp["React 19 + SSR/ISR"]
|
||||||
|
end
|
||||||
|
subgraph Gateway
|
||||||
|
Nginx["Nginx / Traefik 网关"]
|
||||||
|
end
|
||||||
|
subgraph BE["Wagtail 后端"]
|
||||||
|
Wagtail["Django 5 + Wagtail 7"]
|
||||||
|
DRF["DRF REST v2"]
|
||||||
|
GQL["Strawberry GraphQL"]
|
||||||
|
end
|
||||||
|
subgraph Infra["基础设施"]
|
||||||
|
PG[(PostgreSQL)]
|
||||||
|
Redis[(Redis)]
|
||||||
|
Search[(OpenSearch)]
|
||||||
|
OSS[(阿里云 OSS / 腾讯云 COS)]
|
||||||
|
Celery["Celery Worker"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser --> CDN --> NextApp --> Nginx --> Wagtail
|
||||||
|
Wagtail --> DRF
|
||||||
|
Wagtail --> GQL
|
||||||
|
Wagtail --> PG
|
||||||
|
Wagtail --> Redis
|
||||||
|
Wagtail --> Search
|
||||||
|
Wagtail --> OSS
|
||||||
|
Wagtail -. 异步任务 .-> Celery
|
||||||
|
Wagtail -- "publish webhook" --> NextApp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 技术栈(含国内替代方案对照)
|
||||||
|
|
||||||
|
| 分层 | 通用方案(原文档) | 中国大陆推荐替代 / 补充 |
|
||||||
|
|---|---|---|
|
||||||
|
| 对象存储 | AWS S3 / MinIO | 阿里云 OSS / 腾讯云 COS(`django-storages` 均支持 S3 兼容协议) |
|
||||||
|
| CDN | Cloudflare | 阿里云 CDN、腾讯云 CDN、又拍云 |
|
||||||
|
| 字体 | Google Fonts | 思源黑体 / HarmonyOS Sans,构建期子集化后自托管,避免运行时依赖 Google |
|
||||||
|
| 地图 | Google Maps | 高德地图 / 腾讯地图 JS SDK |
|
||||||
|
| 验证码 | reCAPTCHA | 极验 GeeTest / 腾讯云验证码 |
|
||||||
|
| 短信/通知 | 无 | 阿里云短信服务 + 企业微信机器人 Webhook(替代 Slack) |
|
||||||
|
| 登录方式(可选) | OAuth2/Auth0/Keycloak | 保留通用 OAuth2;如需 C 端可加"企业微信扫码登录" |
|
||||||
|
| 搜索 | Elasticsearch/OpenSearch | 同左,国内可用阿里云 OpenSearch 托管版或自建 |
|
||||||
|
| 备案 | 无 | 需在页脚展示 ICP 备案号,域名/服务器需完成 ICP 备案后方可上线 |
|
||||||
|
|
||||||
|
其余后端(Django/Wagtail/DRF/GraphQL/PostgreSQL/Redis/Celery)与前端(Next.js/React/Tailwind/Zustand/React Query)技术栈维持原文档选型不变,属于合理选型。
|
||||||
|
|
||||||
|
### 2.4 Monorepo 目录结构(沿用并细化)
|
||||||
|
|
||||||
|
```
|
||||||
|
wagtailcms/ # 当前仓库根目录(后端优先落地)
|
||||||
|
├── 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 设计规范(补全缺失细节)
|
||||||
|
|
||||||
|
**REST(Wagtail API v2 + DRF 自定义)**
|
||||||
|
- 统一前缀:`/api/v2/`(Wagtail 官方 API v2)+ `/api/v1/custom/`(自定义业务接口,如表单提交)
|
||||||
|
- 分页:统一 `limit` / `offset`,响应体包含 `meta: {total, limit, offset}`
|
||||||
|
- 排序/过滤:`?order=-first_published_at`、`?type=blog.BlogPage`
|
||||||
|
- 错误响应统一结构:
|
||||||
|
```json
|
||||||
|
{ "error": { "code": "NOT_FOUND", "message": "..." } }
|
||||||
|
```
|
||||||
|
- 限流:DRF `ScopedRateThrottle`,公开只读接口 `100/min`,表单提交接口 `5/min`(防刷)
|
||||||
|
- 版本弃用策略:新版本上线后旧版本保留至少 2 个发布周期,响应头带 `Deprecation` / `Sunset`
|
||||||
|
|
||||||
|
**预览模式(原文档缺失,重要补充)**
|
||||||
|
- Wagtail 后台"预览"生成一次性 `preview_token`(存 Redis,TTL 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) |
|
||||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wagtailcms.settings.dev')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-r base.txt
|
||||||
|
|
||||||
|
django-debug-toolbar>=4.4
|
||||||
|
pytest>=8.0
|
||||||
|
pytest-django>=4.9
|
||||||
|
factory-boy>=3.3
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-r base.txt
|
||||||
|
|
||||||
|
psycopg[binary]>=3.2
|
||||||
|
django-storages[s3]>=1.14
|
||||||
|
gunicorn>=23.0
|
||||||
|
sentry-sdk>=2.0
|
||||||
@@ -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()
|
||||||
@@ -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="")
|
||||||
@@ -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.js(localhost:3000)联调
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
WAGTAIL_CACHE_BACKEND = None
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user