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:
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -2,8 +2,14 @@
|
||||
线索表单:FormDefinition 是可复用的表单定义 Snippet,通过 core.blocks.FormBlock
|
||||
嵌入任意 StreamField 页面;提交结果保存为 Lead。
|
||||
详见 documents/设计方案分析与完善版.md §2.6(FormBlock)与 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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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