commit 7f51a31150b2e9e2da2d79683214362b9067f16f Author: Zhengen TANG Date: Thu Aug 6 15:56:14 2026 +0800 chore: initial commit of Wagtail backend scaffold diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f861f62 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f2e6bd --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..d022f03 --- /dev/null +++ b/apps/api/apps.py @@ -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" diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..e722004 --- /dev/null +++ b/apps/api/urls.py @@ -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) diff --git a/apps/blog/__init__.py b/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..29f7f46 --- /dev/null +++ b/apps/blog/apps.py @@ -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 = "技术博客" diff --git a/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..7ef06f5 --- /dev/null +++ b/apps/blog/migrations/0001_initial.py @@ -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='标签'), + ), + ] diff --git a/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models.py b/apps/blog/models.py new file mode 100644 index 0000000..dafd20b --- /dev/null +++ b/apps/blog/models.py @@ -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 = "博客文章" diff --git a/apps/blog/templates/blog/blog_index_page.html b/apps/blog/templates/blog/blog_index_page.html new file mode 100644 index 0000000..1a14b4e --- /dev/null +++ b/apps/blog/templates/blog/blog_index_page.html @@ -0,0 +1,14 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.intro }}

+ + + diff --git a/apps/blog/templates/blog/blog_page.html b/apps/blog/templates/blog/blog_page.html new file mode 100644 index 0000000..b67e0ad --- /dev/null +++ b/apps/blog/templates/blog/blog_page.html @@ -0,0 +1,12 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+

{{ page.published_at }}

+ {% for block in page.body %} + {% include_block block %} + {% endfor %} + + diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..b63cfdd --- /dev/null +++ b/apps/core/apps.py @@ -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 diff --git a/apps/core/blocks.py b/apps/core/blocks.py new file mode 100644 index 0000000..8809ae8 --- /dev/null +++ b/apps/core/blocks.py @@ -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=_("富文本"))), +] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..315a87e --- /dev/null +++ b/apps/core/models.py @@ -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 diff --git a/apps/core/signals.py b/apps/core/signals.py new file mode 100644 index 0000000..4c1b315 --- /dev/null +++ b/apps/core/signals.py @@ -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) diff --git a/apps/core/templates/core/blocks/cta_block.html b/apps/core/templates/core/blocks/cta_block.html new file mode 100644 index 0000000..a00e705 --- /dev/null +++ b/apps/core/templates/core/blocks/cta_block.html @@ -0,0 +1,5 @@ +
+

{{ value.heading }}

+ {% if value.description %}

{{ value.description }}

{% endif %} + {{ value.button.text }} +
diff --git a/apps/core/templates/core/blocks/faq_block.html b/apps/core/templates/core/blocks/faq_block.html new file mode 100644 index 0000000..a2a2f09 --- /dev/null +++ b/apps/core/templates/core/blocks/faq_block.html @@ -0,0 +1,9 @@ +{% load wagtailcore_tags %} +
+ {% for item in value.items %} +
+

{{ item.question }}

+
{{ item.answer|richtext }}
+
+ {% endfor %} +
diff --git a/apps/core/templates/core/blocks/feature_block.html b/apps/core/templates/core/blocks/feature_block.html new file mode 100644 index 0000000..15cff16 --- /dev/null +++ b/apps/core/templates/core/blocks/feature_block.html @@ -0,0 +1,4 @@ +
+

{{ value.title }}

+

{{ value.description }}

+
diff --git a/apps/core/templates/core/blocks/hero_block.html b/apps/core/templates/core/blocks/hero_block.html new file mode 100644 index 0000000..3590603 --- /dev/null +++ b/apps/core/templates/core/blocks/hero_block.html @@ -0,0 +1,8 @@ +{% load wagtailcore_tags %} +
+

{{ value.title }}

+ {% if value.subtitle %}

{{ value.subtitle }}

{% endif %} + {% for button in value.buttons %} + {{ button.text }} + {% endfor %} +
diff --git a/apps/core/templates/core/blocks/logo_cloud_block.html b/apps/core/templates/core/blocks/logo_cloud_block.html new file mode 100644 index 0000000..260303b --- /dev/null +++ b/apps/core/templates/core/blocks/logo_cloud_block.html @@ -0,0 +1,10 @@ +{% load wagtailimages_tags %} +
+ {% if value.heading %}

{{ value.heading }}

{% endif %} +
+ {% for logo in value.logos %} + {% image logo width-160 as logo_img %} + + {% endfor %} +
+
diff --git a/apps/core/templates/core/blocks/stats_block.html b/apps/core/templates/core/blocks/stats_block.html new file mode 100644 index 0000000..373d267 --- /dev/null +++ b/apps/core/templates/core/blocks/stats_block.html @@ -0,0 +1,5 @@ +
+ {% for item in value.items %} +
{{ item.value }}{{ item.label }}
+ {% endfor %} +
diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..ccf3f36 --- /dev/null +++ b/apps/core/views.py @@ -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"}) diff --git a/apps/home/__init__.py b/apps/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/home/apps.py b/apps/home/apps.py new file mode 100644 index 0000000..adbcca2 --- /dev/null +++ b/apps/home/apps.py @@ -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 = "首页" diff --git a/apps/home/migrations/0001_initial.py b/apps/home/migrations/0001_initial.py new file mode 100644 index 0000000..1c2f175 --- /dev/null +++ b/apps/home/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/apps/home/migrations/__init__.py b/apps/home/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/home/models.py b/apps/home/models.py new file mode 100644 index 0000000..4f33254 --- /dev/null +++ b/apps/home/models.py @@ -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 = "首页" diff --git a/apps/home/templates/home/home_page.html b/apps/home/templates/home/home_page.html new file mode 100644 index 0000000..617c680 --- /dev/null +++ b/apps/home/templates/home/home_page.html @@ -0,0 +1,11 @@ +{% load wagtailcore_tags %} + + +{{ page.title }} + +

{{ page.title }}

+ {% for block in page.body %} + {% include_block block %} + {% endfor %} + + diff --git a/documents/Next.js + Wagtail 企业级 Headless CMS 完整项目代码架构.pdf b/documents/Next.js + Wagtail 企业级 Headless CMS 完整项目代码架构.pdf new file mode 100644 index 0000000..6d6b5e1 Binary files /dev/null and b/documents/Next.js + Wagtail 企业级 Headless CMS 完整项目代码架构.pdf differ diff --git a/documents/Next.js 企业官网完整源码模板(基于 Wagtail Headless CMS).pdf b/documents/Next.js 企业官网完整源码模板(基于 Wagtail Headless CMS).pdf new file mode 100644 index 0000000..2124991 Binary files /dev/null and b/documents/Next.js 企业官网完整源码模板(基于 Wagtail Headless CMS).pdf differ diff --git a/documents/Wagtail CMS Backend 完整源码(企业级 Headless CMS).pdf b/documents/Wagtail CMS Backend 完整源码(企业级 Headless CMS).pdf new file mode 100644 index 0000000..d0027cc Binary files /dev/null and b/documents/Wagtail CMS Backend 完整源码(企业级 Headless CMS).pdf differ diff --git a/documents/基于 Wagtail 的现代前后端分离 CMS 设计方案.pdf b/documents/基于 Wagtail 的现代前后端分离 CMS 设计方案.pdf new file mode 100644 index 0000000..89d6e01 Binary files /dev/null and b/documents/基于 Wagtail 的现代前后端分离 CMS 设计方案.pdf differ diff --git a/documents/整体设计图.png b/documents/整体设计图.png new file mode 100644 index 0000000..8774965 Binary files /dev/null and b/documents/整体设计图.png differ diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md new file mode 100644 index 0000000..aa28367 --- /dev/null +++ b/documents/设计方案分析与完善版.md @@ -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) | diff --git a/documents/软件公司企业官网设计方案(基于 Wagtail Headless CMS).pdf b/documents/软件公司企业官网设计方案(基于 Wagtail Headless CMS).pdf new file mode 100644 index 0000000..bec39a5 Binary files /dev/null and b/documents/软件公司企业官网设计方案(基于 Wagtail Headless CMS).pdf differ diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..9fcc462 --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..c8b85bd --- /dev/null +++ b/requirements/base.txt @@ -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 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..e36e3a2 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,6 @@ +-r base.txt + +django-debug-toolbar>=4.4 +pytest>=8.0 +pytest-django>=4.9 +factory-boy>=3.3 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..3d3c5b1 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,6 @@ +-r base.txt + +psycopg[binary]>=3.2 +django-storages[s3]>=1.14 +gunicorn>=23.0 +sentry-sdk>=2.0 diff --git a/wagtailcms/__init__.py b/wagtailcms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wagtailcms/asgi.py b/wagtailcms/asgi.py new file mode 100644 index 0000000..eba9bb6 --- /dev/null +++ b/wagtailcms/asgi.py @@ -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() diff --git a/wagtailcms/settings/__init__.py b/wagtailcms/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py new file mode 100644 index 0000000..d4faca0 --- /dev/null +++ b/wagtailcms/settings/base.py @@ -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="") diff --git a/wagtailcms/settings/dev.py b/wagtailcms/settings/dev.py new file mode 100644 index 0000000..840d4f6 --- /dev/null +++ b/wagtailcms/settings/dev.py @@ -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 diff --git a/wagtailcms/settings/production.py b/wagtailcms/settings/production.py new file mode 100644 index 0000000..d51a7b8 --- /dev/null +++ b/wagtailcms/settings/production.py @@ -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"}, +} diff --git a/wagtailcms/urls.py b/wagtailcms/urls.py new file mode 100644 index 0000000..0be20f9 --- /dev/null +++ b/wagtailcms/urls.py @@ -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)), +] diff --git a/wagtailcms/wsgi.py b/wagtailcms/wsgi.py new file mode 100644 index 0000000..b58d7ea --- /dev/null +++ b/wagtailcms/wsgi.py @@ -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()