羽球搭子 HarmonyOS 实战(19):账号认证后的数据作用域
一、登录成功之后,真正困难的是“不串数据”
球友甲登录后创建了一场周末对局,退出账号,再让球友乙登录同一台设备。如果页面仍然显示甲的对局、邀请码或个人胜率,认证虽然成功,数据边界却已经失效。账号系统不能只回答“当前有没有 token”,还要回答“当前所有持久化数据属于谁”。
羽球搭子的处理方式是把认证会话和业务数据分开保存。认证存储负责 token、过期时间和用户资料;对局存储、云端会话状态再读取稳定的用户 ID,生成各自的持久化键。昵称不会参与新的作用域计算,因为昵称允许修改,也可能重名。这样登录、退出和切换账号时,应用可以清空内存镜像,再从新作用域恢复数据。
这套边界解决的是本机多账号隔离,不等同于服务端权限。服务端仍然必须根据令牌校验资源归属;客户端作用域负责避免旧账号数据在界面上短暂泄漏,并保证离线缓存不会互相覆盖。
二、认证会话只保存身份,不直接承载业务对象
认证存储中只需要三类值:访问令牌、绝对过期时间和用户资料。登录接口通常返回相对秒数,保存时立即换算成绝对时间,后续每次取 token 都做过期判断。令牌为空或已经过期时,业务层统一得到空字符串,不需要每个页面重复比较时间。
export class AuthSessionStore { static readonly TOKEN = 'cloud_auth_token' static readonly EXPIRES_AT = 'cloud_auth_expires_at' static readonly USER = 'cloud_auth_user' static save(session: AuthSession): void { const expiresAt = Date.now() + session.expiresIn * 1000 AppStorage.setOrCreate<string>(this.TOKEN, session.token) AppStorage.setOrCreate<number>(this.EXPIRES_AT, expiresAt) AppStorage.setOrCreate<UserProfile>(this.USER, session.user) this.persist(session.token, expiresAt, session.user) } static getToken(): string { const token = AppStorage.get<string>(this.TOKEN) ?? '' const expiresAt = AppStorage.get<number>(this.EXPIRES_AT) ?? 0 return token.length > 0 && expiresAt > Date.now() ? token : '' } }这里有一个刻意的取舍:运行期 AppStorage 更新成功后,即使 Preferences 刷盘失败,当前页面仍可继续使用;但下次冷启动可能需要重新登录。认证存储不应该在持久化异常时伪装成完整成功,因此界面可以在关键操作前再次检查getToken(),服务端请求也必须允许返回未授权错误。
| 数据 | 适合的存储位置 | 原因 | 失效动作 |
|---|---|---|---|
| token | 认证存储 | 请求鉴权需要,生命周期独立 | 过期或退出时删除 |
| expiresAt | 认证存储 | 避免使用过期 token | 每次读取时比较 |
| userId、昵称、头像 | 认证存储 | 页面展示及作用域选择 | 用户资料更新时覆盖 |
| 对局摘要与详情 | 对局存储 | 需要离线使用和重启恢复 | 按 userId 分区 |
| 云端版本、邀请码、别名 | 云会话存储 | 用于同步和冲突判断 | 按 userId 分区 |
三、稳定 userId 进入键空间
业务存储不把所有账号的数据写入同一个sessions键,而是先计算作用域。已登录用户使用服务端返回的稳定 ID;未登录状态保留默认键,继续支持本地离线使用。这样两个账号的同名对局也不会相互覆盖。
function currentScope(): string { const user = AuthSessionStore.getUser() if (user !== undefined && user.id.length > 0) { return `u_${user.id}_` } return '' } function scopedKey(baseKey: string): string { const scope = currentScope() return scope.length === 0 ? baseKey : `${scope}${baseKey}` } function detailKey(sessionId: string): string { return scopedKey(`session_${sessionId}`) }作用域前缀不是加密,也不是权限控制。它只是明确“哪一份本地缓存属于哪位用户”。如果攻击者能够直接读取应用沙箱,仍需要系统沙箱、设备锁和敏感数据保护提供安全边界。服务端接口更不能信任客户端拼出的 userId,而应从令牌解析身份。
四、从旧键迁移到新作用域要可重复
应用早期可能只有一个无作用域键,或者曾经用昵称哈希区分用户。升级后直接只读新键,会让用户误以为历史对局丢失。恢复逻辑因此按“新作用域 → 无作用域旧键 → 昵称旧键”的顺序查找;找到旧数据后写入新键,但不在读到第一份数据前删除旧值。
function readSessions(prefs: Preferences): string { const primary = prefs.getSync(scopedKey('g_sessions'), '') as string if (primary.length > 0) { return primary } const unscoped = currentScope().length > 0 ? prefs.getSync('g_sessions', '') as string : '' if (unscoped.length > 0) { prefs.putSync(scopedKey('g_sessions'), unscoped) return unscoped } const legacy = prefs.getSync(legacyScopedKey('g_sessions'), '') as string if (legacy.length > 0) { prefs.putSync(scopedKey('g_sessions'), legacy) } return legacy }迁移必须具备幂等性:第一次启动完成复制,第二次启动首先命中新键,不再产生不同结果。旧数据是否删除应由独立的版本迁移策略决定,不能夹在页面加载中立即清理,否则一次解析异常就可能同时破坏新旧两份数据。
五、切换账号时先清镜像,再恢复目标作用域
退出账号的关键动作不是把按钮文字改成“未登录”,而是让依赖账号的内存状态立即失效。对局摘要、当前场次、云端会话版本和统计结果都可能仍在 AppStorage 中。正确顺序是清除认证会话,重置账号模式,重新恢复账号作用域存储,最后刷新页面派生状态。
function switchAccount(next: AuthSession | undefined): void { if (next === undefined) { AuthSessionStore.clear() } else { AuthSessionStore.save(next) } SessionStore.clearRuntimeMirror() CloudSessionStateStore.clearRuntimeMirror() CloudSessionStateStore.hydrateFromPrefs() SessionStore.hydrateFromPrefs() StatsViewModel.reload() }这里“先清再读”非常重要。如果直接覆盖少数字段,目标账号没有某项数据时,上一账号的旧值可能继续留在页面。清理动作只针对运行期镜像,不应粗暴清空整个 Preferences 文件,否则退出账号会连带删除其他账号的离线数据。
六、页面只消费可观察结果
页面不需要知道作用域键的具体格式。它只订阅登录态、当前用户名、对局列表和统计模型,并通过统一方法触发重新装载。认证成功、退出和用户资料更新都走同一条刷新路径,避免“首页已经切换,统计页还是旧账号”的局部更新。
class AccountViewModel { @Trace signedIn: boolean = false @Trace userName: string = '' @Trace sessions: SessionSummary[] = [] reload(): void { const user = AuthSessionStore.getUser() this.signedIn = AuthSessionStore.getToken().length > 0 this.userName = this.signedIn ? user?.nickname ?? '' : '' this.sessions = SessionStore.list() } signOut(): void { AuthRepository.signOut() AccountScopedStores.reload() this.reload() } }页面层保留加载态和错误态,但不自行拼接持久化键。只要作用域逻辑集中在 Store 内部,将来调整键格式或增加迁移版本时,就不需要逐页修改。
七、五条失败路径决定隔离是否可靠
| 场景 | 容易出现的错误 | 防护策略 | 可见结果 |
|---|---|---|---|
| token 已过期 | 页面仍显示已连接 | 读取 token 时统一校验绝对时间 | 回到未登录态 |
| 账号 A 退出、B 登录 | A 的 AppStorage 残留 | 先清运行期镜像再恢复 B | B 只看到自己的数据 |
| 用户修改昵称 | 本地作用域变化 | 新作用域只使用稳定 userId | 数据保持不变 |
| 旧版本无作用域数据 | 升级后历史为空 | 按顺序读取并迁移旧键 | 首次启动完成兼容 |
| Preferences 数据损坏 | JSON 解析中断 | 捕获异常并使用空模型 | 页面可继续进入 |
还要警惕“退出即删除全部本地数据”的过度清理。多数情况下退出只是断开云端身份,用户再次登录仍希望恢复自己的离线对局。真正的本地清除应该是独立、明确、带确认的设置操作。
八、怎样验证没有串号
验收至少准备两个测试账号和一份未登录数据。账号 A 创建对局并设为当前场次,退出后确认页面先变为空;账号 B 登录,创建不同名称的对局,再次切回 A,A 的列表和当前场次应恢复。随后修改 A 的昵称,确认数据仍归属于同一 userId。
冷启动验证同样必要:分别在 A、B 登录状态下结束进程并重启,确认认证过期判断、作用域恢复和页面刷新顺序一致。再构造一个已经过期的绝对时间,应用应拒绝返回 token,而不是等服务端第一次报错才改变界面。
Preferences 的具体能力和约束应以 HarmonyOS ArkData Preferences 官方指南 为准。账号隔离设计还应结合目标 SDK 的数据保护要求和服务端鉴权策略复核。
九、总结
账号认证后的数据作用域是一条完整链路:认证会话提供稳定 userId,业务 Store 用它生成键空间,账号切换先清运行期镜像,再从目标作用域恢复,页面只消费恢复后的可观察状态。昵称、头像和页面文案都不能代替稳定身份。
这套实现让本地离线与云端账号可以共存:未登录用户继续使用默认分区,登录用户拥有独立缓存,旧版本数据通过幂等迁移进入新键。它不能替代服务端权限控制,却能把同机多账号最常见的串数据问题挡在 UI 出现之前。