账户删除
概述
MysticX 支持两种删除模式:
- 软删除 - 匿名化用户的个人身份信息(PII),取消订阅并封禁账户。保留审计数据。这是用户自助服务和管理员操作的默认方式。
- 硬删除(GDPR) - 通过级联删除永久移除用户及所有关联数据。仅用于 GDPR 被遗忘权请求。
两种模式都会将用户的原始邮箱记录到墓碑表(deleted_email)中,以防止滥用重新注册奖励。
架构
墓碑表
DeletedEmail 模型存储已删除账户的邮箱地址:
| 字段 | 类型 | 描述 |
|---|---|---|
| id | String | CUID 主键 |
| String | 原始邮箱(小写) | |
| originalUserId | String | 被删除用户的 ID(纯字符串,无外键) |
| stripeCustomerId | String? | 用于审计的 Stripe 客户 ID |
| deletedAt | DateTime | 删除发生的时间 |
email 列已建立索引以便在注册时快速查找。originalUserId 故意不设置外键关联到 User — 墓碑记录必须在硬删除后仍然存在。
重新注册奖励防滥用
当用户注册时,lib/auth.tsx 中的 databaseHooks.user.create.after 钩子会调用 wasEmailPreviouslyDeleted(email)。如果在墓碑表中找到该邮箱:
- 跳过:
initializeCredits()(300 积分欢迎奖励) - 跳过:
claimInvitationForNewUser()(推荐码被邀请人奖励) - 仍执行:
ensureInvitationStateForUser()(生成邀请码) - 仍执行:
createNewUserFeatureNotifications()(功能通知)
该检查采用失败开放策略:如果墓碑查询抛出异常,isReturningUser 默认为 false,这样新用户不会因为临时数据库错误而被错误地惩罚。
软删除流程
实现位于 lib/account-deletion.ts → softDeleteUser(userId)。
步骤
- 获取用户及其活跃/试用中的订阅
- 取消每个 Stripe 订阅(每个订阅单独 try/catch — 失败不会阻止删除)
- 在单个事务中:
- 使用原始邮箱创建墓碑记录
- 匿名化用户行:
- Email →
deleted_{userId}@deleted.mysticx.com - Name →
Deleted User - Image、timezone、invite code、Stripe customer ID、preferences → null
- Credits → 0,tier → FREE
- Banned → true,ban reason → "Account deleted by user"
deletedAt→ 当前时间戳
- Email →
- 硬删除 11 张包含个人数据或内容的表:
session、account(身份验证凭据)userMemory、userProfile、telegramLink(PII)notification(个人通知)cardOfDayEntry、weeklyGuidance、soulJourney(生成的内容)userCardSkin、userTarotReader(购买记录)
- 在匿名化的用户行上保留(审计/推荐价值):
creditTransaction- 滥用调查referral、referralMilestoneReward- 邀请人计数保持准确tarotReading、chat、message- 除 userId 外键外无 PIIsubscription- Stripe 审计跟踪
推荐系统影响
- 邀请人删除: 匿名化的用户行仍然存在,因此所有 Referral/ReferralMilestoneReward 外键仍然有效。邀请码被清除(不再有新推荐)。
- 被邀请人删除并重新注册:
claimInvitationForNewUser被跳过,防止双重推荐奖励。
硬删除流程(GDPR)
实现位于 app/actions/admin.ts → hardDeleteUser(userId)。仅限管理员。
步骤
- 获取用户及其活跃/试用中的订阅
- 取消每个 Stripe 订阅(每个订阅单独 try/catch)
- 如果用户尚未被软删除:
- 检查邮箱是否已匿名化(
@deleted.mysticx.com) - 如果未匿名化,创建墓碑记录
- 检查邮箱是否已匿名化(
- 执行
prisma.user.delete()级联删除所有 22 个外键关联
级联树
prisma.user.delete(userId)
├── Session ──────────── CASCADE
├── Account ──────────── CASCADE
├── UserProfile ──────── CASCADE
├── TelegramLink ─────── CASCADE
├── CreditTransaction ── CASCADE
├── CardOfDayEntry ────── CASCADE
├── Notification ──────── CASCADE
├── UserCardSkin ──────── CASCADE
├── UserTarotReader ───── CASCADE
├── UserMemory ────────── CASCADE
├── WeeklyGuidance ────── CASCADE
├── SoulJourney ────────── CASCADE
├── Referral (inviter) ── CASCADE
├── Referral (invitee) ── CASCADE
├── ReferralMilestoneReward CASCADE
├── Subscription ──────── CASCADE
├── TarotReading ──────── CASCADE
│ ├── Chat ──────────── CASCADE
│ │ └── Message ───── CASCADE
│ └── AiApiCall ─────── CASCADE
├── UserFeedback ──────── SetNull (保留记录)
├── BlogPost.createdBy ── SetNull
├── BlogPost.updatedBy ── SetNull
└── User.invitedByUserId ─ SetNull (其他用户)User 上不存在 onDelete: Restrict 约束,因此删除不会被外键违反所阻止。
入口点
用户自助服务
- 界面:
/account/security页面上的DeleteAccountCard组件 - 操作:
app/actions/account.ts→deleteMyAccount() - 流程: 要求输入 "DELETE" 确认 → 调用
softDeleteUser→ 退出登录 → 重定向到首页
管理员面板
用户操作下拉菜单中的两个选项(UserActionsDropdown):
| 操作 | 按钮 | 行为 |
|---|---|---|
| 删除用户 | 软删除 | 匿名化 + 保留审计跟踪 |
| 硬删除(GDPR) | 永久删除 | 通过级联删除移除所有数据 |
独立的 DeleteUserModal 组件也执行软删除。
文件
| 文件 | 用途 |
|---|---|
lib/account-deletion.ts | 核心逻辑:softDeleteUser、wasEmailPreviouslyDeleted |
lib/auth.tsx | 注册钩子,包含返回用户检查 |
app/actions/account.ts | 用户自助删除 server action |
app/actions/admin.ts | 管理员 deleteUser(软删除)+ hardDeleteUser(GDPR) |
components/DeleteAccountCard.tsx | 面向用户的删除卡片(国际化,12 种语言) |
app/admin/_components/UserActionsDropdown.tsx | 管理员下拉菜单,包含两种删除选项 |
app/admin/_components/DeleteUserModal.tsx | 独立的管理员软删除按钮 |
__tests__/account-deletion/account-deletion.test.ts | 45 个纯逻辑测试 |
数据库迁移
迁移:20260403081911_add_account_deletion_support
- 在
user表中添加deletedAt DateTime?字段并建立索引 - 创建
deleted_email表并在email列建立索引