HarmonyOS 登录态治理实战:授权码、会话、登出与隐私边界

登录功能看起来只是一个按钮,真正上线后却会牵出一串问题:用户换设备后会话不同步,退出登录后消息页还显示头像,授权范围过大导致隐私提示难解释,Token 过期后页面反复跳登录。登录态治理要先把“华为账号授权”“应用服务端会话”“本地用户资料缓存”“模块登出响应”拆开,而不是把所有逻辑写成一个 isLogin
请添加图片描述

本文围绕四个结果展开:

  1. 授权请求只申请当前业务需要的范围。
  2. 客户端不把授权码、访问凭据和用户资料混在一起存。
  3. Token 失效、账号切换、用户登出都有统一事件。
  4. 隐私资料读取前有明确边界,便于审核和用户解释。

1. 登录态先拆成四层

很多应用登录混乱,是因为把平台授权结果当成应用会话,把用户头像昵称当成登录凭据。更稳的拆法是四层:授权层负责拿授权码,会话层负责应用服务端换票,资料层保存展示字段,事件层通知业务模块清理状态。

请添加图片描述

层级 保存内容 风险点
授权层 授权码、授权结果、错误码 授权码不应长期保存
会话层 应用自己的 sessionId、过期时间 失效后要能刷新或退登
资料层 头像、昵称、账号绑定状态 不要当作安全凭据
事件层 登录成功、会话过期、用户登出 各模块必须响应清理

这样拆完,页面问的是“当前会话是否有效”,资料页问的是“资料缓存是否可展示”,订单页问的是“会话失效后如何恢复”。每个问题都有对应模块,不会互相抢职责。

2. Account Kit 资料边界和工程目录

Account Kit 提供华为账号登录、授权、用户信息获取、取消授权等能力。工程落地时,建议以官方登录能力作为入口,但应用自己的用户体系仍由服务端维护。

资料入口 工程落点
Account Kit 简介 明确账号登录、授权和用户信息能力边界
华为账号一键登录 登录页发起授权,不直接生成业务会话
获取用户信息 只在用户授权范围内读取资料
取消授权 用户解绑或注销时同步撤销授权

建议目录:

entry/src/main/ets/
  common/account/AuthRequest.ets
  common/account/SessionStore.ets
  common/account/AccountProfileStore.ets
  common/account/LogoutBus.ets
  common/account/PrivacyGate.ets
  pages/account/LoginPage.ets

登录页只发起动作,SessionStore 管会话,LogoutBus 通知其他模块,隐私资料读取必须经过 PrivacyGate

3. AuthRequest 封装授权请求

授权请求需要包含来源、业务场景和授权范围。不要在所有入口都申请同一组权限;比如只为了收藏文章,不一定需要读取头像和昵称。

export type AuthScene = 'login_page' | 'checkout_required' | 'profile_bind'

export interface AuthRequestOptions {
  scene: AuthScene
  scopes: string[]
  traceId: string
}

export class AuthRequestBuilder {
  build(scene: AuthScene): AuthRequestOptions {
    const baseScopes = ['openid']
    const profileScopes = scene === 'profile_bind' ? ['profile'] : []
    return {
      scene,
      scopes: [...baseScopes, ...profileScopes],
      traceId: `auth_${Date.now()}_${Math.floor(Math.random() * 10000)}`
    }
  }
}

这段代码的重点是最小化授权范围。用户只是为了下单而登录时,先拿到应用会话即可;需要完善资料时,再解释为什么读取头像昵称。

4. 授权结果不要直接变成应用会话

客户端拿到授权码后,应交给服务端换取应用自己的会话。这样服务端可以完成账号绑定、风控、设备识别和会话过期策略。

export interface PlatformAuthResult {
  code?: string
  errorCode?: string
  errorMessage?: string
}

export interface AppSessionDTO {
  sessionId: string
  accountId: string
  expiredAt: number
}

export class SessionExchangeService {
  async exchange(auth: PlatformAuthResult): Promise<AppSessionDTO> {
    if (!auth.code) {
      throw new Error(`授权失败:${auth.errorCode ?? 'UNKNOWN'}`)
    }
    // 实际项目中这里请求应用服务端,由服务端校验授权码并签发应用会话。
    return {
      sessionId: `sid_${auth.code.slice(0, 8)}`,
      accountId: 'account_10001',
      expiredAt: Date.now() + 2 * 60 * 60 * 1000
    }
  }
}

客户端不要把授权码当作长期凭据。授权码只用于换取应用会话,换票完成后应清理临时值。

5. SessionStore 只保存会话摘要

本地会话存储要克制。能由服务端判断的,不要在客户端长期保存;必须保存的,也要带上过期时间和刷新时机。

export type SessionState = 'ANONYMOUS' | 'ACTIVE' | 'EXPIRED' | 'REFRESHING'

export interface SessionSnapshot {
  state: SessionState
  sessionId?: string
  accountId?: string
  expiredAt?: number
}

export class SessionStore {
  private current: SessionSnapshot = { state: 'ANONYMOUS' }

  save(dto: AppSessionDTO): void {
    this.current = {
      state: 'ACTIVE',
      sessionId: dto.sessionId,
      accountId: dto.accountId,
      expiredAt: dto.expiredAt
    }
  }

  getSnapshot(): SessionSnapshot {
    if (this.current.expiredAt && Date.now() >= this.current.expiredAt) {
      return { ...this.current, state: 'EXPIRED' }
    }
    return { ...this.current }
  }

  clear(): void {
    this.current = { state: 'ANONYMOUS' }
  }
}

页面不要直接读取底层持久化,而是读取快照。这样 Token 过期时,页面能展示“登录已过期,请重新登录”,不是直接请求失败。

6. LogoutBus 让各模块同步退登

用户退出登录时,订单、消息、会员、缓存都要响应。最危险的做法是只清理登录页状态,其他页面仍然拿旧账号展示数据。

请添加图片描述

export type LogoutReason = 'user_click' | 'session_expired' | 'account_switch' | 'privacy_revoke'

export interface LogoutEvent {
  reason: LogoutReason
  occurredAt: number
}

export class LogoutBus {
  private listeners: Array<(event: LogoutEvent) => void> = []

  subscribe(listener: (event: LogoutEvent) => void): void {
    this.listeners.push(listener)
  }

  publish(reason: LogoutReason): void {
    const event: LogoutEvent = { reason, occurredAt: Date.now() }
    this.listeners.forEach(listener => listener(event))
  }
}

业务模块订阅这个事件后,应清理自己的账号缓存。例如消息模块清空未读数,订单模块重新拉取匿名状态,会员模块刷新权益快照。

7. PrivacyGate 控制资料读取

用户资料不是登录凭据,也不是每个页面都能随便读。读取头像、昵称、手机号、绑定状态前,要明确业务目的和授权状态。

export type ProfileField = 'avatar' | 'nickname' | 'phoneMask' | 'bindStatus'

export interface ProfileReadRequest {
  field: ProfileField
  scene: 'profile_page' | 'comment_box' | 'customer_service'
}

export class PrivacyGate {
  canRead(req: ProfileReadRequest, session: SessionSnapshot): boolean {
    if (session.state !== 'ACTIVE') {
      return false
    }
    if (req.field === 'phoneMask' && req.scene !== 'customer_service') {
      return false
    }
    return true
  }
}

这段逻辑可以帮助通过隐私说明审查。资料读取要能解释“为什么读、在哪展示、用户退出后如何清理”。

8. 登录页状态要给用户明确反馈

登录页不要只有成功失败。授权取消、网络异常、服务端换票失败、会话过期,都应该给不同文案。

export function buildLoginMessage(state: SessionState, errorCode?: string): string {
  if (state === 'ACTIVE') {
    return '登录成功,正在同步账号数据'
  }
  if (state === 'REFRESHING') {
    return '正在恢复登录状态,请稍候'
  }
  if (state === 'EXPIRED') {
    return '登录状态已过期,请重新登录'
  }
  if (errorCode === 'USER_CANCEL') {
    return '你已取消授权,可稍后重新登录'
  }
  return '请使用华为账号登录以同步数据'
}

可解释的登录状态能减少误会。尤其是用户主动取消授权时,不应弹出“系统错误”。

9. 登录链路验收动作

场景 操作 预期结果
首次登录 登录页发起账号授权 授权后由服务端签发应用会话
取消授权 用户在授权页返回 页面展示取消说明,不创建会话
会话过期 修改过期时间后进入订单页 触发重新登录或刷新,不展示旧数据
主动登出 点击退出登录 各业务模块收到事件并清理账号态
隐私撤销 取消授权或注销账号 本地资料缓存清空,页面不再展示头像昵称

可以在调试阶段加入状态保护:

export function assertSessionSafe(snapshot: SessionSnapshot): void {
  if (snapshot.state === 'ACTIVE' && (!snapshot.sessionId || !snapshot.accountId)) {
    throw new Error('有效会话必须包含 sessionId 和 accountId')
  }
  if (snapshot.state === 'ANONYMOUS' && snapshot.sessionId) {
    throw new Error('匿名态不能保留会话标识')
  }
}

这个断言可以提前发现退登后残留账号信息的问题。

10. 登录态异常排查表

现象 优先查看 处理建议
退出后仍显示头像 资料缓存、登出事件订阅 所有资料页统一响应 LogoutBus
频繁跳登录 会话过期时间、刷新接口 增加前台恢复和过期提示
授权失败文案不清楚 错误码映射 区分用户取消、网络失败、服务端换票失败
隐私说明难解释 资料读取场景 每个字段绑定业务用途
换账号后数据串号 accountId 和本地缓存键 缓存键必须包含账号维度

登录态复现场景:给读者一组可执行核验

登录态问题常发生在授权码过期、刷新失败和多端登出之间。补充这组核验可以帮助读者验证会话状态不会在页面间漂移。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface SessionReplayCase {
  sessionId: any
  refreshAt: any
  logoutAt: any
  source: any
}

const replay76: SessionReplayCase = {
  sessionId: 'sample',
  refreshAt: 'sample',
  logoutAt: 'sample',
  source: 'sample',
}

function assertReplay76(item: SessionReplayCase): void {
  if (item.logoutAt > 0 && item.refreshAt > item.logoutAt) throw new Error('登出后仍刷新会话')
}

这组核验覆盖会话刷新、登出和来源,能帮助读者定位登录态在页面切换中是否漂移。

11. 小结:登录不是一个布尔值

HarmonyOS 应用的登录态治理,关键是把平台授权、应用会话、资料缓存和登出事件拆开。客户端只保留必要摘要,敏感凭据交给服务端;业务页面不直接判断授权结果,而读取会话快照和资料权限。这样用户取消授权、Token 过期、切换设备、退出登录时,应用都能给出一致且可解释的表现。

Logo

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

更多推荐