feat: 实现PIPL数据主体删除权支持功能

- 新增 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 勾选项
This commit is contained in:
2026-08-07 13:29:20 +08:00
parent 8fbbd2e6b0
commit d6503bb46f
10 changed files with 405 additions and 7 deletions
+3
View File
@@ -24,3 +24,6 @@ OSS_ENDPOINT_URL=
# 发布后前端 ISR 缓存失效 Webhook # 发布后前端 ISR 缓存失效 Webhook
FRONTEND_REVALIDATE_URL=https://www.example.com/api/revalidate FRONTEND_REVALIDATE_URL=https://www.example.com/api/revalidate
REVALIDATE_SECRET=change-me REVALIDATE_SECRET=change-me
# 前端站点基础 URL(用于邮件中的链接,如 PIPL 数据删除确认链接)
FRONTEND_BASE_URL=https://www.example.com
+28
View File
@@ -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
@@ -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'],
},
),
]
+53
View File
@@ -2,8 +2,14 @@
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock 线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
嵌入任意 StreamField 页面;提交结果保存为 Lead。 嵌入任意 StreamField 页面;提交结果保存为 Lead。
详见 documents/设计方案分析与完善版.md §2.6FormBlock)与 B2B 官网核心转化路径。 详见 documents/设计方案分析与完善版.md §2.6FormBlock)与 B2B 官网核心转化路径。
LeadDeletionRequest 支持 PIPL 数据主体删除权(详见 §2.15)。
""" """
import uuid
from datetime import timedelta
from django.db import models from django.db import models
from django.utils import timezone
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel from wagtail.admin.panels import FieldPanel, InlinePanel
@@ -111,3 +117,50 @@ class Lead(models.Model):
def __str__(self): def __str__(self):
form_name = self.form.name if self.form_id else "(表单已删除)" form_name = self.form.name if self.form_id else "(表单已删除)"
return f"{form_name} - {self.created_at:%Y-%m-%d %H:%M}" 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)
+12
View File
@@ -5,3 +5,15 @@ class LeadSubmitSerializer(serializers.Serializer):
form_id = serializers.IntegerField() form_id = serializers.IntegerField()
data = serializers.DictField() data = serializers.DictField()
source_url = serializers.URLField(required=False, allow_blank=True, default="") 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()
+138 -1
View File
@@ -1,12 +1,28 @@
"""apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。""" """apps.forms 单元测试:线索提交 API(B2B 官网核心转化路径,测试覆盖率要求较高)。"""
from datetime import timedelta
import pytest import pytest
from django.core.cache import cache
from django.utils import timezone
from rest_framework.test import APIClient 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 pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def _clear_throttle_cache():
"""ScopedRateThrottle 依赖默认缓存记录请求次数,避免测试间相互影响(forms scope 限流 5/min)。"""
cache.clear()
yield
@pytest.fixture @pytest.fixture
def contact_form(): def contact_form():
form_def = FormDefinition.objects.create( form_def = FormDefinition.objects.create(
@@ -62,3 +78,124 @@ def test_lead_submit_requires_form_id():
) )
assert response.status_code == 400 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"
+11 -1
View File
@@ -1,7 +1,17 @@
from django.urls import path from django.urls import path
from .views import LeadSubmitView from .views import LeadDeletionConfirmView, LeadDeletionRequestView, LeadSubmitView
urlpatterns = [ urlpatterns = [
path("leads/", LeadSubmitView.as_view(), name="lead-submit"), 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",
),
] ]
+122 -2
View File
@@ -2,17 +2,27 @@
线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与 线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。 documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope5/min,防刷)。 公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope5/min,防刷)。
LeadDeletionRequestView/LeadDeletionConfirmView 支持 PIPL 数据主体删除权(§2.15)。
""" """
import json import json
import logging
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.core.mail import send_mail
from django.utils import timezone
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .models import FormDefinition, Lead from .models import FormDefinition, Lead, LeadDeletionRequest
from .serializers import LeadSubmitSerializer from .serializers import (
LeadDeletionConfirmSerializer,
LeadDeletionRequestSerializer,
LeadSubmitSerializer,
)
logger = logging.getLogger(__name__)
class LeadSubmitView(APIView): class LeadSubmitView(APIView):
@@ -49,3 +59,113 @@ class LeadSubmitView(APIView):
return Response( return Response(
{"success": True, "message": form_def.success_message}, status=201 {"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": "已成功删除与该邮箱关联的个人信息。",
}
)
+3 -3
View File
@@ -349,7 +349,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
- [x] 隐私政策内容已上线(`apps.core.SimpleContentPage` 通用富文本内容页模型 + `seed_privacy_policy` 管理命令 + 前端 `/privacy-policy` 页面 + 页脚链接,内容覆盖 PIPL 要求的收集目的/用途/用户权利/Cookie 说明等) - [x] 隐私政策内容已上线(`apps.core.SimpleContentPage` 通用富文本内容页模型 + `seed_privacy_policy` 管理命令 + 前端 `/privacy-policy` 页面 + 页脚链接,内容覆盖 PIPL 要求的收集目的/用途/用户权利/Cookie 说明等)
- [x] Cookie 同意条控件(`frontend/components/layout/CookieConsent.tsx``"use client"` 组件,首次访问展示"接受/拒绝"横幅,选择结果存入 `localStorage`,已挂载到根 layout - [x] Cookie 同意条控件(`frontend/components/layout/CookieConsent.tsx``"use client"` 组件,首次访问展示"接受/拒绝"横幅,选择结果存入 `localStorage`,已挂载到根 layout
- [ ] 字体/地图/验证码/CDN 均替换为可在国内正常访问的服务 - [ ] 字体/地图/验证码/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 开发路线图(对齐当前仓库状态) ### 2.16 开发路线图(对齐当前仓库状态)
@@ -370,7 +370,7 @@ CI 中要求单元测试覆盖率不低于 70%,核心 `apps/forms`(涉及线
- [x] `apps/forms``FormDefinition`/`FormDefinitionField`/`Lead`Snippet 方式)+ 提交 API`/api/v1/custom/leads/`,限流 + 邮件通知) - [x] `apps/forms``FormDefinition`/`FormDefinitionField`/`Lead`Snippet 方式)+ 提交 API`/api/v1/custom/leads/`,限流 + 邮件通知)
- [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置) - [x] Wagtail API v2 挂载、分页/过滤/排序(Wagtail 内置)
- [x] Next.js 前端脚手架(独立仓库):Header/Footer/layout、首页、博客/产品/案例/解决方案列表与详情页、`BlockRenderer`、ISR + revalidate route`lint`/`build` 已验证通过 - [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] `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(见下)落地时接入 - [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` 实际配置)未开始 - [ ] RBAC 落地(Wagtail `Group` + `GroupPagePermission` 实际配置)未开始
- [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线) - [ ] 测试覆盖率仍不完整(已有基础单元测试,但集成测试、前端组件测试、E2E 均未编写,当前无 CI 流水线)
- [x] 生产安全 settings(§2.11 中 `SECURE_*`/CORS/CSRF 白名单等)已在 `production.py` 中逐项落实,并补充 `SECURE_PROXY_SSL_HEADER`(适配国内云厂商 SLB/CLB 边缘终止 TLS 场景);实际域名需在部署时通过 `.env` 填入 - [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 提前项)未开始 - [ ] 中文分词、OpenSearch 集成(Phase 2 提前项)未开始
### 2.17 风险与备选方案 ### 2.17 风险与备选方案
+3
View File
@@ -133,3 +133,6 @@ CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[])
# 发布后通知前端做 ISR 缓存失效(详见设计文档 §2.7) # 发布后通知前端做 ISR 缓存失效(详见设计文档 §2.7)
FRONTEND_REVALIDATE_URL = env("FRONTEND_REVALIDATE_URL", default="") FRONTEND_REVALIDATE_URL = env("FRONTEND_REVALIDATE_URL", default="")
REVALIDATE_SECRET = env("REVALIDATE_SECRET", default="") REVALIDATE_SECRET = env("REVALIDATE_SECRET", default="")
# 前端站点基础 URL,用于拼接邮件中的链接,如 PIPL 数据删除确认链接(详见 §2.15)
FRONTEND_BASE_URL = env("FRONTEND_BASE_URL", default="http://localhost:3000")