Skip to content
Cooing's Blog
Go back

用户点下注销之后:多身份、设备、Agent 的删除链路

TL;DR

一个带录音硬件、第三方服务,多端设备,Agent 的产品注销流程,一个用户在系统里有多条登录身份、设备绑定、音频与记忆数据,后台还可能运行着录音分析、日程同步和 Agent Workflow1。删掉账号,需要同时处理这些数据的所有权与后续写入,我选择将注销拆成一条固定顺序:二次认证 → 原子冻结 → 撤销访问 → 停止在途任务 → 擦除数据 → 写入账户 tombstone2,将轻撤权与重删除两阶段分开,以访问撤销完成为准并响应用户,真正的业务数据擦除继续在后台执行。跨服务失败由 Temporal 重试和周期性对账接续,设备上的离线内容则在下一次绑定前清除

flowchart LR
    A["重新验证身份"] --> B["原子冻结 active → deleting"]
    B --> C["撤销登录、授权和设备归属"]
    C --> D["access_revoked / 202"]
    C --> E["取消任务、确认停写"]
    E --> F["对象存储、业务库及独立服务擦除"]
    F --> G["清洗用户资料 / deleted"]
    B -.-> H["定期对账与重试"]
    H -.-> C

一、数据分散,牵一发而动全身

以一次实际的使用路径为例:用户通过 Apple 登录,绑定录音设备,音频上传后生成逐字稿、Episode3 和记忆;Agent 从这些内容中提取待办,再通过 OAuth 授权向外部日历写入日程。用户在 App 设置页提交注销时,云端数据、第三方权限和离线硬件都与同一个业务账号有关。

数据所在位置关联内容注销时的处置
身份与授权邮箱、Apple/Google、企业 OAuth 身份、业务 Session、OAuth Grant验证操作者,冻结账号,逐一撤销旧访问
业务数据库音频记录、转写、Episode、记忆、Agent Session、日程与搜索投影按 user_id 和关联外键清理
对象存储原始音频、Wiki、支持附件按记录中的对象 Key 与用户前缀删除
第三方集成Calendar Connection、Webhook、授权凭据断开连接,撤销本服务持有的授权
独立服务工具调用授权、运行结果、用户目录先关闭访问,再清除所属数据
硬件设备云端归属、设备名称、离线录音与配置云端解绑,再次绑定前通过 App 执行本地清理
运营记录设备生产事实、解绑事件、必要的审计引用、注销原因依据处置规则保留或去标识化

数据清单本身需要持续维护。源表清理完成后,派生副本、搜索索引和其他存储仍可能保留旧数据;跨存储传播与持续核对也是大规模数据删除系统的设计重点4,据此从数据流反向整理了删除清单,逐一核对 users 表外键之外的对象、副本和外部授权

二、从登录态到一次性的注销证明

2.1 验证方法由已绑定身份决定

普通业务 Session 适合保持日常登录,销毁账户前增加一次身份验证,证明操作者仍控制着账号的有效身份。

这里用身份服务的 Admin identities[] 作为方法来源,本项目身份认证主要使用GoTrue,一个业务账号可以同时绑定多个登录 subject5:当前 subject 有邮箱身份,就向该身份实际登记的邮箱发 OTP6,并提供密码验证入口;已绑定 Apple/Google 身份时,展示相应系统登录面板;企业身份则通过重新完成 OAuth 验证。客户端最近使用的登录方式只影响按钮排序,身份服务负责确定可执行的验证方法。

这个细节在多账号绑定场景中很重要。用户资料页的邮箱可能来自聚合 Profile,和当前登录 subject 的邮箱不同。发信邮箱、脱敏展示地址以及密码验证使用同一条 Admin identity。Apple/Google 重新认证后的 subject 也要在签发注销证明前再次确认仍绑定当前业务账号,邮箱 OTP 与密码复用现有身份提供方的校验能力;发信频率由身份提供方维护。若发送频繁,后端将上游限制映射为 429,附带建议重试时间。对于验证码过期、错误或已使用而上游返回相同错误的情形,极端的登录直接注销情况(两次发信间隔小于60s)采取客户端统一提示重新输入或获取新验证码

2.2 challenge、proof 和冻结事务

Apple/Google 的系统面板需要一张本次注销专用的随机 challenge7。客户端将它参与 provider token 的 nonce 校验,服务端再核对返回身份。每次重新开始验证会轮换 challenge,成功兑换后只能使用一次。最终无论通过哪种方式验证,都会获得一张有效期 10 分钟的短时 proof8,数据库保存其 SHA-256 摘要、签发身份和到期时间。

proof 只证明此前完成过有效身份验证。真正发起注销时,服务端在同一个数据库事务里处理以下这几件事:

BEGIN
  读取 proof 绑定的 identity
  锁定 identity 与业务账号的绑定关系
  条件消费有效且未使用的 proof
  保存 reasons[] 与 request_token 的摘要
  将用户状态从 active 更新为 deleting
COMMIT

启动或接续该用户的 Deletion Workflow

事务固定按身份绑定、proof、用户状态的顺序取锁。这样同一张 proof 从两台设备并发提交时只有一次冻结成功;另一条身份恰好执行解绑时,锁顺序也能确保结果一致。签发 proof 的 identity 仍属于当前账号即可,同账号另一条已认证 Session 可以提交这张证明。

注销原因是作为产品端feedback的重要需求,会在delete前要求用户进行多选与自由文本写入,按客户端提交的字符串数组保存,保留原始值与顺序,便于后续统计。提交前客户端还生成一张随机 request_token9,服务端只保存摘要。它会在旧 Session 失效后用于查询注销进度。

deleting 一旦提交,就成为所有业务写入入口的拒绝条件。工作流启动发生在事务之后,调用失败时账户继续处于 deleting,由周期巡检负责补起执行,这段边界避免了数据库行锁长时间等待外部网络,也给进程在提交后崩溃留下恢复路径,并方便后续如有需要利用服务器峰谷性能时间,将轻撤权和数据量较大的异步数据删除(S3存储,音频等)解耦

三、状态码202 到底代表什么

用户确认注销并二次认证后,进入deleting阶段,冻结以后,业务请求会看到 account is deleting。但客户端的成功页还需要更强的证据:旧账号的认证、授权和设备访问均已撤销。

当前注销链把访问处置放在前半段,按序执行 OAuth Grant 撤销、身份服务账户删除、第三方连接断开、设备云端解绑、独立服务的访问撤销,最后释放相应的登录关联。每一步返回明确的完成结果,才推进下一步。

与传统的普通多端应用不同,本项目存在 Capacity 外部能力作用域,支持认证授权 MCP server 以便于外部 Agent 调用当前应用所记录的相关数据,所以 Public MCP10 或工具调用:已经签发的 Token 可能仍在有效期内,因此每次新的在线授权判断都要检查账号 active 状态和相应的 refresh grant。第二个是 OAuth 授权码兑换:冻结与签发新 Grant 必须受相同的账号状态门和并发锁约束,防止撤权过程中又生成一张可用凭据

身份映射的清理也得有顺序。旧 GoTrue subject 对应的关联保留到最终 tombstone,防止尚未过期的旧 JWT11 因查不到映射而被误认为新用户。第三方企业登录的旧关联在访问撤销阶段末释放;再次完成新的 OAuth 登录后,用户可以得到全新的空账号,旧 Session 仍指向已经冻结的用户,对客户端而言,状态契约只有三个需要关注的结果:

服务端结果用户实际能做什么对应证据
503 deletion_pending留在处理中页面,用已保存的查询凭证稍后查询freeze 已提交,访问撤销仍在执行或查询超时
202 deleting / 查询到 access_revoked展示既定注销成功页,清理本机 Session所有必需的访问撤销步骤完成
deleted后台完成应用侧擦除,账户只留下最小墓碑数据域清理结果与最终状态提交

API 最多等待一个短窗口观察轻阶段结果。若响应在网络中丢失,客户端用 request_token 查询 pending 或 access_revoked,查询与原来的 bearer 分离。实现中复用了 Temporal Workflow History12:最后一项必需撤权 Activity 的 ActivityTaskCompleted 事件作为 access_revoked 证据。即使随后的数据清理失败并重开执行,已经完成的撤权结果依然有效。

这个完成点直接影响产品文案,202 说明旧账号已经失去使用能力,音频、记忆和其他用户内容正在后台擦除。成功页面应该说明这一处理阶段,后台运维继续跟踪 deleted,为了提升用户体验,当前在202 即提示用户注销成功

四、擦除前,先让后台安静下来

这部分最容易出现竞态,设想一个场景:某条音频在 11:59 被 Agent 接纳,12:00 用户提交注销,12:01 录音分析刚好结束并准备写回新的 Episode。此时如果只清理业务表,晚到的结果会重新生成一批用户数据,孤儿行来了

因此冻结首先作用于新任务的准入与写回,包括录音派发、Agent Session、定时调度、日历同步和旧消息的重投,访问撤销后,Workflow 立即取消该用户已经运行的录音分析、Agent 主流程及其子流程,再确认每个 Temporal Execution 进入终态。数据库中仍标记 queued、pending、running 的任务也要归并为失败或终止状态;Agent 还需要覆盖 echo_committed 和 waiting13 等可能后续写入的状态

这两类证据分别来自执行器和数据库。CancelWorkflow14 接受请求后,后台仍可能在处理取消信号;数据库中的任务状态更新完成,也需要确认原执行实例已经退出。两边都完成后才进入 DeleteStoredObjects

对象存储删除先依据音频记录定位对象 Key,再清除按用户前缀组织的 Wiki 与附件。业务数据库的相关行随后按外键规则处置。在当前这套设计里,外键 ON DELETE CASCADE 对应用户内容,SET NULL 用于保留记录但解除账号关联,RESTRICT / NO ACTION 让必要审计记录继续指向清洗后的账户行。具体实现通过 PostgreSQL 的 pg_constraint15 找到引用 users(id) 的外键,按各表的删除规则清理;相互依赖的表分批推进,直到关联数据收敛。Schema 由此承载了部分数据保留与删除约束

不同类型的数据走向有区别:

处理结果举例
删除音频、转写、记忆、Agent 结果、用户文件、OAuth Grant、搜索投影
清除归属设备当前 owner、部分可保留的运营记录中的用户引用
保留最小记录脱敏后的用户 tombstone、设备生产事实、必要的解绑与撤权审计、注销原因

用户主表保留 id,清空姓名、头像、时区等 Profile 内容,并写入 deleted_at。留下 tombstone,主要是为了让审计外键维持完整;保留记录仍需接受字段最小化和保留期限审查。

跨服务的清理通过各自拥有者完成。例如独立工具服务先接收 scope=access 撤销 Grant,待全系统确认停写后,再接收 scope=all 删除它自己的用户数据。两次请求使用稳定且不同的 request_id16,服务在同一事务内执行处置并登记结果;重试时核对用户、环境、scope 和请求标识后回放 completed。这部分对应分布式删除中的「由各数据域执行,中心编排状态」方式。数据集元信息、生命周期策略、执行插件和审计可以拆成独立责任17。

五、离线硬件的另一条删除时间线

设备注销会先完成云端解绑:活动绑定改为撤销记录,设备 owner 和名称清空,未使用的绑定 Token 作废,设备回到可以被新账号绑定的状态。设备序列号、生产批次和物流信息仍作为硬件事实保留。

但用户的录音文件也可能留在设备闪存。设备离线时,云端只能处理自己持有的数据。为此,设计增加了一个「再次绑定前擦除」门槛:设备域从最近一次 bound 或因账号注销产生的 unbind 事件判断 reset_required,让正式 App 在新绑定流程里先执行已有的恢复出厂 BLE18 指令。

sequenceDiagram
    participant App as 新账号 App
    participant API as 设备绑定服务
    participant FW as 离线设备
    participant DB as 设备事件记录

    App->>API: 查询设备归属
    API->>DB: 查询最近的绑定 / 注销解绑事件
    DB-->>API: reset_required = true
    API-->>App: 设备待清理
    App->>FW: 发送 BLE 恢复出厂指令
    FW-->>App: 返回成功状态
    App->>API: 申请绑定 Token,声明 reset_completed
    API->>DB: 复核待清理事件与绑定条件
    API-->>App: 新绑定凭证
    App->>API: 确认新账号绑定
    API->>DB: 写入 bound 事件与新 owner

这套状态可以从既有事件记录计算,免去额外维护一张待擦除表,不强求用户在绑定设备的情况下进行delete,设备新出库时同样可能是未绑定状态,所以要看事件来源。服务端已有 reset_required 查询和 reset_completed 绑定门禁的实现及测试;正式 iOS/Android 绑定界面是否都完成了发送恢复出厂 BLE 指令、收到成功状态再继续绑定,服务端当前接受的是 App 的成功声明,硬件成功响应发生在设备与 App 之间。未经绑定的离线设备可能长期保留本地内容(壁纸和部分离线音频),但不重要,再次绑定前会进行清理兜底处理

六、故障恢复和上线验收

跨数据库、身份服务、对象存储与设备域的注销会持续一段时间。Temporal 为每个账号使用固定 Workflow ID 顺序执行上述步骤,Activity 出错时按策略重试。对于独立服务的网络故障,单次执行有重试预算,耗尽后由 Reconciler19 继续恢复同一个账号的删除链,定期扫描仍处于 deleting 的账户,分页检查它们的工作流是否运行,补起缺失或失败的执行;过期的 proof 和 challenge 则由独立的有界批次清理。账号已经冻结却缺少运行中的 Workflow 时会留下告警。各服务要求返回匹配的完成凭证:例如独立服务的 scope=all 结果必须与原请求对应,才能继续写入最终 tombstone。

我们为此安排了包括但不限于以下内容的故障测试:

故障注入点需要观察的结果
两台设备并发消费 proof;同时发生身份解绑只提交一次 freeze,锁顺序稳定
freeze 成功,启动 Workflow 时进程退出账号保持 deleting,巡检拉起后续步骤
旧 OAuth Code、JWT、Refresh Grant 继续使用账号状态门和授权撤销阻止旧账户重新取得访问
录音分析任务在冻结前启动,删除后才完成执行实例被取消,晚到写入被阻断
对象删除成功但响应丢失下次重试仍返回完成,数据库索引保留到对象清理结束
独立服务 scope=access 成功、scope=all 超时用户访问持续关闭,后台继续擦除
设备断线或恢复出厂失败新绑定停在待清理状态,重连后重试

最后在staging到prod前,还要检查一次发布兼容性,注销 Workflow 的 Activity 顺序属于持久化历史的一部分,老版本执行与新 Worker 的 Replay 需要匹配。修改 Activity 顺序可能触发 nondeterminism 风险20,按最佳实践看,Worker Versioning 与 Patching 是两种常见的兼容处理方式,当前直接滚动CD,采用固定顺序直接切换,发布前需清点尚未终结的旧执行,明确旧 Worker 的退出和回放条件,并在 Staging 验证实际部署路径

数据删除的最后一层边界是备份和外部系统。在线用户内容、缓存或派生数据、备份副本可能遵循不同的删除时间表;底层存储的删除流程与应用业务层的完成状态也需要分别核对21。实际最后还需要和SRE打架,要把备份保留、审计最小化、第三方内容和区域合规要求澄清并写入数据处理政策,确保合崩了还能恢复

Footnotes

  1. Workflow(工作流):把一组有顺序、可重试的后台操作组织成一次持续执行。本文中的 Agent Workflow 负责处理用户内容;注销则有独立的 Deletion Workflow,负责按顺序撤权、停写、擦除。参见 Temporal:Workflow Definition。 ↩

  2. Tombstone(墓碑记录):业务内容删除后保留的最小账户记录。保留主键和必要的审计关联,清空姓名、头像等个人资料并标记 deleted,以维护历史外键关系。 ↩

  3. Episode(按话题聚合的一段录音内容):录音转写经话题切分后,把同一段会话的转写成员组织为一个 Episode,后续由 Agent 生成纪要并提取相关记忆或待办。注销时连同原始转写与纪要一起清理。 ↩

  4. 参考案例:Netflix 数据删除平台。 Netflix 工程师在 Architecting a Centralized Platform for Data Deletion at Netflix 中讨论了跨多种数据存储的删除传播、不同存储引擎的删除机制、tombstone 积压与持续审计。这里借鉴其跨数据域追踪的思路。InfoQ 演讲与文字稿。 ↩

  5. Subject(身份主体标识):由身份提供方签发的身份键;一个业务 user_id 可以关联多个登录 subject。验证时以当前登录的 subject 和服务端已确认的绑定为依据,避免混用资料页展示邮箱与真实登录身份。 ↩

  6. OTP(One-Time Password,一次性验证码):通过当前身份所绑定的邮箱完成再次验证。本流程使用 6 位数字验证码;发送频率及验证码核验由既有身份服务负责。 ↩

  7. Challenge(随机挑战值):Apple/Google 重新验证时由后端签发的短期随机串。它作为 nonce(一次性随机数)参与第三方身份令牌的校验,使本次验证与之前的登录结果区分开;新 challenge 会使旧值失效。 ↩

  8. Proof(注销证明):重新验证身份后签发的短时、一次性凭证。本文设计中有效期为 10 分钟,数据库保存其哈希摘要、签发身份与过期时间;冻结事务确认该身份仍与业务账号绑定并原子消费 proof。 ↩

  9. Request Token(注销状态查询凭证):客户端在 freeze 前生成的随机不透明值,供原 Session 失效或 HTTP 响应丢失后查询 pending / access_revoked。服务端只保存摘要,查询时避免将原值放入 URL 或日志。 ↩

  10. MCP(Model Context Protocol,模型上下文协议):Agent 与外部工具或服务交换能力的协议。文中的 Public MCP 是对外工具调用入口,访问已冻结账号时应按账号状态与对应授权关系拒绝新请求。参考 MCP 官方介绍。 ↩

  11. JWT(JSON Web Token,JSON Web 令牌):携带 subject 等身份声明的签名凭证。JWT 即使尚未过期,业务服务仍需检查账号状态;身份关联清理也要防止旧凭据被解析成新账号。参见 RFC 7519。 ↩

  12. Workflow History(工作流历史):Temporal 记录的一系列执行事件,包括 Activity 的安排、完成及失败。本文用最后一个必需撤权 Activity 对应的 ActivityTaskCompleted 事件确认轻阶段完成;重阶段重试不改变已经成立的撤权结果。Temporal:Workflow Definition。 ↩

  13. echo_committed 与 waiting 是 Agent Session 的业务中间态:前者表示某阶段结果已提交,后者表示会话仍在等待后续执行条件;两者都可能关联后续异步写入,因此纳入注销停写核对。 ↩

  14. CancelWorkflow 是 Temporal 的取消请求接口。发出取消信号后,执行仍需处理取消、资源释放等操作;代码还会观察对应 Execution 已关闭,再开始清除其依赖的数据。 ↩

  15. pg_constraint 是 PostgreSQL 的系统目录,记录外键等约束及其动作。这里读取 users(id) 的外键删除规则来枚举数据处置对象;CASCADE、SET NULL、RESTRICT / NO ACTION 代表不同的关联记录处理方式。参见 PostgreSQL:pg_constraint。 ↩

  16. Idempotency(幂等性):相同业务请求重试时产生相同的最终效果。两种 scope 分别使用固定 request_id;服务端验证请求身份、环境与范围匹配后重放已经记录的 completed,避免网络重试引起重复副作用。 ↩

  17. 参考案例:Uber 数据生命周期管理。 Evolution of Data Lifecycle Management at Uber 介绍了数据集元信息、生命周期策略与执行机制的分工,可参考其把数据处置规则和实际执行解耦的组织方式。Uber Engineering 原文。 ↩

  18. BLE(Bluetooth Low Energy,低功耗蓝牙):App 与设备交换控制指令的本地通信方式。本例通过 BLE 请求设备恢复出厂,App 收到设备成功响应后才继续新账号绑定。 ↩

  19. Reconciler(状态对账与修复任务):定期扫描仍处于 deleting 的用户,检查固定 ID 的注销 Workflow 是否运行,并补起缺失或失败的执行;短期凭证的过期清理也由这套巡检机制承担。 ↩

  20. Nondeterminism(工作流非确定性):Temporal Worker 通过 Replay(重放)既有 History 恢复 Workflow 时,代码产生的命令顺序与历史记录不一致,例如修改了尚未完成的 Workflow 的 Activity 顺序,会导致重放失败。Temporal 文档介绍了通过 Worker Versioning 或 Patching 演进代码的做法。Temporal:Workflow Definition。 ↩

  21. 参考资料:Google Cloud 数据删除机制。 Google Cloud 的 Data deletion on Google Cloud 介绍服务层删除请求、底层存储回收和副本处理涉及的多个阶段。应用侧 deleted 表示本文描述的业务数据处置链完成,底层删除与备份保留还需按实际平台政策核对。Google Cloud 官方文档。 ↩

Copyright & License

Author: Cooing

Original Link: https://blog.cooingcode.space/en/blog/account-deletion-lifecycle/

License: Feel free to share or quote, but please attribute properly.