HarmonyOS 7 Network Kit:账号注销擦除状态机
一、审核没有卡在“有没有按钮”,而是卡在杀进程之后
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。到这里,账号注销才从“页面上有入口”变成了一条能被中断、能被解释、也能被验证的工程链路。
更多推荐

所有评论(0)