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