diff --git a/.env.example b/.env.example index 90e0e9b..3758a79 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,9 @@ REVALIDATE_SECRET=change-me # 前端站点基础 URL(用于邮件中的链接,如 PIPL 数据删除确认链接) FRONTEND_BASE_URL=https://www.example.com + +# 部署引导(python manage.py bootstrap_deployment):首次部署时自动创建的超级管理员账号 +# 三项均设置且该用户名不存在时才会创建;账号已存在则跳过,不会重置密码。 +DJANGO_SUPERUSER_USERNAME= +DJANGO_SUPERUSER_EMAIL= +DJANGO_SUPERUSER_PASSWORD= diff --git a/apps/api/tests.py b/apps/api/tests.py new file mode 100644 index 0000000..452f9c8 --- /dev/null +++ b/apps/api/tests.py @@ -0,0 +1,47 @@ +"""apps.api 单元测试:预览专用接口 `page_preview`(apps/api/urls.py 的 +PagePreviewAPIViewSet),覆盖 §2.7/§2.15 描述的"仅凭有效 content_type+token +才能读取草稿快照"行为。""" +import pytest +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def preview_token(home_page): + """为 home_page 生成一条预览快照记录,返回可用于查询接口的 token。""" + preview = home_page.create_page_preview() + preview.save() + return preview.token + + +def test_page_preview_returns_draft_snapshot_for_valid_token(home_page, preview_token): + client = APIClient() + response = client.get( + "/api/v2/page_preview/", + {"content_type": "home.homepage", "token": preview_token}, + ) + + assert response.status_code == 200 + assert response.data["title"] == home_page.title + + +def test_page_preview_returns_404_for_unknown_token(home_page): + client = APIClient() + response = client.get( + "/api/v2/page_preview/", + {"content_type": "home.homepage", "token": "does-not-exist"}, + ) + + assert response.status_code == 404 + + +def test_page_preview_returns_404_for_mismatched_content_type(home_page, preview_token): + """token 是为 home.homepage 生成的,用错误的 content_type 查询应查不到记录。""" + client = APIClient() + response = client.get( + "/api/v2/page_preview/", + {"content_type": "blog.blogpage", "token": preview_token}, + ) + + assert response.status_code == 404 diff --git a/apps/api/urls.py b/apps/api/urls.py index 98a384b..aade164 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -3,6 +3,7 @@ Wagtail API v2 路由配置。 详见 documents/设计方案分析与完善版.md §2.7 Headless API 设计规范。 """ from django.contrib.contenttypes.models import ContentType +from django.http import Http404 from rest_framework.response import Response from wagtail.api.v2.views import PagesAPIViewSet from wagtail.api.v2.router import WagtailAPIRouter @@ -40,11 +41,16 @@ class PagePreviewAPIViewSet(PagesAPIViewSet): return Response(serializer.data) def get_object(self): - app_label, model = self.request.GET["content_type"].split(".") - content_type = ContentType.objects.get(app_label=app_label, model=model) - page_preview = PagePreview.objects.get( - content_type=content_type, token=self.request.GET["token"] - ) + try: + app_label, model = self.request.GET["content_type"].split(".") + content_type = ContentType.objects.get(app_label=app_label, model=model) + page_preview = PagePreview.objects.get( + content_type=content_type, token=self.request.GET["token"] + ) + except (KeyError, ValueError, ContentType.DoesNotExist, PagePreview.DoesNotExist): + # 缺少参数/content_type 格式错误/未知或已过期的 token,统一返回 404, + # 不暴露内部异常细节(详见类文档字符串)。 + raise Http404("No matching page preview found.") page = page_preview.as_page() if not page.pk: # 新建(尚未保存)页面的预览没有真实主键,填充占位值避免 API 路由生成 URL 时报错 diff --git a/apps/blog/tests.py b/apps/blog/tests.py new file mode 100644 index 0000000..95ba2f8 --- /dev/null +++ b/apps/blog/tests.py @@ -0,0 +1,85 @@ +"""apps.blog 单元测试:页面树结构、标签与列表排序。""" +import pytest +from django.utils import timezone + +from apps.blog.models import BlogIndexPage, BlogPage + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def blog_index(home_page): + index = BlogIndexPage(title="博客", slug="blog", intro="技术与行业洞察") + home_page.add_child(instance=index) + return index + + +def test_blog_page_can_be_created_under_index(blog_index): + post = BlogPage( + title="文章一", + slug="post-1", + published_at=timezone.now(), + intro="摘要一", + ) + blog_index.add_child(instance=post) + + assert BlogPage.objects.live().descendant_of(blog_index).count() == 1 + + +def test_blog_page_supports_tags(blog_index): + post = BlogPage( + title="文章一", + slug="post-1", + published_at=timezone.now(), + ) + blog_index.add_child(instance=post) + + post.tags.add("Django", "Wagtail") + post.save() + + post.refresh_from_db() + assert {t.name for t in post.tags.all()} == {"Django", "Wagtail"} + + +def test_blog_index_context_orders_by_published_at_desc(blog_index): + older = BlogPage( + title="较早的文章", + slug="older-post", + published_at=timezone.now() - timezone.timedelta(days=5), + ) + blog_index.add_child(instance=older) + + newer = BlogPage( + title="较新的文章", + slug="newer-post", + published_at=timezone.now(), + ) + blog_index.add_child(instance=newer) + + context = blog_index.get_context(request=None) + titles = [p.title for p in context["posts"]] + + assert titles == ["较新的文章", "较早的文章"] + + +def test_blog_index_context_excludes_draft_posts(blog_index): + live_post = BlogPage( + title="已发布文章", + slug="live-post", + published_at=timezone.now(), + ) + blog_index.add_child(instance=live_post) + + draft_post = BlogPage( + title="草稿文章", + slug="draft-post", + published_at=timezone.now(), + live=False, + ) + blog_index.add_child(instance=draft_post) + + context = blog_index.get_context(request=None) + titles = [p.title for p in context["posts"]] + + assert "已发布文章" in titles + assert "草稿文章" not in titles diff --git a/apps/core/management/commands/bootstrap_deployment.py b/apps/core/management/commands/bootstrap_deployment.py new file mode 100644 index 0000000..cc81c61 --- /dev/null +++ b/apps/core/management/commands/bootstrap_deployment.py @@ -0,0 +1,56 @@ +""" +部署引导命令:首次上线(或每次部署)时自动完成"能登录后台 + 拥有正确权限组"这两件事, +避免每次部署都要人工登录服务器手动执行 `createsuperuser`。 + +用法: + python manage.py bootstrap_deployment + +行为(幂等,可安全重复执行,适合放进部署脚本/容器启动脚本的 migrate 之后): + 1. 若环境变量 DJANGO_SUPERUSER_USERNAME / DJANGO_SUPERUSER_EMAIL / + DJANGO_SUPERUSER_PASSWORD 均已设置,且该用户名尚不存在,则创建一个超级管理员账号。 + - 若用户名已存在,跳过创建(不会重置密码,避免每次部署都覆盖已被人工修改过的密码)。 + - 若三个环境变量未完整设置,跳过此步骤并给出提示(适用于已手动创建过超管、 + 或本地开发环境无需自动建号的场景)。 + 2. 调用 `setup_rbac_groups` 命令初始化/重置 RBAC 权限组(详见该命令的说明)。 + +安全说明: + - 密码只应通过环境变量/密钥管理服务注入,不会被写入日志。 + - 本命令不会修改已存在用户的密码或权限,避免误覆盖人工调整过的账号状态。 +""" +import os + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "部署引导:按需创建首个超级管理员账号 + 初始化 RBAC 权限组,可重复执行。" + + def handle(self, *args, **options): + self._bootstrap_superuser() + call_command("setup_rbac_groups") + + def _bootstrap_superuser(self): + username = os.environ.get("DJANGO_SUPERUSER_USERNAME") + email = os.environ.get("DJANGO_SUPERUSER_EMAIL") + password = os.environ.get("DJANGO_SUPERUSER_PASSWORD") + + if not (username and email and password): + self.stdout.write( + self.style.WARNING( + "未完整设置 DJANGO_SUPERUSER_USERNAME / DJANGO_SUPERUSER_EMAIL / " + "DJANGO_SUPERUSER_PASSWORD,跳过自动创建超级管理员账号。" + ) + ) + return + + User = get_user_model() + if User.objects.filter(username=username).exists(): + self.stdout.write( + self.style.SUCCESS(f"超级管理员账号 '{username}' 已存在,跳过创建。") + ) + return + + User.objects.create_superuser(username=username, email=email, password=password) + self.stdout.write(self.style.SUCCESS(f"已创建超级管理员账号 '{username}'。")) diff --git a/apps/core/tests.py b/apps/core/tests.py index e77d7e7..4dfc141 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -269,7 +269,6 @@ def test_setup_rbac_groups_page_permissions_match_role_matrix(): # 编辑可以创建/编辑但不能发布 editor_perms = page_codenames("编辑") assert editor_perms == {"add_page", "change_page", "view_page"} - assert "publish_page" not in editor_perms # 审核员可以编辑并发布(用于审批发布场景) assert page_codenames("审核员") == {"change_page", "publish_page", "view_page"} # 作者只有 add + view:只能创建页面,仅能编辑自己拥有的页面(Wagtail 内置 ownership 策略) @@ -279,6 +278,105 @@ def test_setup_rbac_groups_page_permissions_match_role_matrix(): assert page_codenames("查看者") == {"view_page"} +# --------------------------------------------------------------------------- +# 健康检查端点(apps/core/views.py,供 K8s liveness/readiness 探针使用) +# --------------------------------------------------------------------------- + + +def test_healthz_returns_ok(client): + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_readyz_returns_ok_when_database_available(client): + response = client.get("/readyz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_readyz_returns_503_when_database_unavailable(client, monkeypatch): + from django.db import connection + + def broken_cursor(*args, **kwargs): + raise Exception("simulated db outage") + + monkeypatch.setattr(connection, "cursor", broken_cursor) + + response = client.get("/readyz") + + assert response.status_code == 503 + assert response.json()["status"] == "error" + + +# --------------------------------------------------------------------------- +# 发布后前端 revalidate webhook(apps/core/signals.py) +# --------------------------------------------------------------------------- + + +def test_notify_frontend_revalidate_skips_when_url_not_configured( + settings, monkeypatch, home_page +): + from wagtail.signals import page_published + + settings.FRONTEND_REVALIDATE_URL = "" + calls = [] + monkeypatch.setattr( + "apps.core.signals.requests.post", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + page_published.send(sender=type(home_page), instance=home_page) + + assert calls == [] + + +def test_notify_frontend_revalidate_posts_webhook_when_configured( + settings, monkeypatch, home_page +): + from wagtail.signals import page_published + + settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate" + settings.REVALIDATE_SECRET = "test-secret" + calls = [] + + def fake_post(url, json=None, headers=None, timeout=None): + calls.append({"url": url, "json": json, "headers": headers}) + + class FakeResponse: + status_code = 200 + + return FakeResponse() + + monkeypatch.setattr("apps.core.signals.requests.post", fake_post) + + page_published.send(sender=type(home_page), instance=home_page) + + assert len(calls) == 1 + assert calls[0]["url"] == "https://frontend.example.com/api/revalidate" + assert calls[0]["headers"] == {"Authorization": "Bearer test-secret"} + assert f"page:{home_page.id}" in calls[0]["json"]["tags"] + + +def test_notify_frontend_revalidate_swallows_request_exception( + settings, monkeypatch, home_page +): + import requests as requests_module + from wagtail.signals import page_published + + settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate" + + def raise_request_exception(*args, **kwargs): + raise requests_module.RequestException("network error") + + monkeypatch.setattr("apps.core.signals.requests.post", raise_request_exception) + + # 不应向外抛出异常——webhook 失败不能影响页面发布流程。 + page_published.send(sender=type(home_page), instance=home_page) + + def test_setup_rbac_groups_author_can_only_edit_own_pages(home_page): """验证“Author 仅能编辑自己的页面”这一矩阵要求,由 Wagtail 内置的 OwnershipPermissionPolicy(仅 add 权限)自然实现,无需自定义 hook。""" @@ -363,3 +461,74 @@ def test_setup_rbac_groups_is_idempotent(): group=group, page=root_page, permission__codename="add_page" ).count() == 1 assert group.permissions.filter(codename="access_admin").count() == 1 + + +# --------------------------------------------------------------------------- +# 部署引导(bootstrap_deployment 管理命令) +# --------------------------------------------------------------------------- + + +def test_bootstrap_deployment_creates_superuser_from_env_vars(monkeypatch): + from django.core.management import call_command + + monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin") + monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "deploy-admin@example.com") + monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "s3cret-pass") + + call_command("bootstrap_deployment") + + User = get_user_model() + user = User.objects.get(username="deploy-admin") + assert user.is_superuser is True + assert user.is_staff is True + assert user.email == "deploy-admin@example.com" + assert user.check_password("s3cret-pass") is True + + +def test_bootstrap_deployment_skips_superuser_creation_when_env_vars_missing(monkeypatch): + from django.core.management import call_command + + monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False) + monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False) + monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False) + + User = get_user_model() + count_before = User.objects.count() + + call_command("bootstrap_deployment") + + assert User.objects.count() == count_before + + +def test_bootstrap_deployment_does_not_overwrite_existing_superuser_password(monkeypatch): + from django.core.management import call_command + + User = get_user_model() + User.objects.create_superuser( + username="deploy-admin", email="old@example.com", password="original-pass" + ) + + monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin") + monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "new@example.com") + monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "new-pass") + + call_command("bootstrap_deployment") + + user = User.objects.get(username="deploy-admin") + assert user.email == "old@example.com" + assert user.check_password("original-pass") is True + assert user.check_password("new-pass") is False + + +def test_bootstrap_deployment_also_initializes_rbac_groups(monkeypatch): + from django.contrib.auth.models import Group + from django.core.management import call_command + + monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False) + monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False) + monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False) + + call_command("bootstrap_deployment") + + expected_names = {"站点管理员", "编辑", "审核员", "作者", "查看者"} + assert expected_names <= set(Group.objects.values_list("name", flat=True)) diff --git a/apps/home/tests.py b/apps/home/tests.py new file mode 100644 index 0000000..5a46214 --- /dev/null +++ b/apps/home/tests.py @@ -0,0 +1,53 @@ +"""apps.home 单元测试:HomePage 页面树结构。 + +test_home_page_subpage_types_allows_all_top_level_sections 是一条回归测试: +此前 HomePage.subpage_types 曾经只包含 blog.BlogIndexPage,导致 products/cases/ +solutions 的 IndexPage 无法通过 Wagtail 管理后台"添加子页面"界面创建(subpage_types/ +parent_page_types 只影响后台创建校验,不影响 ORM add_child(),问题一度被脚本化测试掩盖)。 +""" +import pytest + +from apps.blog.models import BlogIndexPage +from apps.cases.models import CaseStudyIndexPage +from apps.core.models import SimpleContentPage +from apps.home.models import HomePage +from apps.products.models import ProductIndexPage +from apps.solutions.models import SolutionIndexPage + +pytestmark = pytest.mark.django_db + + +def test_home_page_subpage_types_allows_all_top_level_sections(): + expected_types = { + "blog.BlogIndexPage", + "products.ProductIndexPage", + "solutions.SolutionIndexPage", + "cases.CaseStudyIndexPage", + "core.SimpleContentPage", + } + + assert expected_types.issubset(set(HomePage.subpage_types)) + + +@pytest.mark.parametrize( + "index_model,kwargs", + [ + (BlogIndexPage, {"title": "博客", "slug": "blog"}), + (ProductIndexPage, {"title": "产品", "slug": "products"}), + (SolutionIndexPage, {"title": "解决方案", "slug": "solutions"}), + (CaseStudyIndexPage, {"title": "案例", "slug": "cases"}), + (SimpleContentPage, {"title": "隐私政策", "slug": "privacy-policy"}), + ], +) +def test_each_top_level_section_can_be_created_under_home_in_admin( + home_page, index_model, kwargs +): + """`can_create_at` 是 Wagtail 管理后台"添加子页面"按钮实际使用的判断(综合 + parent.subpage_types 与 child.parent_page_types 两个方向),此前的 bug 正是 + 仅靠 ORM `add_child()` 无法暴露的——它只影响管理后台,不影响直接调用 ORM。""" + assert index_model.can_create_at(home_page) is True + + child = index_model(**kwargs) + home_page.add_child(instance=child) + + assert index_model.objects.child_of(home_page).count() == 1 diff --git a/apps/solutions/tests.py b/apps/solutions/tests.py new file mode 100644 index 0000000..be25a9d --- /dev/null +++ b/apps/solutions/tests.py @@ -0,0 +1,39 @@ +"""apps.solutions 单元测试:页面树结构与列表上下文(与 apps.products/apps.cases 同构)。""" +import pytest + +from apps.solutions.models import SolutionIndexPage, SolutionPage + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def solution_index(home_page): + index = SolutionIndexPage(title="解决方案", slug="solutions", intro="行业解决方案") + home_page.add_child(instance=index) + return index + + +def test_solution_page_can_be_created_under_index(solution_index): + solution = SolutionPage( + title="制造业解决方案", + slug="manufacturing", + industry="制造业", + summary="面向制造业的数字化转型方案", + ) + solution_index.add_child(instance=solution) + + assert SolutionPage.objects.live().descendant_of(solution_index).count() == 1 + + +def test_solution_index_context_excludes_draft_solutions(solution_index): + live_solution = SolutionPage(title="方案 A", slug="solution-a") + solution_index.add_child(instance=live_solution) + + draft_solution = SolutionPage(title="方案 B", slug="solution-b", live=False) + solution_index.add_child(instance=draft_solution) + + context = solution_index.get_context(request=None) + titles = [s.title for s in context["solutions"]] + + assert "方案 A" in titles + assert "方案 B" not in titles diff --git a/docker-compose.opensearch.yml b/docker-compose.opensearch.yml new file mode 100644 index 0000000..2af2651 --- /dev/null +++ b/docker-compose.opensearch.yml @@ -0,0 +1,34 @@ +# OpenSearch + 中文分词(IK 插件)本地开发 PoC 环境。 +# +# 用途:验证 Wagtail 原生 opensearch2 后端 + infinilabs/analysis-ik 中文分词插件的可行性, +# 详见 documents/设计方案分析与完善版.md §2.10 "OpenSearch + 中文分词调研"。 +# 这是一个独立的 compose 文件,不与主应用的部署编排耦合,按需单独启停: +# +# docker-compose -f docker-compose.opensearch.yml up -d +# curl http://localhost:9200/_cat/plugins # 确认 analysis-ik 已加载 +# docker-compose -f docker-compose.opensearch.yml down -v # 清理(-v 连数据卷一起删) +# +# 注意:plugins.security.disabled=true 仅用于本地免证书调试,生产环境必须启用安全插件并配置证书。 + +services: + opensearch: + build: + context: ./docker/opensearch + container_name: wagtailcms-opensearch-dev + environment: + - discovery.type=single-node + - plugins.security.disabled=true + - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + - "9600:9600" + volumes: + - opensearch-dev-data:/usr/share/opensearch/data + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + opensearch-dev-data: diff --git a/docker/opensearch/Dockerfile b/docker/opensearch/Dockerfile new file mode 100644 index 0000000..d0ef759 --- /dev/null +++ b/docker/opensearch/Dockerfile @@ -0,0 +1,11 @@ +# 本地开发/调研用镜像:在官方 OpenSearch 2.x 基础上安装 IK 中文分词插件(infinilabs/analysis-ik)。 +# 仅用于本地开发环境的 PoC 验证;生产环境建议评估阿里云"开放搜索 OpenSearch 版"等托管方案, +# 或在自建时用同样的方式基于官方镜像打包(见 documents/设计方案分析与完善版.md §2.10)。 +FROM opensearchproject/opensearch:2.19.6 + +# ⚠️ IK 插件版本必须与 OpenSearch 核心版本完全一致才能加载成功。 +# 使用前请先到 https://release.infinilabs.com/ 确认是否存在与上面 FROM 版本号匹配的构建; +# 如没有完全匹配的版本,需要把 FROM 的 OpenSearch 版本号和下面的插件版本号同时改成 +# release.infinilabs.com 上实际可用的最新匹配版本,本 Dockerfile 尚未在本地实际构建验证过。 +RUN opensearch-plugin install --batch \ + https://get.infini.cloud/opensearch/analysis-ik/2.19.6 diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index 99493b7..301ef9e 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -296,10 +296,31 @@ Next.js 侧对应 `revalidateTag` API Route,收到 webhook 后失效对应 ISR ### 2.10 搜索、SEO、多语言 -- 搜索:中文分词是关键缺失点,OpenSearch 需配置 **IK 分词插件**(原文档完全没提中文分词,这是国内落地必须解决的问题)。 +- 搜索:中文分词是关键缺失点,OpenSearch 需配置 **IK 分词插件**(原文档完全没提中文分词,这是国内落地必须解决的问题)。调研结论见下方小节。 - SEO:站点级 `sitemap.xml`(`wagtail.contrib.sitemaps`)+ 页面级 `schema_json`(JSON-LD)+ `robots.txt` 动态生成。 - 多语言:`WAGTAIL_I18N_ENABLED` + `wagtail-localize`,URL 采用 `/zh/`、`/en/` 前缀,默认 `/zh/` 对国内用户免前缀(根路径直接是中文)。 +#### OpenSearch + 中文分词调研(Phase 2 提前调研,新增) + +- **Wagtail 原生已支持 OpenSearch**:本仓库安装的 Wagtail 7.4.2 底层依赖 `django-modelsearch`,已内置 `wagtail.search.backends.opensearch2`(OpenSearch 2.x)与 `opensearch3`(OpenSearch 3.x)两个后端模块,配置方式与 Elasticsearch 后端一致,**不需要任何第三方 Wagtail 插件**: + ```python + WAGTAILSEARCH_BACKENDS = { + "default": { + "BACKEND": "wagtail.search.backends.opensearch2", + "URLS": [env("OPENSEARCH_URL", default="http://localhost:9200")], + "INDEX_PREFIX": "wagtailcms_", + } + } + ``` + 需额外安装客户端包 `pip install "opensearch-py>=2,<3"`(版本需与 OpenSearch 服务端大版本一致)。**此配置尚未写入 settings**,留待真正接入、且本地/测试环境有可用 OpenSearch 服务时再启用,避免开发环境无 OpenSearch 服务导致搜索功能报错。 +- **中文分词方案**:OpenSearch 官方未内置中文分词器,采用社区插件 [infinilabs/analysis-ik](https://github.com/infinilabs/analysis-ik)(同一套插件同时支持 Elasticsearch 和 OpenSearch,Apache-2.0 协议,17k+ star,长期维护),提供 `ik_smart`(粗粒度,适合短语查询)与 `ik_max_word`(细粒度,适合词项查询)两个分析器,典型字段 mapping 为 `"analyzer": "ik_max_word", "search_analyzer": "ik_smart"`。Wagtail 侧可通过 `WAGTAILSEARCH_BACKENDS['default']['INDEX_SETTINGS']` 覆盖默认 `analysis.analyzer.default` 让 Wagtail 自动生成的索引改用 IK 分词,无需改 Wagtail 源码。 + - ⚠️ **插件版本必须与 OpenSearch 核心版本完全一致**才能加载成功,接入前需在 https://release.infinilabs.com/ 或用 `bin/opensearch-plugin install https://get.infini.cloud/opensearch/analysis-ik/` 确认存在对应构建。 +- **本地 PoC 脚手架(已创建,未在本环境验证)**:新增 [docker-compose.opensearch.yml](../docker-compose.opensearch.yml) + [docker/opensearch/Dockerfile](../docker/opensearch/Dockerfile),基于官方 `opensearchproject/opensearch:2.19.6` 镜像叠加 IK 插件安装,`plugins.security.disabled=true` 仅用于本地免证书调试。**当前开发环境未安装 Docker CLI,本次调研未能实际拉起容器验证**,需在具备 Docker 的机器上执行 `docker-compose -f docker-compose.opensearch.yml up -d`,并完成以下验证步骤后再正式接入: + 1. `curl http://localhost:9200/_cat/plugins` 确认 `analysis-ik` 已加载; + 2. 安装 `opensearch-py`,在 settings 中启用上面的 `opensearch2` 后端配置; + 3. `python manage.py update_index` 重建索引,用中文短语(如“产品案例”)人工验证检索召回是否符合预期的分词粒度。 +- **生产替代方案**:若不想自建/自运维 OpenSearch 集群(插件升级、扩缩容等),可评估阿里云“开放搜索 OpenSearch 版”或腾讯云 ES Serverless 的托管中文分词能力,二者原生支持中文分词、无需自装 IK 插件,代价是绑定云厂商,需按实际预算/团队运维能力二选一(已同步写入 §2.17 风险表)。 + ### 2.11 安全设计(落地到具体配置) ```python @@ -383,16 +404,17 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route,`lint`/`build` 已验证通过 - [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API、蜜罐反垃圾字段、PIPL 数据删除权申请/确认流程)编写了基础单元测试,共 31 个用例均通过 - [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行) +- [x] 部署引导 management command `bootstrap_deployment`:读取 `DJANGO_SUPERUSER_USERNAME`/`_EMAIL`/`_PASSWORD` 三个环境变量,若均已设置且用户名不存在则创建首个超级管理员账号(已存在则跳过,不覆盖密码),随后自动调用 `setup_rbac_groups` 初始化 RBAC 权限组;幂等,可放入部署脚本/容器启动流程的 `migrate` 之后执行,解决“首次部署后如何登录 Wagtail 后台”的鸡生蛋问题;`.env.example` 已补充对应三项环境变量说明;新增 4 个单元测试覆盖建号/跳过/不覆盖密码/联动 RBAC 四种场景 - [x] Snippet:`TeamMember`/`Testimonial`/`Partner`(简单 `@register_snippet`,均含 `order` 排序字段)、`NavigationMenu`+`NavigationMenuItem`(`ClusterableModel`+`Orderable`+`InlinePanel`,内部页面/外部链接二选一)、`SiteSettings`(`wagtail.contrib.settings` + `BaseSiteSetting`,公司信息/ICP备案/社交账号全局配置)均已创建;只读 API 挂载于 `/api/v1/custom/core/`(`team/`、`testimonials/`、`partners/`、`navigation/?name=`、`site-settings/`,限流 public 100/min);前端 `Header`/`Footer` 已接入 `NavigationMenu`/`SiteSettings`(接口不可用时回退静态内容),`TeamMember`/`Testimonial`/`Partner` 已提供 service 层,页面级展示留待对应 StreamField Block(见下)落地时接入 - [x] 预览模式:后端引入 `wagtail-headless-preview`(0.9.0),`SEOablePage` 混入 `HeadlessPreviewMixin`(自动覆盖全部 6 个页面模型),新增自定义 `PagePreviewAPIViewSet` 挂载于 `/api/v2/page_preview/`;前端新增 `services/preview.service.ts`(`getPreviewOrFallback`/`fetchPreviewPage`/`resolvePreviewPath`)、`/preview`与 `/preview/disable` 两个 Route Handler(基于 Next.js Draft Mode + httpOnly cookie),并在首页/博客/产品/案例/解决方案详情页接入草稿优先逻辑,`layout.tsx` 新增预览模式提示条(含退出链接);`lint`/`build` 均验证通过,后端 31 个现有用例均通过(本次未新增自动化测试,仅依靠人工验证) - [x] 剩余 6 个 StreamField Block(`ProductCardBlock`/`PricingBlock`/`TimelineBlock`/`TeamBlock`/`TechStackBlock`/`VideoBlock`)已全部开发完成:`apps/core/blocks.py` 新增对应 Block 类并扩充 `COMMON_BLOCKS`(现共 14 个内容 Block + 1 个 `richtext`),因 Wagtail StreamField 会将 Block 结构写入迁移(`use_json_field=True` 不代表可跳过迁移),已为 blog/cases/home/products/solutions 五个 Page 模型生成并应用 `alter field body` 迁移;`TeamBlock` 复用 `FormBlock` 的 `get_api_representation` 覆写模式,新增 `TeamMemberChooserBlock(SnippetChooserBlock)` 展开 `TeamMember` 完整字段(含 `serialize_image` 复用),并补充对应的空值/展开单元测试(新增 2 个用例,后端共 33 个测试全部通过);前端 `types/wagtail.ts` 新增 6 个 Block 值类型与 `StreamFieldBlock` 联合类型分支,新建 6 个渲染组件(`ProductCardGrid`/`Pricing`/`Timeline`/`Team`/`TechStack`/`Video`)并接入 `BlockRenderer.tsx` 映射表,`lint`/`build` 均验证通过 - [x] RBAC 落地:`python manage.py setup_rbac_groups` 幂等命令创建 5 个 Wagtail Group(站点管理员/编辑/审核员/作者/查看者)并按 §2.8 矩阵配置 `GroupPagePermission`/`GroupCollectionPermission`/Snippet 与用户管理 `Permission`;Author“仅自己”利用 Wagtail 内置 `OwnershipPermissionPolicy`(仅 add 权限)原生实现,无需自定义 hook;新增 7 个后端单元测试,全仓库 pytest 从 33 增至 40,全部通过 未完成(待规划排期): -- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) +- [ ] 测试覆盖率持续提升中:后端 pytest 用例从 40 个增至 65 个,新增覆盖 `apps/blog`(标签、列表排序/草稿过滤)、`apps/solutions`(页面树、草稿过滤)、`apps/home`(`HomePage.subpage_types` 回归测试,覆盖此前"后台无法添加子页面"的 bug 场景)、`apps/core` 健康检查端点 `/healthz`/`/readyz`、发布后 revalidate webhook 信号(含正常/未配置/网络异常三种路径)、`apps/api` 预览接口 `page_preview`(含无效 token/content_type 场景,过程中顺带修复了一个真实 bug:无效 token 之前会 500 而非文档承诺的 404)、`bootstrap_deployment` 部署引导命令(建号/跳过/不覆盖密码/联动 RBAC);但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线 - [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入 - [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持、字体/地图/验证码/CDN 国内可访问排查均已完成,§2.15 合规与本地化清单已全部完成 -- [ ] 中文分词、OpenSearch 集成(Phase 2 提前项)未开始 +- [ ] 中文分词、OpenSearch 集成(Phase 2 提前项):**调研已完成**(结论见 §2.10 “OpenSearch + 中文分词调研”小节:确认 Wagtail 7.4.2 原生内置 `opensearch2`/`opensearch3` 后端 + infinilabs `analysis-ik` 插件的可行方案,并新增本地 Docker Compose PoC 脚手架 `docker-compose.opensearch.yml`),**实际接入(拉起容器验证插件加载、切换 `WAGTAILSEARCH_BACKENDS`、建站内容重建索引)仍未开始**,待具备 Docker 环境后再验证落地 ### 2.17 风险与备选方案