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
+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": "已成功删除与该邮箱关联的个人信息。",
}
)