b5ab92a57e
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
152 lines
7.3 KiB
Swift
152 lines
7.3 KiB
Swift
// 主 App 与键盘扩展共享的账户态存储(App Group UserDefaults)。
|
||
// 状态推导规则与 server/pkg/protocol/dto.go 注释一致:
|
||
// 无 token → guest;余额 > 0 → ok;余额 = 0 且试用未尽 → trial;皆尽 → quota。
|
||
// 14B:mock 种子已移除,首启即 guest;微信登录 applyLogin 写 token(键盘扩展立即可见),
|
||
// GET /v1/me 经 applyMe 刷新,退出登录 logout。
|
||
// 14D:WS usage 帧到达时经 applyUsage 实时回写 balanceSeconds / trialRemainingSeconds。
|
||
import Combine
|
||
import Foundation
|
||
|
||
/// 账户四态。ok/trial/quota 与 dto.go AccountOK/AccountTrial/AccountQuota 字面量对齐,
|
||
/// guest 为客户端本地态(无 token,服务端无此值)。
|
||
public enum AccountState: String, CaseIterable {
|
||
case ok, trial, guest, quota
|
||
}
|
||
|
||
public final class AccountStore: ObservableObject {
|
||
public static let appGroupID = "group.app.dudu.shared"
|
||
|
||
private enum Key {
|
||
static let token = "account.token" // 登录 token(AuthTokenResponse.token)
|
||
static let nickname = "account.nickname" // MeResponse.nickname_masked(服务端已脱敏)
|
||
static let balanceSeconds = "account.balance_seconds" // MeResponse.balance_seconds
|
||
static let trialRemaining = "account.trial_remaining" // trial_daily_limit - trial_used_today
|
||
static let trialDailyLimit = "account.trial_daily_limit"
|
||
static let kbFullAccess = "kb.full_access" // 键盘扩展每次出现时回写 hasFullAccess(引导第 2 步检测)
|
||
static let kbSeen = "kb.seen" // 键盘扩展至少打开过一次(引导第 4 步"试一试"检测)
|
||
}
|
||
|
||
private let defaults: UserDefaults
|
||
|
||
@Published public private(set) var token: String?
|
||
@Published public private(set) var nicknameMasked: String?
|
||
@Published public private(set) var balanceSeconds: Int
|
||
@Published public private(set) var trialRemainingSeconds: Int
|
||
@Published public private(set) var trialDailyLimitSeconds: Int
|
||
@Published public private(set) var keyboardFullAccess: Bool
|
||
@Published public private(set) var keyboardSeen: Bool
|
||
|
||
public init() {
|
||
// App Group 不可用时(无签名的本地调试场景)退回 standard,保证不崩
|
||
let d = UserDefaults(suiteName: Self.appGroupID) ?? .standard
|
||
defaults = d
|
||
token = d.string(forKey: Key.token)
|
||
nicknameMasked = d.string(forKey: Key.nickname)
|
||
balanceSeconds = d.integer(forKey: Key.balanceSeconds)
|
||
trialRemainingSeconds = d.integer(forKey: Key.trialRemaining)
|
||
trialDailyLimitSeconds = (d.object(forKey: Key.trialDailyLimit) as? Int) ?? DuduProtocol.trialDailySeconds
|
||
keyboardFullAccess = d.bool(forKey: Key.kbFullAccess)
|
||
keyboardSeen = d.bool(forKey: Key.kbSeen)
|
||
}
|
||
|
||
public var state: AccountState {
|
||
guard token != nil else { return .guest }
|
||
if balanceSeconds > 0 { return .ok }
|
||
if trialRemainingSeconds > 0 { return .trial }
|
||
return .quota
|
||
}
|
||
|
||
/// 今日试用已用(秒),UI 显示 "X / 3 分钟" 用
|
||
public var trialUsedSeconds: Int {
|
||
max(0, trialDailyLimitSeconds - trialRemainingSeconds)
|
||
}
|
||
|
||
/// 键盘扩展与主 App 是两个进程,UserDefaults 变更不会自动同步到 @Published;
|
||
/// 键盘在 viewWillAppear、主 App 在前台激活时调用。
|
||
public func reload() {
|
||
token = defaults.string(forKey: Key.token)
|
||
nicknameMasked = defaults.string(forKey: Key.nickname)
|
||
balanceSeconds = defaults.integer(forKey: Key.balanceSeconds)
|
||
trialRemainingSeconds = defaults.integer(forKey: Key.trialRemaining)
|
||
trialDailyLimitSeconds = (defaults.object(forKey: Key.trialDailyLimit) as? Int) ?? DuduProtocol.trialDailySeconds
|
||
keyboardFullAccess = defaults.bool(forKey: Key.kbFullAccess)
|
||
keyboardSeen = defaults.bool(forKey: Key.kbSeen)
|
||
}
|
||
|
||
// MARK: - 14B 真实账户写入
|
||
|
||
/// 微信登录成功:写入 token(App Group,键盘扩展下次出现即可见)。
|
||
public func applyLogin(token: String, nicknameMasked nickname: String) {
|
||
defaults.set(token, forKey: Key.token)
|
||
defaults.set(nickname, forKey: Key.nickname)
|
||
reload()
|
||
}
|
||
|
||
/// GET /v1/me 响应落盘:余额 / 试用 / 昵称。
|
||
public func applyMe(_ me: MeResponse) {
|
||
defaults.set(me.nicknameMasked, forKey: Key.nickname)
|
||
defaults.set(Int(me.balanceSeconds), forKey: Key.balanceSeconds)
|
||
defaults.set(me.trialDailyLimit, forKey: Key.trialDailyLimit)
|
||
defaults.set(max(0, me.trialDailyLimit - me.trialUsedToday), forKey: Key.trialRemaining)
|
||
reload()
|
||
}
|
||
|
||
/// 14D:WS usage 帧实时回写余额 / 试用。
|
||
/// 语义为「字段存在即覆盖」(含 0):ServerMsg 用 decodeIfPresent,
|
||
/// balance_seconds=0 显式下发时解码为 0(非 nil),这里写入 0 让余额归零,
|
||
/// state 推导 balance=0 && trial=0 → quota。后端 #16 去掉 omitempty 后 0 会显式下发;
|
||
/// 即便后端仍 omitempty 吞掉 0(字段缺失 → nil → 不覆盖),也由 error 帧
|
||
/// (QUOTA_EXCEEDED)与下次 GET /v1/me 兜底,不会误把非 0 余额清掉。
|
||
public func applyUsage(balanceSeconds: Int64?, trialRemaining: Int?) {
|
||
if let b = balanceSeconds { defaults.set(Int(b), forKey: Key.balanceSeconds) }
|
||
if let t = trialRemaining { defaults.set(t, forKey: Key.trialRemaining) }
|
||
reload()
|
||
}
|
||
|
||
/// 退出登录:清 token 与缓存余额,回到 guest。
|
||
public func logout() {
|
||
defaults.removeObject(forKey: Key.token)
|
||
defaults.removeObject(forKey: Key.nickname)
|
||
defaults.set(0, forKey: Key.balanceSeconds)
|
||
defaults.set(0, forKey: Key.trialRemaining)
|
||
reload()
|
||
}
|
||
|
||
/// 键盘扩展每次 viewWillAppear 调用:回写完全访问态与"打开过键盘"标记,
|
||
/// 主 App 引导第 2 步(完全访问)/ 第 4 步(试一试)据此自动判定完成。
|
||
public func recordKeyboardActive(fullAccess: Bool) {
|
||
defaults.set(fullAccess, forKey: Key.kbFullAccess)
|
||
defaults.set(true, forKey: Key.kbSeen)
|
||
reload()
|
||
}
|
||
|
||
#if DEBUG
|
||
/// mock 写入:主 App 调试卡一键切四态供键盘扩展演示(仅 DEBUG 构建)。
|
||
public func setMock(state: AccountState) {
|
||
switch state {
|
||
case .ok:
|
||
defaults.set("mock-token", forKey: Key.token)
|
||
defaults.set("wang***", forKey: Key.nickname)
|
||
defaults.set(472 * 60, forKey: Key.balanceSeconds)
|
||
defaults.set(120, forKey: Key.trialRemaining)
|
||
case .trial:
|
||
defaults.set("mock-token", forKey: Key.token)
|
||
defaults.set("wang***", forKey: Key.nickname)
|
||
defaults.set(0, forKey: Key.balanceSeconds)
|
||
defaults.set(120, forKey: Key.trialRemaining)
|
||
case .guest:
|
||
defaults.removeObject(forKey: Key.token)
|
||
defaults.removeObject(forKey: Key.nickname)
|
||
defaults.set(0, forKey: Key.balanceSeconds)
|
||
defaults.set(0, forKey: Key.trialRemaining)
|
||
case .quota:
|
||
defaults.set("mock-token", forKey: Key.token)
|
||
defaults.set("wang***", forKey: Key.nickname)
|
||
defaults.set(0, forKey: Key.balanceSeconds)
|
||
defaults.set(0, forKey: Key.trialRemaining)
|
||
}
|
||
reload()
|
||
}
|
||
#endif
|
||
}
|