一、审核没有卡在“有没有按钮”,而是卡在杀进程之后

EraseLedger 的账号注销入口一直都在设置页底部。最初版本点击确认后依次调用删除接口、清缓存、清数据库、退出登录,联调时每次都能回到欢迎页。真正把问题暴露出来的是审核复测脚本:服务端已经返回 HTTP 202,应用进度到 42% 时被系统回收;重新打开后,页面又显示“立即注销”,再次点击产生了第二个删除请求,而旧凭据和本地草稿仍然存在。

这不是少写一个 catch。注销是一条跨端事务:远端删除可能异步完成,本地擦除不能早于远端确认,授权凭据又是轮询远端状态所必需的。若把 token 先删掉,应用无法确认服务器是否完成;若把本地数据留到最后却不持久化进度,进程死亡后就会重复执行。我们最终把它改成一台可恢复状态机,任务号固定为 ERASE-2042,幂等键 DEL-2042-7F3A,账号尾号 4827。

完整状态为 CONFIRMING → SUBMITTED → REVOKING → PURGING_LOCAL → VERIFYING → CLOSED。SUBMITTED 之前用户可以取消;服务端接受后只能继续完成,不能把按钮重新变成“注销”。这条规则同时约束页面、Preferences 日志和 Network Kit 请求,不再靠页面内的几个布尔值猜测进度。

二、先复盘那次 42%:三份状态各说各话

事故发生在 20:42。20:42:09,服务端返回 202 Accepted 和 requestId DEL-2042-7F3A;20:42:12,旧代码删除图片缓存后把 UI 更新到 42%;紧接着进程被杀。重启后出现三个互相矛盾的事实:服务端任务仍在执行,Preferences 没有请求号,首页的登录态却还有效。

旧实现的问题是把 ViewModel 当成了事务日志。@State progress 只能驱动画面,无法证明某个副作用已经完成;一个 try/finally 也无法跨越进程生命周期。于是我们把注销数据单独放进 erase_journal,只记录继续执行所必需的最小字段:taskId、requestId、idempotencyKey、state、completedSteps、attempt、updatedAt。它不保存访问 token,也不保存账号明文。

这次改造没有追求分布式事务。手机端无法回滚已经完成的远端删除,正确目标是“至少执行一次的外层调度 + 每一步幂等”。同一个幂等键重复提交,服务端返回同一个 requestId;本地每个擦除步骤完成后追加 step 标记;恢复时从第一个未完成步骤继续。

三、Preferences 不是缓存,而是状态机的恢复点

第一段代码解决“重启后不知道发生过什么”。状态只能向前移动,且每次落盘完成后才允许 UI 显示新阶段。这样 42% 不是一个任意数字,而是已完成步骤的派生结果。

// EraseJournal.ets
import { preferences } from '@kit.ArkData'

export type EraseState = 'CONFIRMING' | 'SUBMITTED' | 'REVOKING' |
  'PURGING_LOCAL' | 'VERIFYING' | 'CLOSED'

export interface EraseRecord {
  taskId: string
  requestId: string
  idempotencyKey: string
  state: EraseState
  completedSteps: string[]
  attempt: number
  updatedAt: number
}

export class EraseJournal {
  private order: EraseState[] = [
    'CONFIRMING', 'SUBMITTED', 'REVOKING',
    'PURGING_LOCAL', 'VERIFYING', 'CLOSED'
  ]

  async advance(next: EraseRecord): Promise<void> {
    const current = await this.read()
    if (current && this.order.indexOf(next.state) < this.order.indexOf(current.state)) {
      throw new Error('ERASE_STATE_REGRESSION')
    }
    const store = await preferences.getPreferences(getContext(), 'erase_journal')
    await store.put('active_record', JSON.stringify(next))
    await store.flush()
  }
}

flush() 是这个写法的关键,不等待持久化就更新页面,仍可能在断电时出现“看见已提交、恢复点却没提交”的错觉。状态回退被明确拒绝,但同状态重复写允许发生,因为 Network Kit 回调和生命周期恢复可能几乎同时到达。

completedSteps 也不能无限追加任意字符串。项目只允许 remoteAccepted、remoteDone、grantRevoked、rdbPurged、filesPurged、preferencesPurged 六个枚举值。最终验收页显示 6/6,代码审查也能一眼看出还有哪些副作用没有收口。

四、POST 可以重试,但必须携带同一个幂等键

第二段代码处理最危险的重复调用。网络超时并不等于服务器没收到请求,因此 POST /account/deletions 不能每次生成新键。键在 CONFIRMING 阶段生成并先落盘,之后无论按钮抖动、请求超时还是进程重启,都复用 DEL-2042-7F3A。

// ClosureApi.ets
import { http } from '@kit.NetworkKit'

async submitDeletion(record: EraseRecord): Promise<EraseRecord> {
  const client = http.createHttp()
  try {
    const response = await client.request(`${this.baseUrl}/account/deletions`, {
      method: http.RequestMethod.POST,
      header: {
        'Authorization': `Bearer ${await this.tokenStore.peek()}`,
        'Idempotency-Key': record.idempotencyKey,
        'Content-Type': 'application/json'
      },
      extraData: JSON.stringify({ reason: 'USER_REQUEST', taskId: record.taskId }),
      connectTimeout: 8000,
      readTimeout: 12000
    })
    if (response.responseCode !== 202 && response.responseCode !== 200) {
      throw new Error(`DELETE_SUBMIT_${response.responseCode}`)
    }
    const body = JSON.parse(response.result as string) as { requestId: string }
    return { ...record, requestId: body.requestId, state: 'SUBMITTED',
      completedSteps: ['remoteAccepted'], updatedAt: Date.now() }
  } finally {
    client.destroy()
  }
}

这里的 peek() 只在内存中读取凭据,不把 token 写进 HiLog 或 Preferences。createHttp() 与 destroy() 成对出现;轮询状态时每次请求也在 finally 中销毁实例,避免页面多次进入后积累连接对象。自动重试最多 1 次,并且只重试网络错误与 5xx;4xx 会停止并显示可审计错误,不把权限问题伪装成弱网。

服务端若返回 200,表示同一个键此前已经受理,客户端仍然拿相同 requestId 继续。任务 ERASE-2042 的实际运行正是这样:第一次 202 后进程死亡,恢复查询得到 REMOTE_DONE,没有生成第二个删除任务。

五、凭据不能先删,页面也不能持有执行权

恢复器由 Ability 生命周期触发,而不是由“注销页是否还在栈中”决定。应用启动时读取 erase_journal;只要状态不是 CLOSED,就先拿一个单飞租约,再查询远端。多个页面同时订阅进度可以,但执行器只能有一个。

// EraseCoordinator.ets
async resume(record: EraseRecord): Promise<void> {
  if (!this.lease.tryAcquire(record.taskId)) return
  const run = ++this.generation
  try {
    const remote = await this.api.query(record.requestId)
    this.assertCurrent(run)
    if (remote !== 'REMOTE_DONE') {
      this.schedulePoll(record, run, 2000)
      return
    }

    await this.journal.advance(this.withStep(record, 'remoteDone', 'REVOKING'))
    await this.grants.revokeCurrentAuthorization()
    await this.journal.advance(this.withStep(record, 'grantRevoked', 'PURGING_LOCAL'))

    await this.rdb.clearUserRows(record.taskId)
    await this.files.clearUserFiles(record.taskId)
    await this.localPrefs.clearUserKeys(record.taskId)
    await this.tokenStore.removeLast()

    const closed = this.withAllSteps(record, 'VERIFYING')
    await this.verifyAndClose(closed, 'ERASE-2042-A91C')
  } finally {
    this.clearPollTimer()
    this.lease.release(record.taskId)
  }
}

顺序很重要:远端完成之前保留查询所需凭据;远端完成后撤销授权,再清数据库、文件和业务 Preferences;访问 token 最后删除。每个清理函数都以 taskId 为幂等输入,目录不存在、数据行已删除都视为成功。这样恢复调用三次不会因为“文件已不存在”停在半路。

页面离开只解除订阅,不取消已经被服务端接受的注销。应用进入后台时轮询计时器暂停,回到前台立即查一次;generation 过期的回调不能推进状态。这个细节修掉了旧版最隐蔽的问题:旧页面的慢响应会把新页面从 VERIFYING 又写回 SUBMITTED。

六、验证阶段不是看欢迎页,而是反向证明已经清干净

早期验收把“跳转到登录页”当成完成,实际上路由变化和数据擦除没有因果关系。新版本的 VERIFYING 会做六项反查:远端状态为 REMOTE_DONE;当前授权查询为空;RDB 用户表计数为 0;用户文件目录不存在;业务 Preferences 的 user-scoped key 数为 0;tokenStore 返回空。

只有 6/6 全部通过,才写入回执 ERASE-2042-A91C 并进入 CLOSED。回执只保存 taskId、完成时间、步骤摘要和服务端请求号的哈希后缀,不保留账号标识、删除原因明文或旧 token。失败项会保留当前恢复记录,下次启动继续,而不是为了让 UI 好看强行关闭任务。

本次异常恢复日志如下:

20:42:09.208  EraseLedger  ERASE-2042  SUBMITTED     requestId=DEL-2042-7F3A http=202
20:42:12.041  EraseLedger  ERASE-2042  INTERRUPTED   progress=42% process=reclaimed
20:42:31.774  EraseLedger  ERASE-2042  RESUME        remote=REMOTE_DONE attempt=2
20:42:47.906  EraseLedger  ERASE-2042  VERIFYING     cleared=6/6 token=EMPTY
20:42:47.944  EraseLedger  ERASE-2042  CLOSED        receipt=ERASE-2042-A91C duration=38.6s

日志里的 attempt=2 表示恢复执行次数,不代表创建了第二个远端请求。这个命名后来也专门改过:旧字段叫 retryCount,审核同事很容易误解成“重复注销了两次”。

七、最终页面把可解释性留给用户

恢复后的 ClosureStatusPage 不再显示一个模糊转圈。页面顶部说明任务 ERASE-2042 已从 42% 恢复,当前状态 CLOSED;中间列出远端删除、授权撤销、本地数据、文件、偏好与凭据六项结果;底部显示 requestId DEL-2042-7F3A、回执 ERASE-2042-A91C、总耗时 38.6 秒和网络重试 1 次。

页面只有“复制回执”和“返回欢迎页”两个按钮。状态进入 SUBMITTED 后,不再提供“取消注销”;这不是限制用户,而是避免给出系统无法兑现的操作。若远端长期停留在处理中,页面展示“等待服务器完成”,并保留同一个任务号供客服定位,绝不会重新生成请求。

UI 上的 6/6 来自验证报告,不是动画结束时写死的数字。状态栏时间 20:42、网络 5G/Wi‑Fi、电量 72%,与 DevEco 模拟器和 HiLog 画面一致;这类看似琐碎的一致性,在复盘跨生命周期问题时能避免大量误判。

八、上线前留下的边界和回归项

这套状态机解决的是客户端的可恢复执行,并不能替代服务端的数据删除证明。服务端必须保证幂等键语义、状态查询和最终完成态稳定;如果接口把相同幂等键映射到不同 requestId,客户端再严谨也无法收口。弱网测试还要覆盖 DNS 失败、202 响应丢失、轮询 5xx、凭据临界过期以及系统时间回拨。

本地清理也不能粗暴删除整个应用沙箱。非用户数据,例如首次启动资源、匿名配置和注销回执,应由产品定义独立命名空间。RDB 清理需在事务中完成;文件删除要先关闭打开的 PixelMap、流和数据库句柄;Preferences 清理后必须 flush。任何模块新增 user-scoped 存储,都要同步加入 6 项清单,否则页面的“已完成”会逐渐失真。

最后一条回归项是重复进入:启动页、设置页、深链都可能打开 ClosureStatusPage,但全局只能存在一个 EraseCoordinator 执行租约。我们连续前后台切换 20 次、强杀 5 次,服务端始终只有一个 DEL-2042-7F3A,最终回执也只有 ERASE-2042-A91C。到这里,账号注销才从“页面上有入口”变成了一条能被中断、能被解释、也能被验证的工程链路。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐