注销按钮背后的十四天
「回声」的注销入口藏在设置页最底部。用户点击后弹出一个确认框,文案写着“注销后账号数据将永久删除”。确认之后,前端调用 DELETE /me,服务端把 users.deleted_at 填上当前时间,接口返回 204。从用户视角,事情结束了。
十四天后,同一个手机号重新注册,登录后看到了旧账号的昵称和一部分历史订单。用户把截图发到社区,配文是“说好的永久删除呢”。团队排查后发现,删除逻辑只做了一件事:把用户表标记为已删除。订单表、消息表、搜索索引、推荐特征、日志、备份都没有动。
更麻烦的是恢复流程。一次数据库误操作后,团队从备份恢复了一份三天前的快照,把已经注销的账号也带了回来。清理任务只扫描 deleted_at IS NOT NULL 的用户表,恢复后的订单和消息变成了没有 owner 的孤儿数据,部分还被重新关联到新注册的账号上。
这起事故的根因不是某个删除语句写漏了,而是“注销”被实现成了一个布尔值。真实世界的注销至少包含四个动作:导出、删除、清除备份、留下审计记录。它们有先后顺序,也有各自的失败模式。把四个动作压成一个 deleted_at,就等于把顺序和边界全部丢掉。
注销是状态机,四个动作有先后
「回声」后来把注销拆成三个状态。状态回答“现在处于哪一步”,动作回答“这一步允许做什么”。两者分开,清理任务才不会跳过导出,也不会在备份还没到期时提前清除。
| 状态 | 含义 | 用户可见行为 | 允许的下一步 |
|---|---|---|---|
| pending | 已受理,等待冷静期或人工确认 | 账号冻结,可撤销注销 | deleted |
| deleted | 业务数据已删除或匿名化,备份仍保留 | 无法登录,不能重新注册同标识 | purged |
| purged | 备份窗口结束,副本已清除 | 仅保留最小审计记录 | 无 |
状态之外,四个动作的时序也要写死:
| 动作 | 发生时机 | 输入 | 失败后的处理 |
|---|---|---|---|
| 导出 | pending 阶段,删除之前 | 用户可导出的字段清单 | 导出失败不进入 deleted,允许重试 |
| 删除 | pending → deleted | 用户 ID、删除范围 | 部分失败保持 pending,记录待重试项 |
| 清除备份 | deleted 且备份到期 | BackupUntil、备份批次 | 未到期拒绝执行,到期后重试 |
| 审计留痕 | 每个状态转换 | 请求 ID、操作者、时间、结果 | 审计写入失败阻断状态转换 |
顺序的核心是两条:导出必须在删除之前完成,否则用户拿不到自己的数据;备份清除必须在备份到期之后,否则恢复流程会把已删数据带回来。两条都不是“最佳实践”,而是状态机的不变式。
还有一条容易被忽略:审计记录本身也要最小化。它可以记录“谁在什么时候把哪个请求推进到哪个状态”,但不应该复制手机号、邮箱、证件号。审计日志保留时间通常比业务数据长,如果它包含完整个人信息,注销就永远无法真正完成。
冷静期不是拖延
pending 状态可以承载冷静期,但冷静期的目的是让用户反悔,不是给团队争取清理时间。冷静期内账号应该冻结:不能发内容、不能下单、不能被推荐,但用户可以撤销注销。冷静期结束后,系统自动进入 deleted,而不是等客服手动点按钮。
如果业务需要人工审核(例如涉及未结清订单),pending 可以延长,但必须记录延长原因和新的截止时间。没有截止时间的 pending 会变成永久挂起,用户既不能用账号,也不能重新注册。
deleted 不等于数据消失
deleted 表示业务系统已经不再使用这份数据,但备份、日志、消息队列里可能仍有副本。这个状态的存在,是为了让“业务删除”和“副本清除”解耦:前者可以很快完成,后者要等备份周期结束。
因此 deleted 状态下,用户重新注册同一手机号时,系统应该允许还是拒绝?「回声」选择拒绝,直到 purged。原因是旧数据可能仍在备份中,允许重新注册会让新旧账号的标识冲突,也会让恢复流程难以判断归属。这个选择不唯一,但必须显式写进产品规则。
失败场景:用户表删了,日志和备份还在
回到那次“十四天后复活”。真正的链路比一个删除语句复杂得多:
- 用户表被标记删除,但订单表通过外键仍然保留用户 ID。
- 搜索索引没有删除文档,用户还能搜到旧昵称。
- 推荐特征表按设备 ID 保留行为序列,设备 ID 没有变。
- 应用日志里保留了注册和登录时的手机号,日志保留期是 180 天。
- 备份策略是每日全量、保留 30 天,恢复时不会区分已注销用户。
- 审计表只记录了“删除成功”,没有记录删除范围、操作者和请求 ID。
六个副本里,只有第一个被清理了。用户重新注册后,推荐系统按设备 ID 把旧行为接上,搜索索引把旧昵称带出来,备份恢复则让旧账号整体复活。
后果不只是用户投诉。团队无法回答“到底删了哪些数据”,因为删除动作没有范围清单;也无法证明“备份里的数据不会再被使用”,因为恢复流程没有注销过滤。面对监管问询,这两点都是硬伤。
修复时,团队做了三件事。第一,为每个存储建立数据清单,标明字段、用途、保留期和删除方式。第二,把删除动作改成按清单执行,部分失败不回滚,而是记录待重试项。第三,恢复流程增加过滤:恢复后先重放注销状态,再对外提供服务。
第三件事最容易被忽略。备份恢复通常被当成灾难恢复演练的一部分,演练只验证“数据能不能回来”,不验证“已注销的数据会不会一起回来”。如果恢复后直接切流量,注销承诺就被一次演练推翻了。
用 Advance 和 Exportable 固定时序
状态机的规则如果只写在运维手册里,下一次赶工就会被绕过。把它写成纯函数,输入当前状态和当前时间,输出下一个状态或错误,才能让清理任务、接口和测试共用同一套判断。
下面这段代码来自 webapp-lab。Advance 只允许 pending → deleted → purged,deleted 到 purged 之间必须等备份到期;Exportable 给出用户可导出的字段清单,内部风控和运营备注不进入导出。
// file: internal/profile/deletion.go
// tier: L1
// verify: go test ./internal/profile/
package profile
import (
"fmt"
"sort"
"time"
)
// State 是注销流程的状态。注销不是布尔值,不能一次调用删干净。
type State string
const (
StatePending State = "pending"
StateDeleted State = "deleted"
StatePurged State = "purged"
)
// Deletion 记录一次注销请求。BackupUntil 是备份的最晚保留时间,
// 在此之前不能清除备份副本。
type Deletion struct {
State State
RequestedAt time.Time
BackupUntil time.Time
}
// Advance 把注销推进到下一个状态。pending 只能到 deleted;
// deleted 在备份到期后才能到 purged;purged 和未知状态都报错。
func Advance(d Deletion, now time.Time) (Deletion, error) {
switch d.State {
case StatePending:
if d.RequestedAt.IsZero() {
return d, fmt.Errorf("profile: deletion has no request time")
}
d.State = StateDeleted
return d, nil
case StateDeleted:
if d.RequestedAt.IsZero() {
return d, fmt.Errorf("profile: deletion has no request time")
}
if now.Before(d.BackupUntil) {
return d, fmt.Errorf("profile: backup retained until %s", d.BackupUntil.Format(time.RFC3339))
}
d.State = StatePurged
return d, nil
case StatePurged:
return d, fmt.Errorf("profile: deletion already purged")
default:
return d, fmt.Errorf("profile: unknown deletion state %q", d.State)
}
}
// Exportable 返回可交付给用户的数据字段名,按名称排序。
// 公开和敏感字段属于用户本人;内部风控、运营备注不进入导出。
func Exportable(fields []Field) []string {
names := make([]string, 0, len(fields))
for _, f := range fields {
switch f.Class {
case ClassPublic, ClassSensitive:
names = append(names, f.Name)
}
}
sort.Strings(names)
return names
}
这段代码有几个刻意的限制。
Advance是纯函数,不直接写数据库。调用方拿到新状态后再决定如何持久化,测试可以在不启动数据库的情况下覆盖所有转换。- pending 和 deleted 都检查
RequestedAt。没有请求时间的注销无法计算冷静期和备份窗口,应该尽早失败,而不是用零值时间默默继续。 - deleted 到 purged 用
now.Before(BackupUntil)判断,等于备份到期时刻才允许清除。边界取“到期即允许”,因为备份保留承诺的是“不早于这个时间删除”,不是“必须晚于”。 - purged 和未知状态都返回错误。重复执行清理任务时,第二次会得到明确的“已经清除”,而不是静默成功;未知状态则说明数据被写坏或版本不兼容,应该报警。
Exportable只返回 public 和 sensitive,internal 字段不进入用户导出。导出是给用户的数据副本,不是内部风控报表。
代码没有覆盖“删除范围”本身。它只推进状态,不知道订单、消息、搜索索引和日志分别该怎么删。删除范围应该来自上一节的数据清单,并按存储分别执行、分别重试。
测试:状态转换和备份窗口都要有边界
状态机最容易出错的地方不是正常路径,而是边界:备份到期前一秒、到期时刻、重复执行、未知状态、缺失请求时间。测试要把这些情况都固定下来。
// file: internal/profile/deletion_test.go
// tier: L1
// verify: go test ./internal/profile/
package profile
import (
"reflect"
"strings"
"testing"
"time"
)
func at(day int) time.Time {
return time.Date(2026, 11, day, 9, 0, 0, 0, time.UTC)
}
func TestAdvanceFollowsStateMachine(t *testing.T) {
d := Deletion{State: StatePending, RequestedAt: at(1), BackupUntil: at(30)}
deleted, err := Advance(d, at(2))
if err != nil {
t.Fatal(err)
}
if deleted.State != StateDeleted {
t.Fatalf("state = %s, want deleted", deleted.State)
}
if !deleted.RequestedAt.Equal(d.RequestedAt) || !deleted.BackupUntil.Equal(d.BackupUntil) {
t.Fatalf("timestamps changed: %+v", deleted)
}
purged, err := Advance(deleted, at(30))
if err != nil {
t.Fatal(err)
}
if purged.State != StatePurged {
t.Fatalf("state = %s, want purged", purged.State)
}
}
func TestAdvanceWaitsForBackupWindow(t *testing.T) {
d := Deletion{State: StateDeleted, RequestedAt: at(1), BackupUntil: at(30)}
got, err := Advance(d, at(29))
if err == nil {
t.Fatal("want error before backup expiry")
}
if got.State != StateDeleted {
t.Fatalf("state = %s, want unchanged deleted", got.State)
}
if !strings.Contains(err.Error(), "backup retained") {
t.Fatalf("error = %v, want backup message", err)
}
}
func TestAdvanceRejectsInvalidTransitions(t *testing.T) {
if _, err := Advance(Deletion{State: StatePurged}, at(1)); err == nil {
t.Fatal("want error for purged state")
}
if _, err := Advance(Deletion{State: State("archived"), RequestedAt: at(1)}, at(2)); err == nil {
t.Fatal("want error for unknown state")
}
if _, err := Advance(Deletion{State: StatePending}, at(2)); err == nil {
t.Fatal("want error for missing request time")
}
if _, err := Advance(Deletion{State: StateDeleted, BackupUntil: at(30)}, at(31)); err == nil {
t.Fatal("want error for missing request time")
}
}
func TestExportableSkipsInternalFields(t *testing.T) {
fields := []Field{
{Name: "phone", Class: ClassSensitive},
{Name: "nickname", Class: ClassPublic},
{Name: "risk_score", Class: ClassInternal},
{Name: "avatar", Class: ClassPublic},
}
got := Exportable(fields)
want := []string{"avatar", "nickname", "phone"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("exportable = %v, want %v", got, want)
}
empty := Exportable(nil)
if empty == nil || len(empty) != 0 {
t.Fatalf("nil input = %#v, want non-nil empty slice", empty)
}
}
TestAdvanceFollowsStateMachine 走完 pending → deleted → purged,并断言两个时间戳在转换中不变。时间戳一旦被覆盖,冷静期和备份窗口都会失去依据。
TestAdvanceWaitsForBackupWindow 用第 29 天访问第 30 天到期的备份,期望报错且状态保持 deleted。另一个隐含边界在第 30 天:now.Before(BackupUntil) 为 false,所以到期当天就允许清除。测试的零值用例则覆盖“没有请求时间”和“没有备份时间”这两种数据损坏。
TestExportableSkipsInternalFields 断言风险分不进入导出。这个用例防的是“导出时复用完整对象序列化”的老问题:内部字段一旦进入用户下载包,就等于主动泄露。
测试没有覆盖真实备份系统的批次、跨区域复制和恢复演练。这些需要集成测试和定期演练,单元测试只能保证状态机本身不倒退。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/profile/ -run 'TestAdvance|TestExportable' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestAdvanceFollowsStateMachine
--- PASS: TestAdvanceFollowsStateMachine (0.00s)
=== RUN TestAdvanceWaitsForBackupWindow
--- PASS: TestAdvanceWaitsForBackupWindow (0.00s)
=== RUN TestAdvanceRejectsInvalidTransitions
--- PASS: TestAdvanceRejectsInvalidTransitions (0.00s)
=== RUN TestExportableSkipsInternalFields
--- PASS: TestExportableSkipsInternalFields (0.00s)
PASS
ok webapp/internal/profile 0.271s
输出只覆盖状态机和导出清单,不包含真实存储的删除结果。上线前还需要在预发环境跑一次完整链路:导出、删除、等待备份窗口、清除、恢复演练,并确认恢复后已注销账号不会重新出现。
小结
注销不是布尔值,而是一个有顺序的状态机。pending 让用户反悔,deleted 表示业务数据已经删除但副本仍在,purged 表示备份窗口结束、副本已清除。导出必须在删除之前,备份清除必须在到期之后,审计留痕必须贯穿每一次状态转换且不复制个人信息。
只看用户表有没有 deleted_at,无法回答“数据到底删干净没有”。真正的答案是数据清单:每个存储、每个字段、每个副本分别怎么删、谁来重试、失败如何记录。状态机负责顺序,清单负责范围,两者缺一不可。
下一章会从资料与隐私转向多端产品:同一个服务端,面对 App、Web 和小程序时,接口、缓存和发布节奏会有什么不同。
阅读导航:上一节:4.2 个人信息分级与最小化 · 下一节:5.1 App、Web 与小程序的差异 。
常见错误与边界
- 用一个布尔字段表示注销。
deleted_at只能表示“业务表标记过”,无法表达导出、备份清除和审计的状态。 - 先删数据再让用户导出。导出必须在 pending 阶段完成,删除后用户拿不到自己的数据,客服也无法补救。
- 备份未到期就清除。恢复流程会把已删数据带回来,注销承诺被一次恢复演练推翻。
- 恢复后不重放注销状态。备份恢复只验证“数据能回来”,没验证“已注销的数据不会回来”。
- 审计日志复制完整个人信息。审计保留期通常更长,复制手机号和证件号会让注销永远无法真正完成。
- 清理任务重复执行时静默成功。purged 状态应该返回明确错误或幂等结果,未知状态应该报警。
- 只删用户表。订单、消息、搜索索引、推荐特征、日志、第三方 SDK 都可能有副本,需要按清单逐项处理。
- pending 没有截止时间。没有截止时间的冷静期会变成永久挂起,用户既不能用账号也不能重新注册。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。