模块化 AppStore 并完善素材管理与个人空间设置。

将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 11:01:08 +08:00
co-authored by Cursor
parent ceca780ab3
commit 005349f8e6
128 changed files with 2953 additions and 1123 deletions
+28 -388
View File
@@ -5,403 +5,43 @@
import Foundation
/// 本地会话存储,与 Android `AppStoreDataSource` 对齐。
/// 应用本地存储统一入口,负责组装各功能模块并协调退出登录清理。
final class AppStore {
/// 全局应用存储。
static let shared = AppStore()
private enum Key {
static let token = "key_in_token"
static let lastLoginUsername = "key_last_login_username"
static let privacyAgreementAccepted = "key_privacy_agreement_accepted"
static let userId = "key_in_user_id"
static let userName = "key_in_user_name"
static let realName = "key_in_real_name"
static let avatar = "key_in_avatar"
static let phone = "key_in_phone"
static let accountType = "key_in_account_type"
static let accountDisplayName = "key_in_account_display_name"
static let roleCode = "key_in_role_code"
static let roleName = "key_in_role_name"
static let currentScenicId = "key_in_current_scenic_id"
static let currentScenicName = "key_in_current_scenic_name"
static let currentStoreId = "key_in_current_store_id"
static let legacyRoleId = "key_in_role_id"
static let rolePermissionList = "key_role_permission_list"
static let permissionItems = "key_in_permission"
static let roleScenicList = "key_current_role_scenic_list"
static let onlineStatus = "key_online_status"
static let lastLocationReportTime = "key_last_location_report_time"
static let locationReminderMinutes = "key_location_reminder_minutes"
static let isOpenReceiveVoice = "key_is_open_receive_voice"
static let scenicQueuePunchSpotId = "_scenic_queue_punch_spot_id"
static let scenicQueuePunchSpotName = "_scenic_queue_punch_spot_name"
static let scenicQueueRemark = "_scenic_queue_remark"
static let scenicQueueSettingsSnapshot = "_scenic_queue_settings_snapshot"
static let scenicQueueOfflineTTS = "_scenic_queue_offline_tts_v1"
static let scenicQueuePresetVoices = "_scenic_queue_preset_voices_v1"
}
/// 登录会话与当前账号存储。
let session: AppSessionStore
private let defaults: UserDefaults
/// 角色权限与首页权限缓存。
let permissions: AppPermissionStore
/// 位置上报会话存储。
let location: AppLocationStore
/// 收款功能偏好存储。
let payment: AppPaymentStore
/// 景区排队功能存储。
let scenicQueue: AppScenicQueueStore
/// 使用指定 UserDefaults 创建模块化应用存储。
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let session = AppSessionStore(defaults: defaults)
self.session = session
permissions = AppPermissionStore(defaults: defaults, session: session)
location = AppLocationStore(defaults: defaults, session: session)
payment = AppPaymentStore(defaults: defaults, session: session)
scenicQueue = AppScenicQueueStore(defaults: defaults, session: session)
}
/// 登录 Token。
var token: String {
get { defaults.string(forKey: Key.token) ?? "" }
set { defaults.set(newValue, forKey: Key.token) }
}
/// 上次登录手机号,用于登录页恢复。
var lastLoginUsername: String? {
get { defaults.string(forKey: Key.lastLoginUsername) }
set {
if let newValue {
defaults.set(newValue, forKey: Key.lastLoginUsername)
} else {
defaults.removeObject(forKey: Key.lastLoginUsername)
}
}
}
/// 是否已同意隐私协议。
var privacyAgreementAccepted: Bool {
get { defaults.bool(forKey: Key.privacyAgreementAccepted) }
set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted) }
}
var userId: String {
get { defaults.string(forKey: Key.userId) ?? "" }
set { defaults.set(newValue, forKey: Key.userId) }
}
var userName: String {
get { defaults.string(forKey: Key.userName) ?? "" }
set { defaults.set(newValue, forKey: Key.userName) }
}
var realName: String {
get { defaults.string(forKey: Key.realName) ?? "" }
set { defaults.set(newValue, forKey: Key.realName) }
}
var avatar: String {
get { defaults.string(forKey: Key.avatar) ?? "" }
set { defaults.set(newValue, forKey: Key.avatar) }
}
var phone: String {
get { defaults.string(forKey: Key.phone) ?? "" }
set { defaults.set(newValue, forKey: Key.phone) }
}
var accountType: String {
get { defaults.string(forKey: Key.accountType) ?? "" }
set { defaults.set(newValue, forKey: Key.accountType) }
}
var accountDisplayName: String {
get { defaults.string(forKey: Key.accountDisplayName) ?? "" }
set { defaults.set(newValue, forKey: Key.accountDisplayName) }
}
var roleCode: String {
get { defaults.string(forKey: Key.roleCode) ?? "" }
set { defaults.set(newValue, forKey: Key.roleCode) }
}
var roleName: String {
get { defaults.string(forKey: Key.roleName) ?? "" }
set { defaults.set(newValue, forKey: Key.roleName) }
}
var currentScenicId: Int {
get {
let value = defaults.string(forKey: Key.currentScenicId) ?? ""
return Int(value) ?? 0
}
set {
defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId)
}
}
var currentScenicName: String {
get { defaults.string(forKey: Key.currentScenicName) ?? "" }
set { defaults.set(newValue, forKey: Key.currentScenicName) }
}
var currentStoreId: Int {
get { defaults.integer(forKey: Key.currentStoreId) }
set { defaults.set(newValue, forKey: Key.currentStoreId) }
}
/// 旧版 role_id,用于权限匹配兜底。
var legacyRoleId: Int {
get { defaults.integer(forKey: accountScopedKey(Key.legacyRoleId)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.legacyRoleId)) }
}
/// 是否在线(位置上报会话)。
var onlineStatus: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.onlineStatus)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.onlineStatus)) }
}
/// 上次位置上报时间戳(毫秒)。
var lastLocationReportTime: Int64 {
get { Int64(defaults.integer(forKey: accountScopedKey(Key.lastLocationReportTime))) }
set { defaults.set(Int(newValue), forKey: accountScopedKey(Key.lastLocationReportTime)) }
}
/// 位置超时提前提醒分钟数,0 表示不提醒。
var locationReminderMinutes: Int {
get { defaults.integer(forKey: accountScopedKey(Key.locationReminderMinutes)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) }
}
/// 收款到账是否开启语音播报,对齐 Android `getIsOpenReceiveVoice`。
var isOpenReceiveVoice: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.isOpenReceiveVoice)) }
}
/// 排队管理已保存打卡点 ID。
var scenicQueuePunchSpotId: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) }
}
/// 排队管理已保存打卡点名称。
var scenicQueuePunchSpotName: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) }
}
/// 排队管理自定义播报文案。
var scenicQueueRemark: String {
get { defaults.string(forKey: accountScopedKey(Key.scenicQueueRemark)) ?? "" }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueRemark)) }
}
/// 排队语音是否优先使用离线 TTS。
var scenicQueueUseOfflineTTS: Bool {
get { defaults.bool(forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) }
set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) }
}
/// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。
var accountCachePrefix: String {
let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines)
let type = accountType.trimmingCharacters(in: .whitespacesAndNewlines)
if uid.isEmpty { return "guest" }
if type.isEmpty { return uid }
return "\(uid)_\(type)"
}
/// 当前解析后的业务角色。
var currentAppRole: AppRoleCode? {
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
}
/// 当前是否为摄影师角色。
var isPhotographerRole: Bool {
if let role = currentAppRole {
return role.isPhotographer
}
let name = roleName.trimmingCharacters(in: .whitespacesAndNewlines)
return name == "摄影师"
}
/// Token 非空视为已登录。
var isLoggedIn: Bool {
!token.isEmpty
}
/// 保存登录 Token。
func saveToken(_ token: String) {
self.token = token
}
/// 从账号切换实体写入会话快照。
func applyAccount(_ account: AccountSwitchAccount) {
userId = String(account.businessUserId)
accountType = account.accountType
accountDisplayName = account.title
userName = account.subtitle
phone = account.phone
if !account.avatar.isEmpty {
avatar = account.avatar
}
currentScenicName = account.scenicName
currentScenicId = account.scenicId ?? 0
currentStoreId = account.storeId ?? 0
}
/// 从用户资料接口回写展示字段。
func applyUserInfo(_ info: UserInfoResponse) {
if !info.nickname.isEmpty {
userName = info.nickname
}
if !info.realName.isEmpty {
realName = info.realName
}
if !info.avatar.isEmpty {
avatar = info.avatar
}
if !info.phone.isEmpty {
phone = info.phone
}
if !info.roleName.isEmpty, roleName.isEmpty {
roleName = info.roleName
}
}
/// 清除登录态与会话快照。
/// 清除当前账号业务快照与登录态,保留隐私授权和上次登录用户名。
func logout() {
clearPermissionSnapshot()
token = ""
userId = ""
userName = ""
realName = ""
avatar = ""
phone = ""
accountType = ""
accountDisplayName = ""
roleCode = ""
roleName = ""
currentScenicId = 0
currentScenicName = ""
currentStoreId = 0
}
/// 保存完整 role-permission 列表。
func saveRolePermissionList(_ list: [RolePermissionResponse]) {
guard let data = try? JSONEncoder().encode(list) else { return }
defaults.set(data, forKey: accountScopedKey(Key.rolePermissionList))
}
/// 读取完整 role-permission 列表。
func rolePermissionList() -> [RolePermissionResponse] {
guard let data = defaults.data(forKey: accountScopedKey(Key.rolePermissionList)),
let list = try? JSONDecoder().decode([RolePermissionResponse].self, from: data) else {
return []
}
return list
}
/// 保存当前角色扁平权限。
func savePermissionItems(_ items: [HomePermissionItem]) {
guard let data = try? JSONEncoder().encode(items) else { return }
defaults.set(data, forKey: accountScopedKey(Key.permissionItems))
}
/// 读取当前角色扁平权限。
func permissionItems() -> [HomePermissionItem] {
guard let data = defaults.data(forKey: accountScopedKey(Key.permissionItems)),
let items = try? JSONDecoder().decode([HomePermissionItem].self, from: data) else {
return []
}
return items
}
/// 保存当前角色可选景区列表。
func saveRoleScenicList(_ scenicList: [ScenicInfo]) {
guard let data = try? JSONEncoder().encode(scenicList) else { return }
defaults.set(data, forKey: accountScopedKey(Key.roleScenicList))
}
/// 读取当前角色可选景区列表。
func roleScenicList() -> [ScenicInfo] {
guard let data = defaults.data(forKey: accountScopedKey(Key.roleScenicList)),
let list = try? JSONDecoder().decode([ScenicInfo].self, from: data) else {
return []
}
return list
}
/// 在 role-permission 列表中匹配当前账号角色。
func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? {
RolePermissionMatcher.match(
in: list,
roleCode: roleCode,
roleName: roleName,
legacyRoleId: legacyRoleId
)
}
/// 清除上次位置上报时间。
func clearLastLocationReportTime() {
defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime))
}
/// 是否已经保存排队打卡点。
func hasScenicQueuePunchSpotSaved() -> Bool {
let id = scenicQueuePunchSpotId.trimmingCharacters(in: .whitespacesAndNewlines)
return Int64(id).map { $0 > 0 } ?? false
}
/// 保存排队设置快照。
func saveScenicQueueSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) {
guard let data = try? JSONEncoder().encode(snapshot) else { return }
defaults.set(data, forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot))
}
/// 读取排队设置快照。
func scenicQueueSettingsSnapshot() -> ScenicQueueSettingsSnapshot? {
guard let data = defaults.data(forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) else { return nil }
return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
}
/// 保存排队预设播报文案,最多 5 条。
func saveScenicQueuePresetVoices(_ voices: [String]) {
let normalized = voices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(5)
defaults.set(Array(normalized), forKey: accountScopedKey(Key.scenicQueuePresetVoices))
}
/// 读取排队预设播报文案。
func scenicQueuePresetVoices() -> [String] {
defaults.stringArray(forKey: accountScopedKey(Key.scenicQueuePresetVoices)) ?? []
}
/// 清除排队管理本地缓存。
func clearScenicQueueSnapshot() {
[
Key.scenicQueuePunchSpotId,
Key.scenicQueuePunchSpotName,
Key.scenicQueueRemark,
Key.scenicQueueSettingsSnapshot,
Key.scenicQueueOfflineTTS,
Key.scenicQueuePresetVoices,
].forEach { defaults.removeObject(forKey: accountScopedKey($0)) }
}
/// 清除权限与位置会话快照。
func clearPermissionSnapshot() {
let prefix = accountScopedKey("")
let keys = [
Key.rolePermissionList,
Key.permissionItems,
Key.roleScenicList,
Key.onlineStatus,
Key.lastLocationReportTime,
Key.locationReminderMinutes,
Key.isOpenReceiveVoice,
Key.scenicQueuePunchSpotId,
Key.scenicQueuePunchSpotName,
Key.scenicQueueRemark,
Key.scenicQueueSettingsSnapshot,
Key.scenicQueueOfflineTTS,
Key.scenicQueuePresetVoices,
]
keys.forEach { defaults.removeObject(forKey: prefix + $0) }
}
private func accountScopedKey(_ key: String) -> String {
"\(accountCachePrefix)_\(key)"
permissions.clear()
location.clear()
payment.clear()
scenicQueue.clear()
session.clearAuthenticatedSession()
}
}