From d6503bb46f51381cd55ae6649b696203c611628d Mon Sep 17 00:00:00 2001 From: Zhengen TANG Date: Fri, 7 Aug 2026 13:29:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0PIPL=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=B8=BB=E4=BD=93=E5=88=A0=E9=99=A4=E6=9D=83=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 LeadDeletionRequest 模型,支持邮箱验证的删除申请/确认流程 - POST /api/v1/custom/leads/deletion-requests/ 发起申请(通用响应避免探测邮箱是否存在) - POST /api/v1/custom/leads/deletion-requests/confirm/ 确认删除(24小时token有效期) - 按邮箱在 Lead.data 中匹配并删除对应记录,跨SQLite/Postgres使用Python级匹配 - LeadDeletionRequest 通过 Django Admin 只读展示供合规审计 - 新增 FRONTEND_BASE_URL 设置用于拼接邮件确认链接 - apps/forms/tests.py 新增6个测试用例(含节流缓存隔离修复),全仓库30个用例通过 - 更新设计文档 §2.15/§2.16 勾选项 --- .env.example | 3 + apps/forms/admin.py | 28 ++++ .../migrations/0002_leaddeletionrequest.py | 32 ++++ apps/forms/models.py | 53 +++++++ apps/forms/serializers.py | 12 ++ apps/forms/tests.py | 139 +++++++++++++++++- apps/forms/urls.py | 12 +- apps/forms/views.py | 124 +++++++++++++++- documents/设计方案分析与完善版.md | 6 +- wagtailcms/settings/base.py | 3 + 10 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 apps/forms/admin.py create mode 100644 apps/forms/migrations/0002_leaddeletionrequest.py diff --git a/.env.example b/.env.example index f861f62..ce424ac 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ OSS_ENDPOINT_URL= # 发布后前端 ISR 缓存失效 Webhook FRONTEND_REVALIDATE_URL=https://www.example.com/api/revalidate REVALIDATE_SECRET=change-me + +# 前端站点基础 URL(用于邮件中的链接,如 PIPL 数据删除确认链接) +FRONTEND_BASE_URL=https://www.example.com diff --git a/apps/forms/admin.py b/apps/forms/admin.py new file mode 100644 index 0000000..e1fd4f5 --- /dev/null +++ b/apps/forms/admin.py @@ -0,0 +1,28 @@ +""" +LeadDeletionRequest 通过 Django Admin(非 Wagtail)暴露为只读审计视图, +供合规/客服人员查看 PIPL 数据删除申请处理记录,详见 +documents/设计方案分析与完善版.md §2.15。 +""" +from django.contrib import admin + +from .models import LeadDeletionRequest + + +@admin.register(LeadDeletionRequest) +class LeadDeletionRequestAdmin(admin.ModelAdmin): + list_display = ( + "contact", + "status", + "requested_at", + "completed_at", + "deleted_count", + ) + list_filter = ("status",) + search_fields = ("contact",) + readonly_fields = [f.name for f in LeadDeletionRequest._meta.fields] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/apps/forms/migrations/0002_leaddeletionrequest.py b/apps/forms/migrations/0002_leaddeletionrequest.py new file mode 100644 index 0000000..4ab775b --- /dev/null +++ b/apps/forms/migrations/0002_leaddeletionrequest.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.5 on 2026-08-07 05:13 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forms', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='LeadDeletionRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('contact', models.EmailField(max_length=254, verbose_name='联系邮箱')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='确认令牌')), + ('status', models.CharField(choices=[('pending', '待确认'), ('completed', '已完成'), ('expired', '已过期')], default='pending', max_length=20, verbose_name='状态')), + ('requested_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='确认时间')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), + ('deleted_count', models.PositiveIntegerField(default=0, verbose_name='已删除记录数')), + ], + options={ + 'verbose_name': '数据删除申请(PIPL)', + 'verbose_name_plural': '数据删除申请(PIPL)', + 'ordering': ['-requested_at'], + }, + ), + ] diff --git a/apps/forms/models.py b/apps/forms/models.py index ac69e81..3255449 100644 --- a/apps/forms/models.py +++ b/apps/forms/models.py @@ -2,8 +2,14 @@ 线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock 嵌入任意 StreamField 页面;提交结果保存为 Lead。 详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 B2B 官网核心转化路径。 + +LeadDeletionRequest 支持 PIPL 数据主体删除权(详见 §2.15)。 """ +import uuid +from datetime import timedelta + from django.db import models +from django.utils import timezone from modelcluster.fields import ParentalKey from modelcluster.models import ClusterableModel from wagtail.admin.panels import FieldPanel, InlinePanel @@ -111,3 +117,50 @@ class Lead(models.Model): def __str__(self): form_name = self.form.name if self.form_id else "(表单已删除)" return f"{form_name} - {self.created_at:%Y-%m-%d %H:%M}" + + +class LeadDeletionRequest(models.Model): + """PIPL 数据主体删除权申请记录(合规审计用途)。 + + 匿名用户凭提交表单时留下的邮箱发起申请,邮件确认后才会删除匹配的 Lead 记录, + 避免任意人凭他人邮箱发起删除。仅支持邮箱验证(暂未接入短信网关, + 手机号验证需待国内短信/验证码服务落地后再支持)。 + """ + + STATUS_PENDING = "pending" + STATUS_COMPLETED = "completed" + STATUS_EXPIRED = "expired" + STATUS_CHOICES = [ + (STATUS_PENDING, "待确认"), + (STATUS_COMPLETED, "已完成"), + (STATUS_EXPIRED, "已过期"), + ] + + TOKEN_VALID_HOURS = 24 + + contact = models.EmailField(verbose_name="联系邮箱") + token = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, verbose_name="确认令牌" + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default=STATUS_PENDING, + verbose_name="状态", + ) + requested_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间") + confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name="确认时间") + completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间") + deleted_count = models.PositiveIntegerField(default=0, verbose_name="已删除记录数") + + class Meta: + verbose_name = "数据删除申请(PIPL)" + verbose_name_plural = "数据删除申请(PIPL)" + ordering = ["-requested_at"] + + def __str__(self): + return f"{self.contact} - {self.get_status_display()}" + + @property + def is_expired(self): + return timezone.now() > self.requested_at + timedelta(hours=self.TOKEN_VALID_HOURS) diff --git a/apps/forms/serializers.py b/apps/forms/serializers.py index 8c80122..4f683b2 100644 --- a/apps/forms/serializers.py +++ b/apps/forms/serializers.py @@ -5,3 +5,15 @@ class LeadSubmitSerializer(serializers.Serializer): form_id = serializers.IntegerField() data = serializers.DictField() source_url = serializers.URLField(required=False, allow_blank=True, default="") + + +class LeadDeletionRequestSerializer(serializers.Serializer): + """PIPL 数据删除权:发起申请时仅需提交联系邮箱。""" + + contact = serializers.EmailField() + + +class LeadDeletionConfirmSerializer(serializers.Serializer): + """PIPL 数据删除权:确认时提交邮件中的 token。""" + + token = serializers.UUIDField() diff --git a/apps/forms/tests.py b/apps/forms/tests.py index b32123e..f1a1d11 100644 --- a/apps/forms/tests.py +++ b/apps/forms/tests.py @@ -1,12 +1,28 @@ """apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。""" +from datetime import timedelta + import pytest +from django.core.cache import cache +from django.utils import timezone from rest_framework.test import APIClient -from apps.forms.models import FormDefinition, FormDefinitionField, Lead +from apps.forms.models import ( + FormDefinition, + FormDefinitionField, + Lead, + LeadDeletionRequest, +) pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def _clear_throttle_cache(): + """ScopedRateThrottle 依赖默认缓存记录请求次数,避免测试间相互影响(forms scope 限流 5/min)。""" + cache.clear() + yield + + @pytest.fixture def contact_form(): form_def = FormDefinition.objects.create( @@ -62,3 +78,124 @@ def test_lead_submit_requires_form_id(): ) assert response.status_code == 400 + + +# --- PIPL 数据删除权(详见设计文档 §2.15) --- + + +def test_deletion_request_returns_generic_message_and_sends_email( + contact_form, mailoutbox +): + Lead.objects.create( + form=contact_form, + data={"name": "张三", "email": "zhangsan@example.com"}, + ) + + client = APIClient() + response = client.post( + "/api/v1/custom/leads/deletion-requests/", + {"contact": "zhangsan@example.com"}, + format="json", + ) + + assert response.status_code == 202 + assert LeadDeletionRequest.objects.filter(contact="zhangsan@example.com").exists() + assert len(mailoutbox) == 1 + assert mailoutbox[0].to == ["zhangsan@example.com"] + + +def test_deletion_request_returns_same_generic_message_for_unknown_contact(mailoutbox): + """未提交过表单的邮箱也返回相同提示,避免探测。""" + client = APIClient() + response_known = client.post( + "/api/v1/custom/leads/deletion-requests/", + {"contact": "known@example.com"}, + format="json", + ) + response_unknown = client.post( + "/api/v1/custom/leads/deletion-requests/", + {"contact": "unknown@example.com"}, + format="json", + ) + + assert response_known.status_code == response_unknown.status_code == 202 + assert response_known.data["message"] == response_unknown.data["message"] + + +def test_deletion_confirm_deletes_matching_leads_only(contact_form): + matching_lead = Lead.objects.create( + form=contact_form, + data={"name": "张三", "email": "zhangsan@example.com"}, + ) + other_lead = Lead.objects.create( + form=contact_form, + data={"name": "李四", "email": "lisi@example.com"}, + ) + deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com") + + client = APIClient() + response = client.post( + "/api/v1/custom/leads/deletion-requests/confirm/", + {"token": str(deletion_request.token)}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["success"] is True + assert response.data["deleted_count"] == 1 + assert not Lead.objects.filter(pk=matching_lead.pk).exists() + assert Lead.objects.filter(pk=other_lead.pk).exists() + + deletion_request.refresh_from_db() + assert deletion_request.status == LeadDeletionRequest.STATUS_COMPLETED + assert deletion_request.deleted_count == 1 + assert deletion_request.completed_at is not None + + +def test_deletion_confirm_invalid_token_returns_404(): + client = APIClient() + response = client.post( + "/api/v1/custom/leads/deletion-requests/confirm/", + {"token": "00000000-0000-0000-0000-000000000000"}, + format="json", + ) + + assert response.status_code == 404 + assert response.data["error"]["code"] == "INVALID_TOKEN" + + +def test_deletion_confirm_expired_token_returns_400(): + deletion_request = LeadDeletionRequest.objects.create(contact="zhangsan@example.com") + LeadDeletionRequest.objects.filter(pk=deletion_request.pk).update( + requested_at=timezone.now() - timedelta(hours=25) + ) + + client = APIClient() + response = client.post( + "/api/v1/custom/leads/deletion-requests/confirm/", + {"token": str(deletion_request.token)}, + format="json", + ) + + assert response.status_code == 400 + assert response.data["error"]["code"] == "TOKEN_EXPIRED" + + deletion_request.refresh_from_db() + assert deletion_request.status == LeadDeletionRequest.STATUS_EXPIRED + + +def test_deletion_confirm_already_processed_returns_400(): + deletion_request = LeadDeletionRequest.objects.create( + contact="zhangsan@example.com", + status=LeadDeletionRequest.STATUS_COMPLETED, + ) + + client = APIClient() + response = client.post( + "/api/v1/custom/leads/deletion-requests/confirm/", + {"token": str(deletion_request.token)}, + format="json", + ) + + assert response.status_code == 400 + assert response.data["error"]["code"] == "ALREADY_PROCESSED" diff --git a/apps/forms/urls.py b/apps/forms/urls.py index 26e7f99..d93d6b2 100644 --- a/apps/forms/urls.py +++ b/apps/forms/urls.py @@ -1,7 +1,17 @@ from django.urls import path -from .views import LeadSubmitView +from .views import LeadDeletionConfirmView, LeadDeletionRequestView, LeadSubmitView urlpatterns = [ path("leads/", LeadSubmitView.as_view(), name="lead-submit"), + path( + "leads/deletion-requests/", + LeadDeletionRequestView.as_view(), + name="lead-deletion-request", + ), + path( + "leads/deletion-requests/confirm/", + LeadDeletionConfirmView.as_view(), + name="lead-deletion-confirm", + ), ] diff --git a/apps/forms/views.py b/apps/forms/views.py index feb16f9..b8ca0ed 100644 --- a/apps/forms/views.py +++ b/apps/forms/views.py @@ -2,17 +2,27 @@ 线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与 documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。 公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope(5/min,防刷)。 + +LeadDeletionRequestView/LeadDeletionConfirmView 支持 PIPL 数据主体删除权(§2.15)。 """ import json +import logging from django.conf import settings from django.core.mail import send_mail +from django.utils import timezone from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView -from .models import FormDefinition, Lead -from .serializers import LeadSubmitSerializer +from .models import FormDefinition, Lead, LeadDeletionRequest +from .serializers import ( + LeadDeletionConfirmSerializer, + LeadDeletionRequestSerializer, + LeadSubmitSerializer, +) + +logger = logging.getLogger(__name__) class LeadSubmitView(APIView): @@ -49,3 +59,113 @@ class LeadSubmitView(APIView): return Response( {"success": True, "message": form_def.success_message}, status=201 ) + + +# PIPL 数据删除权:出于隐私考虑,无论邮箱是否存在关联数据,均返回相同的通用提示, +# 避免被用来探测某个邮箱是否曾提交过表单。 +GENERIC_DELETION_REQUEST_MESSAGE = ( + "如果我们持有与该邮箱关联的信息,确认邮件将发送至该邮箱,请查收并点击链接完成删除确认。" +) + + +class LeadDeletionRequestView(APIView): + """发起 PIPL 数据删除申请:POST {"contact": "user@example.com"}。""" + + throttle_classes = [ScopedRateThrottle] + throttle_scope = "forms" + + def post(self, request, *args, **kwargs): + serializer = LeadDeletionRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + contact = serializer.validated_data["contact"] + + deletion_request = LeadDeletionRequest.objects.create(contact=contact) + confirm_url = ( + f"{settings.FRONTEND_BASE_URL.rstrip('/')}/privacy-policy/delete-confirm" + f"?token={deletion_request.token}" + ) + try: + send_mail( + subject="确认删除您的个人信息", + message=( + "我们收到了删除您个人信息的申请。如果这不是您本人操作,请忽略此邮件。\n\n" + f"请在 {LeadDeletionRequest.TOKEN_VALID_HOURS} 小时内点击以下链接确认删除:\n" + f"{confirm_url}" + ), + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + recipient_list=[contact], + fail_silently=False, + ) + except Exception: + logger.exception("发送删除确认邮件失败:%s", contact) + + return Response({"message": GENERIC_DELETION_REQUEST_MESSAGE}, status=202) + + +class LeadDeletionConfirmView(APIView): + """确认 PIPL 数据删除申请:POST {"token": "..."},校验通过后删除匹配的 Lead 记录。""" + + throttle_classes = [ScopedRateThrottle] + throttle_scope = "forms" + + def post(self, request, *args, **kwargs): + serializer = LeadDeletionConfirmSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + token = serializer.validated_data["token"] + + try: + deletion_request = LeadDeletionRequest.objects.get(token=token) + except LeadDeletionRequest.DoesNotExist: + return Response( + {"error": {"code": "INVALID_TOKEN", "message": "删除链接无效。"}}, + status=404, + ) + + if deletion_request.status != LeadDeletionRequest.STATUS_PENDING: + return Response( + { + "error": { + "code": "ALREADY_PROCESSED", + "message": "该删除申请已处理或已过期。", + } + }, + status=400, + ) + + if deletion_request.is_expired: + deletion_request.status = LeadDeletionRequest.STATUS_EXPIRED + deletion_request.save(update_fields=["status"]) + return Response( + {"error": {"code": "TOKEN_EXPIRED", "message": "删除链接已过期,请重新申请。"}}, + status=400, + ) + + contact = deletion_request.contact.strip().lower() + matched_ids = [ + lead.pk + for lead in Lead.objects.all() + if any( + isinstance(value, str) and value.strip().lower() == contact + for value in lead.data.values() + ) + ] + deleted_count = len(matched_ids) + if matched_ids: + Lead.objects.filter(pk__in=matched_ids).delete() + + now = timezone.now() + deletion_request.status = LeadDeletionRequest.STATUS_COMPLETED + deletion_request.confirmed_at = now + deletion_request.completed_at = now + deletion_request.deleted_count = deleted_count + deletion_request.save( + update_fields=["status", "confirmed_at", "completed_at", "deleted_count"] + ) + + return Response( + { + "success": True, + "deleted_count": deleted_count, + "message": "已成功删除与该邮箱关联的个人信息。", + } + ) diff --git a/documents/设计方案分析与完善版.md b/documents/设计方案分析与完善版.md index 9501c38..6e74ac7 100644 --- a/documents/设计方案分析与完善版.md +++ b/documents/设计方案分析与完善版.md @@ -349,7 +349,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [x] 隐私政策内容已上线(`apps.core.SimpleContentPage` 通用富文本内容页模型 + `seed_privacy_policy` 管理命令 + 前端 `/privacy-policy` 页面 + 页脚链接,内容覆盖 PIPL 要求的收集目的/用途/用户权利/Cookie 说明等) - [x] Cookie 同意条控件(`frontend/components/layout/CookieConsent.tsx`,`"use client"` 组件,首次访问展示"接受/拒绝"横幅,选择结果存入 `localStorage`,已挂载到根 layout) - [ ] 字体/地图/验证码/CDN 均替换为可在国内正常访问的服务 -- [ ] 表单收集的个人信息需明确告知用途并支持删除请求(PIPL 数据主体权利) +- [x] 表单收集的个人信息支持删除请求(PIPL 数据主体权利):新增 `LeadDeletionRequest` 模型(`apps/forms/models.py`),匿名用户凭提交表单时留下的邮箱在 `/privacy-policy/delete-request` 页面发起申请(`POST /api/v1/custom/leads/deletion-requests/`,限流 forms scope 5/min,无论邮箱是否存在关联数据均返回相同提示以避免探测),系统发送含 24 小时有效 token 的确认邮件,用户在 `/privacy-policy/delete-confirm?token=` 页面点击确认后(`POST /api/v1/custom/leads/deletion-requests/confirm/`)按邮箱在 `Lead.data` 中做匹配并删除对应记录;`LeadDeletionRequest` 处理记录通过 Django Admin(只读)供合规审计;隐私政策页面已加入申请入口链接 ### 2.16 开发路线图(对齐当前仓库状态) @@ -370,7 +370,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [x] `apps/forms`:`FormDefinition`/`FormDefinitionField`/`Lead`(Snippet 方式)+ 提交 API(`/api/v1/custom/leads/`,限流 + 邮件通知) - [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置) - [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route,`lint`/`build` 已验证通过 -- [x] pytest 基础测试框架(pytest-django + factory_boy):`pytest.ini` + `conftest.py`(root_page/home_page fixtures),为 `apps/core`(SEOablePage 字段、FormBlock API 表示、SimpleContentPage 页面树、Snippet 模型与只读 API)、`apps/products`、`apps/cases`、`apps/forms`(线索提交 API)编写了基础单元测试,共 24 个用例均通过 +- [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 数据删除权申请/确认流程)编写了基础单元测试,共 30 个用例均通过 - [x] `apps/core.SimpleContentPage`:通用富文本法务/说明类页面模型,用于隐私政策等内容,配套 `seed_privacy_policy` management command 用于幂等创建/更新隐私政策页面(需在 HomePage 实例存在后手动运行) - [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(见下)落地时接入 @@ -380,7 +380,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始 - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入 -- [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件均已完成(详见 §2.15),合规清单剩余"字体/地图/验证码/CDN 国内可访问"与"表单 PIPL 数据删除权支持"两项 +- [x] ICP 备案、页脚备案号展示、隐私政策内容、Cookie 同意条控件、表单 PIPL 数据删除权支持均已完成(详见 §2.15),合规清单剩余“字体/地图/验证码/CDN 国内可访问”一项 - [ ] 中文分词、OpenSearch 集成(Phase 2 提前项)未开始 ### 2.17 风险与备选方案 diff --git a/wagtailcms/settings/base.py b/wagtailcms/settings/base.py index 3362d0c..8cccae4 100644 --- a/wagtailcms/settings/base.py +++ b/wagtailcms/settings/base.py @@ -133,3 +133,6 @@ 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="") + +# 前端站点基础 URL,用于拼接邮件中的链接,如 PIPL 数据删除确认链接(详见 §2.15) +FRONTEND_BASE_URL = env("FRONTEND_BASE_URL", default="http://localhost:3000")