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:
@@ -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
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -2,8 +2,14 @@
|
|||||||
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
|
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
|
||||||
嵌入任意 StreamField 页面;提交结果保存为 Lead。
|
嵌入任意 StreamField 页面;提交结果保存为 Lead。
|
||||||
详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 B2B 官网核心转化路径。
|
详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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" scope(5/min,防刷)。
|
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope(5/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": "已成功删除与该邮箱关联的个人信息。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 风险与备选方案
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user