Files
wagtailcms/apps/forms/views.py
T
Zhengen d6503bb46f 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 勾选项
2026-08-07 13:29:20 +08:00

172 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
线索提交接口。挂载在 /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, LeadDeletionRequest
from .serializers import (
LeadDeletionConfirmSerializer,
LeadDeletionRequestSerializer,
LeadSubmitSerializer,
)
logger = logging.getLogger(__name__)
class LeadSubmitView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = "forms"
def post(self, request, *args, **kwargs):
serializer = LeadSubmitSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
form_id = serializer.validated_data["form_id"]
try:
form_def = FormDefinition.objects.get(pk=form_id)
except FormDefinition.DoesNotExist:
return Response(
{"error": {"code": "NOT_FOUND", "message": "表单不存在"}}, status=404
)
lead = Lead.objects.create(
form=form_def,
data=serializer.validated_data["data"],
source_url=serializer.validated_data.get("source_url", ""),
)
if form_def.notification_email:
send_mail(
subject=f"【新线索】{form_def.name}",
message=json.dumps(lead.data, ensure_ascii=False, indent=2),
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
recipient_list=[form_def.notification_email],
fail_silently=True,
)
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": "已成功删除与该邮箱关联的个人信息。",
}
)