179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""
|
||
线索提交接口。挂载在 /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, 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
|
||
)
|
||
|
||
# 蜜罐反垃圾:honeypot 字段非空说明是自动化脚本提交,直接返回“成功”假象,
|
||
# 不落库、不发通知邮件,避免打草惊蛇(详见设计文档 §2.15 验证码/反垃圾方案)。
|
||
if serializer.validated_data.get("website"):
|
||
return Response(
|
||
{"success": True, "message": form_def.success_message}, status=201
|
||
)
|
||
|
||
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": "已成功删除与该邮箱关联的个人信息。",
|
||
}
|
||
)
|