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:
+122
-2
@@ -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": "已成功删除与该邮箱关联的个人信息。",
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user