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
+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
嵌入任意 StreamField 页面;提交结果保存为 Lead。
详见 documents/设计方案分析与完善版.md §2.6FormBlock)与 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)
+12
View File
@@ -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()
+138 -1
View File
@@ -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"
+11 -1
View File
@@ -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",
),
]
+122 -2
View File
@@ -2,17 +2,27 @@
线索提交接口。挂载在 /api/v1/custom/leads/(详见 apps/forms/urls.py 与
documents/设计方案分析与完善版.md §2.7 自定义业务接口设计)。
公开表单提交接口,限流走 DRF ScopedRateThrottle 的 "forms" scope5/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": "已成功删除与该邮箱关联的个人信息。",
}
)