身份与用户体系
客户端本地身份与用户体系负责维护"当前登录用户"的会话状态(含 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 是一款多平台客户端应用,其身份体系被设计为两层结构:
| 层 | 模型 | 存储 | 生命周期 |
|---|---|---|---|
| 会话层 | CurrentUser | ISecureStorage(键:KEY_CURRENT_LOGIN_USER) | 随登录/登出变更,跨进程重启 |
| 资料层 | User(实体)+ IdentityUserInfoDTO | 本地 SQLite(表 D5428AED) | 持久化,字段加密 |
设计动机(WHY):
- 会话与资料分离:令牌(
AuthToken/ShopAuthToken)属于敏感凭据,必须放在安全存储(ISecureStorage,各平台映射到 Keychain/Keystore/ProtectedData 等);而昵称、头像等资料可放本地数据库以便离线读取与批量查询。两者分开后,登出只需清空会话,不必删除资料缓存。 - 脱敏防泄漏:
User实体中NickName与UserInfo均为byte[](密文),只有经ISecurityService.D()解密后才得到明文;IUserManager.GetCurrentUserPhoneNumberAsync默认隐藏手机号中间四位。这保证即使本地数据库文件被拖走,也无法直接还原用户敏感信息。 - 防结构探测:SQLite 表名与列名使用十六进制常量(表
D5428AED,列5E72F0AE等),避免明文 schema 被轻易识别。 - 状态一致性:
isAnonymous标志与currentUser成对维护,区分"尚未从存储加载"与"确认无登录用户"两种null语义,避免每次读取都穿透安全存储。
总体架构(Architecture)
各组件职责与连接理由:
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 栈。
数据模型关系
两个模型字段语义对比:
| 字段 | CurrentUser(会话) | User(实体) |
|---|---|---|
| 标识 | UserId: Guid | Id: 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) 是整个身份体系最核心的读取路径:
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
关键设计点:
- 条件
currentUser == null && !isAnonymous:仅当内存缓存为空且尚未确认匿名态时才穿透到ISecureStorage。首次加载后,若无登录用户则isAnonymous = true,后续调用直接返回null,不再反复访问安全存储(安全存储通常是平台原生 API,开销高于内存)。 - 异常吞噬:安全存储读取失败(如平台 Keychain 异常)不会抛给调用方,只记录日志并返回
null——会话损坏时按"未登录"降级,保证应用可用性优先。 clone开关:对外暴露的GetCurrentUserAsync()恒传true,返回currentUser?.Clone()的深拷贝;而GetAuthTokenAsync()/GetShopAuthTokenAsync()这类内部读取传false直接拿引用,避免不必要的克隆开销。
同步入口 GetCurrentUser() 在 DEBUG 构建下还内置了随机化缓存行为:
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()(同步等待懒加载完成)路径,从而让"缓存命中"与"同步穿透加载"两条分支都能在开发/测试中被持续覆盖,防止缓存路径长期未被验证而隐藏缺陷。
登录写入与资料级联
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 即代表登出语义,一次调用同时完成持久化清理与内存态翻转。
资料层的写入则多一个可选的落库开关:
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 响应速度。
端到端时序
登出流程
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:
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 三重校验:
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 管道。
手机号脱敏
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 的显式序列化与克隆
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、推送处理)可直接使用:
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 侧能力)
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 复用内部实例——令牌仅被读取不回写,克隆是多余的深拷贝开销,此处在防御性与性能之间做了取舍。
持久化实体定义
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_USER | const 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/MP2Key | 0–3 | CurrentUser 各属性 | 会话对象二进制序列化显式键 |
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
| 参数 | 类型 | 说明 |
|---|---|---|
value | IdentityUserInfoDTO | 新资料 |
updateToDataBase | bool | true 时经 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.Empty | BindingUserAsync 的 ?? string.Empty |
CurrentUser 缺令牌 | ExplicitHasValue() 为假;Clone() 返回 null | CurrentUser.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)
- 新增会话字段:在
CurrentUser上按显式键递增(MPKey(4)…),同时必须在Clone()中补齐该字段赋值(源码注释强制要求),否则克隆快照会静默丢失新字段。 - 新增用户资料字段:扩展
IdentityUserInfoDTO,密文整体存于User.UserInfo列,无需改表结构(byte[]自包含反序列化)。 - 响应登录态变化:订阅
OnSignOut事件,在登出后清理业务侧缓存/重置 UI 状态;登录成功则通过SetCurrentUserAsync+SetCurrentUserInfoAsync组合完成。 - 替换实现:
IUserManager通过 IoC 注册(ServiceCollectionExtensions.TryAddUserManager.cs提供注册扩展),测试环境可注入替身以模拟匿名/已登录状态。
相关链接(Related Links)
- IUserManager.cs — 服务契约与默认方法
- UserManager.cs — 核心实现
- CurrentUser.cs — 会话模型
- User.cs — SQLite 持久化实体
- IUserRepository.cs — 仓储抽象
- UserRepository.cs — 仓储实现
- UserService.cs — MVVM 层响应式封装
- ServiceCollectionExtensions.TryAddUserManager.cs — IoC 注册扩展