Repository Wiki
BeyondDimension/SteamTools

身份与用户体系

客户端本地身份与用户体系负责维护"当前登录用户"的会话状态(含 JWT 令牌)、用户资料的内存缓存与本地加密持久化,并通过 IUserManager 统一对外提供读写、登出与手机号脱敏等能力。

目的与范围(Purpose and Scope)

本页覆盖 SteamTools 客户端中身份与用户体系的本地运行时机制,包括:

  • 会话模型 CurrentUser(UserId、AuthToken、ShopAuthToken、PhoneNumber)及其序列化契约;
  • 服务契约 IUserManager 与其实现 UserManager(懒加载、匿名态标记、克隆读取、登出事件);
  • 本地用户实体 User(SQLite 表 D5428AED)与 IUserRepository/UserRepository 持久化路径;
  • 敏感字段(昵称、用户资料)经 ISecurityService 的加解密绑定与完整性校验(VerifyUserInfoAsync);
  • 会话在 ISecureStorage 中的持久化键 KEY_CURRENT_LOGIN_USER。

不属于本页(由兄弟页面承接):

  • 网络侧认证流程(登录/注册/OAuth 授权请求的 HTTP 交互)——见认证与授权相关页面;
  • JWT 令牌的生成、刷新与解析细节(JWTEntity 内部结构);
  • Steam 平台账号切换、游戏账号管理(Plugins.GameAccount)等插件级账号能力。

概述(Overview)

SteamTools 是一款多平台客户端应用,其身份体系被设计为两层结构:

层模型存储生命周期
会话层CurrentUserISecureStorage(键:KEY_CURRENT_LOGIN_USER)随登录/登出变更,跨进程重启
资料层User(实体)+ IdentityUserInfoDTO本地 SQLite(表 D5428AED)持久化,字段加密

设计动机(WHY):

  1. 会话与资料分离:令牌(AuthToken/ShopAuthToken)属于敏感凭据,必须放在安全存储(ISecureStorage,各平台映射到 Keychain/Keystore/ProtectedData 等);而昵称、头像等资料可放本地数据库以便离线读取与批量查询。两者分开后,登出只需清空会话,不必删除资料缓存。
  2. 脱敏防泄漏:User 实体中 NickName 与 UserInfo 均为 byte[](密文),只有经 ISecurityService.D() 解密后才得到明文;IUserManager.GetCurrentUserPhoneNumberAsync 默认隐藏手机号中间四位。这保证即使本地数据库文件被拖走,也无法直接还原用户敏感信息。
  3. 防结构探测:SQLite 表名与列名使用十六进制常量(表 D5428AED,列 5E72F0AE 等),避免明文 schema 被轻易识别。
  4. 状态一致性:isAnonymous 标志与 currentUser 成对维护,区分"尚未从存储加载"与"确认无登录用户"两种 null 语义,避免每次读取都穿透安全存储。

总体架构(Architecture)

Loading diagram...

各组件职责与连接理由:

  • UserService(ReactiveObject):MVVM 层对身份状态的响应式封装,UI 通过它绑定登录态变化(本页不展开其绑定细节)。
  • IUserManager:对外契约,继承自 IAuthHelper(令牌访问),并提供 static IUserManager Instance => Ioc.Get<IUserManager>() 服务定位入口,方便静态上下文调用。
  • UserManager:核心实现,聚合四个依赖——ISecureStorage(会话持久化)、IUserRepository(资料持久化)、ISecurityService(加解密)、ILogger(日志)。
  • UserRepository:继承通用仓储 Repository<User, Guid>,封装对 SQLite 表 D5428AED 的 CRUD。
  • CurrentUser:纯数据会话模型,通过 MessagePack(MPObj/MP2Obj)显式键 0–3 序列化,兼容 Newtonsoft.Json 与 System.Text.Json 双 Json 栈。

数据模型关系

Loading diagram...

两个模型字段语义对比:

字段CurrentUser(会话)User(实体)
标识UserId: GuidId: Guid(主键,列 5E72F0AE)
昵称无(在 IdentityUserInfoDTO 中)NickName: byte[]?(密文,列 A931B798)
头像无Avatar: Guid?(资源 Id,列 4C12F9EE)
令牌AuthToken / ShopAuthToken(JWTEntity?)无(令牌不入库)
手机号PhoneNumber: string?无
扩展资料无UserInfo: byte[]?(密文 IdentityUserInfoDTO,列 654D00DA)

CurrentUser 使用 [MPKey(n), MP2Key(n)] 显式整数键(0–3)而非属性名映射,原因是:安全存储中的会话数据需要紧凑且稳定的二进制布局,未来新增字段(键 4、5…)不会破坏旧数据反序列化;同时通过 [N_JsonProperty("n")]/[S_JsonProperty("n")] 保证两套 Json 序列化器输出一致的属性名。源码注释明确要求:"如需增加字段,还需要在 Clone 中赋值新添加字段"。

核心流程(Core Flow)

登录态读取(懒加载 + 克隆)

UserManager.GetCurrentUserAsync(bool clone) 是整个身份体系最核心的读取路径:

csharp
1protected async ValueTask<CurrentUser?> GetCurrentUserAsync(bool clone) 2{ 3 if (currentUser == null && !isAnonymous) 4 { 5 try 6 { 7 CurrentUser = await storage.GetAsync<CurrentUser>(KEY_CURRENT_LOGIN_USER); 8 } 9 catch (Exception e) 10 { 11 logger.LogError(e, nameof(GetCurrentUserAsync)); 12 } 13 PrintCurrentUser(nameof(GetCurrentUserAsync)); 14 } 15 return clone ? currentUser?.Clone() : currentUser; 16}

Source: UserManager.cs

关键设计点:

  1. 条件 currentUser == null && !isAnonymous:仅当内存缓存为空且尚未确认匿名态时才穿透到 ISecureStorage。首次加载后,若无登录用户则 isAnonymous = true,后续调用直接返回 null,不再反复访问安全存储(安全存储通常是平台原生 API,开销高于内存)。
  2. 异常吞噬:安全存储读取失败(如平台 Keychain 异常)不会抛给调用方,只记录日志并返回 null——会话损坏时按"未登录"降级,保证应用可用性优先。
  3. clone 开关:对外暴露的 GetCurrentUserAsync() 恒传 true,返回 currentUser?.Clone() 的深拷贝;而 GetAuthTokenAsync()/GetShopAuthTokenAsync() 这类内部读取传 false 直接拿引用,避免不必要的克隆开销。

同步入口 GetCurrentUser() 在 DEBUG 构建下还内置了随机化缓存行为:

csharp
1public CurrentUser? GetCurrentUser() 2{ 3 var hasCurrentUser = currentUser != null; 4#if DEBUG 5 var read_cache = Random2.Next(100) % 2 == 0; 6 hasCurrentUser = read_cache && hasCurrentUser; 7#endif 8 ...

Source: UserManager.cs

这是有意为之的测试注入:DEBUG 下 50% 概率强制走 func.RunSync()(同步等待懒加载完成)路径,从而让"缓存命中"与"同步穿透加载"两条分支都能在开发/测试中被持续覆盖,防止缓存路径长期未被验证而隐藏缺陷。

登录写入与资料级联

csharp
1public async Task SetCurrentUserAsync(CurrentUser? value) 2{ 3 await storage.SetAsync(KEY_CURRENT_LOGIN_USER, value); 4 CurrentUser = value; 5 PrintCurrentUser("SetCurrentUser"); 6}

Source: UserManager.cs

写入顺序是先持久化、后更新内存:若 SetAsync 抛出异常,内存态保持不变,避免出现"内存已登录但磁盘未落盘"的分裂状态。CurrentUser 属性 setter 同时把 isAnonymous = value == null 置位,因此传 null 即代表登出语义,一次调用同时完成持久化清理与内存态翻转。

资料层的写入则多一个可选的落库开关:

csharp
1public async Task SetCurrentUserInfoAsync(IdentityUserInfoDTO value, bool updateToDataBase) 2{ 3 currentUserInfo = value; 4 if (updateToDataBase) 5 { 6 await InsertOrUpdateAsync(value); 7 } 8}

Source: UserManager.cs

updateToDataBase 允许调用方区分"服务端拉取后仅刷新内存展示"与"需要写回本地数据库供离线复用"两种场景——资料展示频繁而落库昂贵,默认走内存即可保证 UI 响应速度。

端到端时序

Loading diagram...

登出流程

csharp
1public event Action? OnSignOut; 2 3public async Task SignOutAsync() 4{ 5 PrintCurrentUser("SignOut"); 6 currentUserInfo = default; 7 await SetCurrentUserAsync(null); 8 OnSignOut?.Invoke(); 9}

Source: UserManager.cs

登出分三步:先清空资料缓存 currentUserInfo(令其立即失效,避免登出后仍可读到资料),再通过 SetCurrentUserAsync(null) 同时清空安全存储与 currentUser,最后触发 OnSignOut 事件通知订阅方(如清空其他业务缓存、重置导航)。事件放在持久化之后触发,保证订阅方收到事件时全局登录态已经一致地处于匿名态。

实现剖析(Implementation Details)

加密资料绑定与完整性校验

User 实体中的 NickName、UserInfo 都是密文 byte[]。读取时通过泛型绑定方法解密为 DTO:

csharp
1async Task<TUserDTO?> BindingUserAsync<TUserDTO>(User user) where TUserDTO : IUserDTO, new() 2{ 3 var nickName = await security.D(user.NickName); 4 5 var value = new TUserDTO 6 { 7 Id = user.Id, 8 NickName = nickName ?? string.Empty, 9 Avatar = user.Avatar ?? default, 10 }; 11 return value; 12}

Source: UserManager.cs

security.D() 即解密(Decrypt),NickName ?? string.Empty 兜底保证解密失败时 DTO 字段不为 null。写入方向则存在对应的 VerifyUserInfoAsync 三重校验:

csharp
1async Task<bool> VerifyUserInfoAsync(User user, IdentityUserInfoDTO userInfo) 2{ 3 if (user.Id != userInfo.Id) { logger.LogError("VerifyUserInfo Fail(Id)."); return false; } 4 var nickName = await security.D(user.NickName) ?? string.Empty; 5 if (nickName != userInfo.NickName) { logger.LogError("VerifyUserInfo Fail(NickName)."); return false; } 6 if (user.Avatar != userInfo.Avatar) { logger.LogError("VerifyUserInfo Fail(Avatar)."); return false; } 7 return true; 8}

Source: UserManager.cs

该校验在更新用户数据(InsertOrUpdateAsync)前比对"库中已有数据"与"即将写入数据"的 Id/昵称/头像,任一不一致即拒绝并写错误日志——这是一种防串号写库的守门机制:如果另一个账号的资料被错误地传入当前写库流程(例如多账号切换竞态),校验会在此处拦截,避免数据库行被错误数据覆盖。

BindingUserInfoAsync 则进一步处理 User.UserInfo 密文列:当实体上的 UserInfo 非空时解密并反序列化为完整 IdentityUserInfoDTO,配合 GetUserByTableAsync<TUserDTO> 的空值短路(if (user == null) return default;)构成完整的实体→DTO 管道。

手机号脱敏

csharp
1async Task<string> GetCurrentUserPhoneNumberAsync(bool notHideMiddleFour = false) 2{ 3 var phone_number = (await GetCurrentUserAsync())?.PhoneNumber; 4 if (string.IsNullOrWhiteSpace(phone_number)) return string.Empty; 5 return notHideMiddleFour ? phone_number : PhoneNumberHelper.ToStringHideMiddleFour(phone_number); 6}

Source: IUserManager.cs

作为接口默认方法(C# default interface method)直接写在 IUserManager 上,供所有实现复用。默认隐藏中间四位,必须显式传 notHideMiddleFour: true 才能拿完整号码——"最小暴露"作为默认值,倒置了安全选项的取舍方向:UI 展示(大多数场景)天然安全,真正需要完整号码的支付/验证场景才显式申请。

CurrentUser 的显式序列化与克隆

csharp
1[MPObj, MP2Obj(SerializeLayout.Explicit)] 2public sealed partial class CurrentUser : IExplicitHasValue, IPhoneNumber 3{ 4 [MPKey(0), MP2Key(0)] 5 [N_JsonProperty("0")] 6 [S_JsonProperty("0")] 7 public Guid UserId { get; set; } 8 9 [MPKey(1), MP2Key(1)] 10 [N_JsonProperty("1")] 11 [S_JsonProperty("1")] 12 public JWTEntity? AuthToken { get; set; } 13 ... 14 bool IExplicitHasValue.ExplicitHasValue() => AuthToken.HasValue(); 15 16 public CurrentUser? Clone() => this.HasValue() ? 17 new() 18 { 19 UserId = UserId, 20 AuthToken = AuthToken, 21 PhoneNumber = PhoneNumber, 22 ShopAuthToken = ShopAuthToken, 23 } : null; 24}

Source: CurrentUser.cs

三重设计意图:

  • IExplicitHasValue.ExplicitHasValue() 以 AuthToken.HasValue() 作为"存在登录会话"的唯一判据——令牌即会话,令牌失效则整个对象视为无效,Clone() 对无效对象返回 null 而非残缺拷贝;
  • Clone() 手工逐字段复制(而非反射/ MemberwiseClone),配合源码注释"如需增加字段,还需要在 Clone 中赋值新添加字段",把"忘记克隆新字段"这类隐蔽 bug 提前到编码期显式暴露;
  • Clone() 在 this.HasValue() 为假时返回 null,使"无效会话"在克隆边界被归一化,调用方拿到的要么是完整有效对象、要么是 null,不存在中间态。

使用示例(Usage Examples)

通过服务定位访问当前用户

IUserManager 内置静态 Instance 属性,静态上下文(如 ValueConverter、推送处理)可直接使用:

csharp
1public interface IUserManager : IAuthHelper 2{ 3 /// <summary> 4 /// 当前登录用户 5 /// </summary> 6 protected const string KEY_CURRENT_LOGIN_USER = "KEY_CURRENT_LOGIN_USER"; 7 8 static IUserManager Instance => Ioc.Get<IUserManager>(); 9 10 /// <inheritdoc cref="GetCurrentUserAsync"/> 11 CurrentUser? GetCurrentUser(); 12 13 /// <summary> 14 /// 获取当前登录用户 15 /// <para>如果[退出登录]则为 <see langword="null"/>,对于接收到的推送消息,要求在服务端时传入接收人用户Id,客户端根据Id读取用户信息,而不使用此值</para> 16 /// </summary> 17 ValueTask<CurrentUser?> GetCurrentUserAsync(); 18}

Source: IUserManager.cs

注意接口 XML 注释中的约定:推送消息场景禁止使用 GetCurrentUserAsync() 判定接收人,必须使用服务端下发的接收人 Id 调 GetUserInfoByIdAsync(userId) 查询——因为多端/多账号场景下本地"当前用户"与服务端推送目标可能不一致,此约定防止把消息归档到错误账号。

令牌读取(IAuthHelper 侧能力)

csharp
1public async ValueTask<JWTEntity?> GetAuthTokenAsync() 2{ 3 var value = await GetCurrentUserAsync(false); 4 return value?.AuthToken; 5} 6 7public async ValueTask<JWTEntity?> GetShopAuthTokenAsync() 8{ 9 var value = await GetCurrentUserAsync(false); 10 return value?.ShopAuthToken; 11}

Source: UserManager.cs

两个令牌分属主站(AuthToken)与商店(ShopAuthToken)双业务域;读取时传 clone: false 复用内部实例——令牌仅被读取不回写,克隆是多余的深拷贝开销,此处在防御性与性能之间做了取舍。

持久化实体定义

csharp
1[SQLiteTable("D5428AED")] 2[DebuggerDisplay("{DebuggerDisplay,nq}")] 3public sealed class User : IEntity<Guid> 4{ 5 string DebuggerDisplay => Id.ToString(); 6 7 [Column("5E72F0AE")] 8 [PrimaryKey] 9 public Guid Id { get; set; } 10 11 [Column("A931B798")] 12 public byte[]? NickName { get; set; } 13 14 [Column("4C12F9EE")] 15 public Guid? Avatar { get; set; } 16 17 [Column("654D00DA")] 18 public byte[]? UserInfo { get; set; } 19}

Source: User.cs

Avatar 存 Guid? 而非图片数据本身——头像二进制由独立的资源/文件存储管理,实体只持有资源 Id,保持用户表行级数据精简;?? default 兜底(见 BindingUserAsync)则把 null 归一为 Guid.Empty 供 DTO 消费。

配置与常量(Configuration Options)

本体系没有外部可配置项(无 appsettings 键),全部为代码内常量约定:

常量类型值位置说明
KEY_CURRENT_LOGIN_USERconst string"KEY_CURRENT_LOGIN_USER"IUserManager(protected)安全存储中当前登录会话的唯一持久化键,实现类通过 using static BD.WTTS.Services.IUserManager; 引用
SQLite 表名特性值"D5428AED"[SQLiteTable] on User用户资料表
Id 列名特性值"5E72F0AE"[Column] + [PrimaryKey]主键 Guid
NickName 列名特性值"A931B798"[Column]加密昵称(密文 byte[])
Avatar 列名特性值"4C12F9EE"[Column]头像资源 Id
UserInfo 列名特性值"654D00DA"[Column]加密的完整资料(密文 byte[])
MessagePack 键MPKey/MP2Key0–3CurrentUser 各属性会话对象二进制序列化显式键

API 参考(API Reference)

IUserManager.GetCurrentUser(): CurrentUser?

同步获取当前登录用户(深拷贝)。内部必要时通过 RunSync() 同步等待异步懒加载完成。

返回:当前用户克隆;未登录返回 null。DEBUG 构建下会随机(50%)绕过内存缓存强制走同步加载路径以覆盖两分支。

IUserManager.GetCurrentUserAsync(): ValueTask<CurrentUser?>

异步获取当前登录用户。首次调用触发安全存储读取并缓存;异常被吞掉并记录日志,返回 null 表示未登录或会话损坏。

IUserManager.SetCurrentUserAsync(value: CurrentUser?): Task

设置当前登录用户;传 null 即登出(持久化与内存态同时清空)。先落盘后更新内存。

IUserManager.GetCurrentUserInfoAsync(): ValueTask<IdentityUserInfoDTO?>

获取当前用户资料。缓存未命中时依次:读会话 → userRepository.FindAsync(UserId) → 解密绑定 → 缓存返回。

IUserManager.SetCurrentUserInfoAsync(value, updateToDataBase): Task

参数类型说明
valueIdentityUserInfoDTO新资料
updateToDataBasebooltrue 时经 InsertOrUpdateAsync 写回 SQLite

IUserManager.GetUserInfoByIdAsync(userId: Guid): Task<IdentityUserInfoDTO?>

按 Id 查询本地用户资料(推送消息接收人查询的唯一合规入口)。

IUserManager.InsertOrUpdateAsync(user: IUserDTO): Task

添加或更新用户数据到本地数据库。

IUserManager.GetCurrentUserPhoneNumberAsync(notHideMiddleFour: bool = false): Task<string>

获取当前用户手机号,默认经 PhoneNumberHelper.ToStringHideMiddleFour 脱敏;空号返回 string.Empty。

IUserManager.OnSignOut: event Action?

登出完成(含持久化清理)后广播的事件,订阅方应在此时清理各自的账号相关缓存。

UserManager.SignOutAsync(): Task

清空资料缓存 → 清空会话(存储+内存)→ 触发 OnSignOut。

失败模式、边界与并发(Failure Modes & Concurrency)

场景行为依据
安全存储读取抛异常捕获、记日志、返回 null(按未登录降级),应用不崩溃GetCurrentUserAsync 的 try/catch
解密昵称失败security.D() 返回 null,DTO 中兜底为 string.EmptyBindingUserAsync 的 ?? string.Empty
CurrentUser 缺令牌ExplicitHasValue() 为假;Clone() 返回 nullCurrentUser.cs L30–L43
写库前 Id/昵称/头像不一致VerifyUserInfoAsync 拒绝写库并记 Error 日志三重比对守门
GetCurrentUser() 在 UI 线程同步调用通过 RunSync() 阻塞等待异步加载完成Func<ValueTask<CurrentUser?>>.RunSync()
推送消息误用当前用户接口文档显式禁止,必须用 GetUserInfoByIdAsync(服务端下发 Id)IUserManager XML 注释

并发说明:currentUser/currentUserInfo/isAnonymous 为普通实例字段,无锁保护。设计上的缓解手段是写时克隆 + 读返回克隆:外部拿到的永远是快照副本,外部修改不会污染内部状态;反之内部被 SetCurrentUserAsync 替换后,旧引用仍是旧快照。仍需注意:异步懒加载期间多次并发调用可能重复触发一次存储读取(无信号量去重),该冗余读取是性能上可接受、语义上幂等的(最终都赋同一个值)。

边界条件:isAnonymous 标志一旦置位,在下次成功登录(SetCurrentUserAsync 传入非空值)前不会再尝试读存储——这意味着如果外部直接篡改安全存储写入会话,运行中的进程不会感知,需重启进程才会加载新会话。这是刻意用"进程内会话不可变"换取读取零开销的取舍。

性能与运维(Performance & Operational Notes)

  • 热路径零分配倾向:令牌读取(GetAuthTokenAsync)传 clone: false;只有面向外部的 GetCurrentUserAsync() 才克隆。克隆虽是手工 new,但携带 JWTEntity 引用复制,仍属轻量。
  • 懒加载单次穿透:ISecureStorage.GetAsync 只在进程生命周期内最多发生一次(非匿名态下),后续全部命中内存字段。
  • DEBUG 日志脱敏:PrintCurrentUser 使用 ToStringHideMiddleFour() 打印手机号,[Conditional("DEBUG")] 保证发布版零开销。
  • 日志 Tag:protected const string TAG = "UserManager",所有实例日志共用同一 Tag,便于按组件过滤。
  • 运维要点:用户反馈"登录态丢失"时,排查顺序应为:① 安全存储是否被系统清理(App 卸载重装/系统还原/平台凭据重置);② GetCurrentUserAsync 是否记录了 LogError(会话反序列化失败);③ 是否 SignOutAsync 被意外调用(检查 OnSignOut 订阅方日志)。

扩展点(Extension Points)

  1. 新增会话字段:在 CurrentUser 上按显式键递增(MPKey(4)…),同时必须在 Clone() 中补齐该字段赋值(源码注释强制要求),否则克隆快照会静默丢失新字段。
  2. 新增用户资料字段:扩展 IdentityUserInfoDTO,密文整体存于 User.UserInfo 列,无需改表结构(byte[] 自包含反序列化)。
  3. 响应登录态变化:订阅 OnSignOut 事件,在登出后清理业务侧缓存/重置 UI 状态;登录成功则通过 SetCurrentUserAsync + SetCurrentUserInfoAsync 组合完成。
  4. 替换实现:IUserManager 通过 IoC 注册(ServiceCollectionExtensions.TryAddUserManager.cs 提供注册扩展),测试环境可注入替身以模拟匿名/已登录状态。

Sources

(4 files)
src/BD.WTTS.Client/Entities
src/BD.WTTS.Client/Models/Identity
src/BD.WTTS.Client/Services.Implementation/Identity
src/BD.WTTS.Client/Services/Identity