From c083f1d4b32fb7b6bf0a3edc5da43bb73bf020be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 14 Jul 2026 09:35:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=87=E7=BA=A7=20AppStore=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20Key=20=E5=B9=B6=E5=9C=A8=E8=A6=86=E7=9B=96=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=97=B6=E5=BC=BA=E5=88=B6=E9=87=8D=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一账号作用域 Key 对齐 Android,新增缓存 schema 迁移清理旧数据,并同步更新相关模块与单元测试。 Co-authored-by: Cursor --- APPSTORE_CACHE_KEY_MIGRATION.md | 169 ++++++++++++++++++ suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/DataStore/AppLocationStore.swift | 42 +++-- suixinkan/DataStore/AppPaymentStore.swift | 19 +- suixinkan/DataStore/AppPermissionStore.swift | 27 ++- suixinkan/DataStore/AppScenicQueueStore.swift | 84 ++++++--- suixinkan/DataStore/AppSessionStore.swift | 111 +++++++++--- suixinkan/DataStore/AppStore.swift | 12 +- .../DataStore/AppStoreCacheMigrator.swift | 116 ++++++++++++ .../Home/Services/HomeCommonMenuStore.swift | 13 +- .../Home/ViewModels/HomeViewModel.swift | 3 + suixinkan/UI/Login/LoginViewController.swift | 4 +- .../AppStoreCacheMigrationTests.swift | 160 +++++++++++++++++ suixinkanTests/AppStoreModuleTests.swift | 93 +++++++++- suixinkanTests/AppStoreTestSupport.swift | 22 +++ .../CooperationAcquirerViewModelTests.swift | 1 + .../DepositOrderListViewModelTests.swift | 15 +- suixinkanTests/HomeCommonMenuStoreTests.swift | 16 +- suixinkanTests/LocationProviderTests.swift | 1 + .../LocationReportServiceTests.swift | 3 +- suixinkanTests/OrderListViewModelTests.swift | 10 +- ...ymentCollectionDetailsViewModelTests.swift | 3 +- ...aymentCollectionRecordViewModelTests.swift | 2 +- .../PermissionApplyViewModelTests.swift | 1 + .../ProfileSpaceSettingsViewModelTests.swift | 6 +- suixinkanTests/ProfileViewModelTests.swift | 12 ++ suixinkanTests/ScenicQueueFeatureTests.swift | 6 + .../ScenicSelectionViewModelTests.swift | 1 + suixinkanTests/StatisticsViewModelTests.swift | 11 +- suixinkanTests/TaskAddViewModelTests.swift | 2 +- .../TaskOrderSelectViewModelTests.swift | 2 +- 31 files changed, 852 insertions(+), 123 deletions(-) create mode 100644 APPSTORE_CACHE_KEY_MIGRATION.md create mode 100644 suixinkan/DataStore/AppStoreCacheMigrator.swift create mode 100644 suixinkanTests/AppStoreCacheMigrationTests.swift create mode 100644 suixinkanTests/AppStoreTestSupport.swift diff --git a/APPSTORE_CACHE_KEY_MIGRATION.md b/APPSTORE_CACHE_KEY_MIGRATION.md new file mode 100644 index 0000000..0a7a4d3 --- /dev/null +++ b/APPSTORE_CACHE_KEY_MIGRATION.md @@ -0,0 +1,169 @@ +# AppStore 缓存 Key 升级对照 + +## 结论 + +- 旧线上工程与重构版工程的主应用 Bundle ID 均为 `com.yuanzhixiang.suixinkan`。通过 App Store 覆盖升级时,系统会保留原应用容器,因此旧版 `UserDefaults` 会继续存在。 +- 本次升级策略为:**强制重新登录、旧偏好全部重置、不迁移旧缓存**。 +- 新 Key 只对齐 Android 的业务作用域;iOS 统一使用 `_` 作为分隔符,不复制 Android MMKV 历史实现中无分隔符拼接的写法。 +- 本文中的“旧线上工程”为 `/Users/hanqiu/Desktop/android_project/suixinkan_ios`,“重构版工程”为 `/Users/hanqiu/Desktop/suixinkan`。 + +## Key 占位符 + +| 占位符 | 含义 | +|---|---| +| `O` | 旧版将 `[accountType, userId, storeId, scenicId]` 中的非空值用 `_` 拼接,再清洗非字母数字字符后得到的 accountKey | +| `P` | 本次实施前重构版使用的 `_` 账号前缀;用户 ID 为空时可能退化为 `guest` | +| `A` | 本次实施后统一使用的 `_` 账号前缀 | +| `S` | 景区 ID(scenicId) | +| `T` | 排队点位 ID(spotId) | +| `R` | 角色编码(roleCode) | + +> `suixinkan.session.v1` 是旧工程中真实存在的单个 `UserDefaults` Key,其值为 JSON Data。表格中的 `suixinkan.session.v1[token]` 等写法仅表示该 JSON 内的字段,不是独立的 `UserDefaults` Key。 + +## 新旧工程统一业务 Key 对照 + +| 业务数据 | 旧线上工程 Key | 重构版实施前 Key | 统一后的 Key | 升级处理 | +|---|---|---|---|---| +| 登录会话 | `suixinkan.session.v1` JSON | 拆分为多个 `key_in_*` | 保持拆分结构 | 删除旧会话,强制登录 | +| Token | `suixinkan.session.v1[token]` | `key_in_token` | `key_in_token` | 首次升级清空 | +| 用户 ID | `suixinkan.session.v1[userId]` | `key_in_user_id` | `key_in_user_id` | 首次升级清空 | +| 用户名 | `suixinkan.session.v1[userName]` | `key_in_user_name` | `key_in_user_name` | 首次升级清空 | +| 头像 | `suixinkan.session.v1[avatar]` | `key_in_avatar` | `key_in_avatar` | 首次升级清空 | +| 手机号 | `suixinkan.session.v1[phone]` | `key_in_phone` | `key_in_phone` | 首次升级清空 | +| 账号类型 | `suixinkan.session.v1[accountType]` | `key_in_account_type` | `key_in_account_type` | 首次升级清空 | +| 账号类型显示名称 | `suixinkan.session.v1[accountDisplayName]` | `key_in_account_display_name` | `key_in_account_display_name` | 首次升级清空 | +| 角色 ID | `suixinkan.session.v1[roleId]` | `P_key_in_role_id` | `A_key_in_role_id` | 不迁移;当前仅用于 legacy 权限匹配兜底 | +| 角色编码 | 无独立 Key | `key_in_role_code` | `A_key_in_role_code` | 改为账号作用域 | +| 角色名称 | `suixinkan.session.v1[roleName]` | `key_in_role_name` | `A_key_in_role_name` | 改为账号作用域 | +| 权限列表 | 无 | `P_key_role_permission_list` | `A_key_role_permission_list` | 不迁移预发布数据 | +| 当前权限 | 无 | `P_key_in_permission` | `A_key_in_permission` | 不迁移预发布数据 | +| 角色景区列表 | 无 | `P_key_current_role_scenic_list` | `A_key_current_role_scenic_list` | 不迁移预发布数据 | +| 当前景区 ID | `suixinkan.session.v1[scenicId]` | `key_in_current_scenic_id` | `A_key_in_current_scenic_id` | 改为账号作用域 | +| 当前景区名称 | `suixinkan.session.v1[scenicName]` | `key_in_current_scenic_name` | `A_key_in_current_scenic_name` | 改为账号作用域 | +| 当前门店 ID | `suixinkan.session.v1[storeId]` | `key_in_current_store_id` | `A_scenic_S_scenic_store_id` | 改为账号+景区作用域 | +| 当前门店名称 | `suixinkan.session.v1[storeName]` | 无 | 不再持久化 | 删除旧值 | +| 常用菜单 | `home.common.menu.uris` | `P_role_R_common_uris` | `A_role_R_common_uris` | 不迁移旧菜单;仅统一作用域,Android 精确后缀不同 | +| 收款语音 | `payment.voice.broadcast.enabled` | `P_key_is_open_receive_voice` | `A_key_is_open_receive_voice` | 重置为默认值 | +| 在线状态 | 无 | `P_key_online_status` | `A_key_online_status` | 改为账号作用域 | +| 上次位置上报时间 | 无 | `P_key_last_location_report_time` | `A_key_last_location_report_time` | 改为账号作用域 | +| 定位提醒分钟数 | 无 | `P_key_location_reminder_minutes` | `A_key_location_reminder_minutes` | 改为账号作用域 | +| 当前排队点位 ID | `account_O_scenic_S_scenic_queue_selected_spot_id` | `P__scenic_queue_punch_spot_id` | `A_scenic_S_scenic_queue_punch_spot_id` | 不迁移 | +| 当前排队点位名称 | `account_O_scenic_S_scenic_queue_selected_spot_name` | `P__scenic_queue_punch_spot_name` | `A_scenic_S_spot_T_scenic_queue_punch_spot_name` | 不迁移 | +| 排队播报备注 | `account_O_scenic_S_scenic_queue_custom_tts_text_spot_T` | `P__scenic_queue_remark` | `A_scenic_S_spot_T_scenic_queue_remark` | 不迁移 | +| 排队设置快照 | `account_O_scenic_S_scenic_queue_settings_snapshot_spot_T` | `P__scenic_queue_settings_snapshot` | `A_scenic_S_spot_T_scenic_queue_settings_snapshot` | 不迁移 | +| 排队预设语音 | `account_O_scenic_S_scenic_queue_preset_voices_spot_T` | `P__scenic_queue_preset_voices_v1` | `A_scenic_S_spot_T_scenic_queue_preset_voices_v1` | 不迁移 | +| 离线 TTS | 无 | `P__scenic_queue_offline_tts_v1` | `A_scenic_S_scenic_queue_offline_tts_v1` | 默认值对齐 Android 为 `true` | + +## 首次升级需要清理的旧 Key + +已新增整型版本标记 `suixinkan.cache.schema.version`。当版本小于目标缓存版本时执行一次清理,全部清理完成后再写入目标版本,避免每次启动重复清空用户重新登录后产生的数据。 + +### 会话与推送 + +- `suixinkan.session.v1` +- `apns_device_token` +- `apns_uploaded_token` + +同时清空重构版预发布阶段可能存在的登录与账号字段,以确保升级后强制重新登录: + +- `key_in_token` +- `key_last_login_username` +- `key_in_user_id` +- `key_in_user_name` +- `key_in_real_name` +- `key_in_avatar` +- `key_in_phone` +- `key_in_account_type` +- `key_in_account_display_name` +- `key_in_role_id` +- `key_in_role_code` +- `key_in_role_name` +- `key_in_current_scenic_id` +- `key_in_current_scenic_name` +- `key_in_current_store_id` + +`key_privacy_agreement_accepted` 是否清理应服从“旧偏好全部重置”的既定策略,因此本次也应清理。 + +同时清理早期重构实现可能写入的以下未分域业务字段: + +- `key_role_permission_list` +- `key_in_permission` +- `key_current_role_scenic_list` +- `key_online_status` +- `key_last_location_report_time` +- `key_location_reminder_minutes` +- `key_is_open_receive_voice` + +### 首页与收款 + +- `home.common.menu.uris` +- `home.common.menu.android.baseline.v2` +- `home.routing.unknown.records` +- `payment.voice.broadcast.enabled` + +### 排队全局偏好 + +- `scenic_queue_tts_enabled` +- `scenic_queue_background_poll_enabled` +- `scenic_queue_selected_spot_id` +- `scenic_queue_selected_spot_name` +- `scenic_queue_custom_tts_text` +- `scenic_queue_photo_estimate_seconds` +- `scenic_queue_broadcast_interval_seconds` +- `scenic_queue_countdown_threshold_seconds` +- `scenic_queue_show_start_shooting_button` +- `scenic_queue_auto_call_ahead_count` +- `scenic_queue_quick_call_button_enabled` +- `scenic_queue_prepare_call_button_enabled` +- `scenic_queue_config_logs` +- `scenic_queue_settings_snapshot` +- `scenic_queue_preset_voices` + +### 排队动态缓存 + +清理符合以下已知格式的旧 Key,不应扩大为删除整个 `UserDefaults` domain: + +- `account_O_scenic_S_scenic_queue_selected_spot_id` +- `account_O_scenic_S_scenic_queue_selected_spot_name` +- `account_O_scenic_S_scenic_queue_custom_tts_text_spot_T` +- `account_O_scenic_S_scenic_queue_settings_snapshot_spot_T` +- `account_O_scenic_S_scenic_queue_preset_voices_spot_T` +- `scenic_S_spot_T_scenic_queue_settings_snapshot` +- `scenic_S_scenic_queue_settings_snapshot` +- `scenic_S_spot_T_scenic_queue_preset_voices` + +### 重构版预发布缓存 + +清理使用旧 `P` 前缀的以下 Key 家族: + +- `P_key_in_role_id` +- `P_key_role_permission_list` +- `P_key_in_permission` +- `P_key_current_role_scenic_list` +- `P_key_online_status` +- `P_key_last_location_report_time` +- `P_key_location_reminder_minutes` +- `P_key_is_open_receive_voice` +- `P_role_R_common_uris` +- `P__scenic_queue_punch_spot_id` +- `P__scenic_queue_punch_spot_name` +- `P__scenic_queue_remark` +- `P__scenic_queue_settings_snapshot` +- `P__scenic_queue_offline_tts_v1` +- `P__scenic_queue_preset_voices_v1` + +其中 `P__scenic_queue_*` 的双下划线来自当前 Key 生成器和自带前导下划线的后缀叠加,属于需要淘汰的错误格式。 + +## 不应清理的内容 + +- 禁止调用 `removePersistentDomain(forName:)` 清空应用的整个 `UserDefaults` domain,应只删除明确列出的 Key 和严格匹配的动态 Key。 +- 不删除 `Documents/CloudDownloads`。该目录可能包含用户主动下载的文件,不属于普通缓存。 +- 不删除 SDK 缓存、图片缓存或其他未列入本次升级范围的用户文件。 +- 不做旧 `suixinkan.session.v1`、排队设置、常用菜单或收款偏好的解码与迁移。 + +## 发布前备注 + +- 旧线上版本为 `1.0.1 (2)`;当前覆盖升级版本已设置为 `1.1.4 (1010401)`。 +- 重构版当前未保留旧工程的 APNs entitlement、远程通知后台能力、回调和设备 Token 上传流程。该问题不是缓存 Key 修改,但若线上仍需要推送,发布前必须单独恢复并验证。清理 `apns_device_token` 与 `apns_uploaded_token` 后,恢复推送时应重新绑定设备 Token。 +- `Application Support/suixinkan/cloud_transfer_tasks.json` 当前为全账号共享文件,存在账号切换后读取其他账号任务的风险。该问题不是 `UserDefaults` Key 迁移的一部分,应作为独立发布风险处理。 +- 本方案已在重构版中实施:应用首次创建 `AppStore` 时按 `suixinkan.cache.schema.version` 执行一次定向清理,并切换到表格中的统一 Key;不删除未列入范围的 `UserDefaults` 或用户文件。 diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 7842a87..127ecf6 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -402,7 +402,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 1010401; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( @@ -426,7 +426,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -467,7 +467,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 1010401; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( @@ -491,7 +491,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/suixinkan/DataStore/AppLocationStore.swift b/suixinkan/DataStore/AppLocationStore.swift index 956ed93..43d08c8 100644 --- a/suixinkan/DataStore/AppLocationStore.swift +++ b/suixinkan/DataStore/AppLocationStore.swift @@ -25,33 +25,53 @@ final class AppLocationStore { /// 是否处于位置上报在线状态。 var onlineStatus: Bool { - get { defaults.bool(forKey: scopedKey(.onlineStatus)) } - set { defaults.set(newValue, forKey: scopedKey(.onlineStatus)) } + get { + guard let key = scopedKey(.onlineStatus) else { return false } + return defaults.bool(forKey: key) + } + set { + guard let key = scopedKey(.onlineStatus) else { return } + defaults.set(newValue, forKey: key) + } } /// 上次位置上报时间戳,单位为毫秒。 var lastReportTime: Int64 { - get { Int64(defaults.integer(forKey: scopedKey(.lastReportTime))) } - set { defaults.set(Int(newValue), forKey: scopedKey(.lastReportTime)) } + get { + guard let key = scopedKey(.lastReportTime) else { return 0 } + return Int64(defaults.integer(forKey: key)) + } + set { + guard let key = scopedKey(.lastReportTime) else { return } + defaults.set(Int(newValue), forKey: key) + } } /// 位置超时提前提醒分钟数,0 表示不提醒。 var reminderMinutes: Int { - get { defaults.integer(forKey: scopedKey(.reminderMinutes)) } - set { defaults.set(newValue, forKey: scopedKey(.reminderMinutes)) } + get { + guard let key = scopedKey(.reminderMinutes) else { return 0 } + return defaults.integer(forKey: key) + } + set { + guard let key = scopedKey(.reminderMinutes) else { return } + defaults.set(newValue, forKey: key) + } } /// 清除上次位置上报时间。 func clearLastReportTime() { - defaults.removeObject(forKey: scopedKey(.lastReportTime)) + guard let key = scopedKey(.lastReportTime) else { return } + defaults.removeObject(forKey: key) } /// 清除当前账号的位置上报状态和偏好。 - func clear() { - Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + func clear(accountScope: String) { + guard !accountScope.isEmpty else { return } + Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") } } - private func scopedKey(_ key: Key) -> String { - "\(session.accountCachePrefix)_\(key.rawValue)" + private func scopedKey(_ key: Key) -> String? { + session.accountScopedKey(key.rawValue) } } diff --git a/suixinkan/DataStore/AppPaymentStore.swift b/suixinkan/DataStore/AppPaymentStore.swift index a5432ea..ac4b9b6 100644 --- a/suixinkan/DataStore/AppPaymentStore.swift +++ b/suixinkan/DataStore/AppPaymentStore.swift @@ -23,16 +23,23 @@ final class AppPaymentStore { /// 收款到账是否开启语音播报。 var isOpenReceiveVoice: Bool { - get { defaults.bool(forKey: scopedKey(.isOpenReceiveVoice)) } - set { defaults.set(newValue, forKey: scopedKey(.isOpenReceiveVoice)) } + get { + guard let key = scopedKey(.isOpenReceiveVoice) else { return false } + return defaults.bool(forKey: key) + } + set { + guard let key = scopedKey(.isOpenReceiveVoice) else { return } + defaults.set(newValue, forKey: key) + } } /// 清除当前账号的收款偏好。 - func clear() { - Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + func clear(accountScope: String) { + guard !accountScope.isEmpty else { return } + Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") } } - private func scopedKey(_ key: Key) -> String { - "\(session.accountCachePrefix)_\(key.rawValue)" + private func scopedKey(_ key: Key) -> String? { + session.accountScopedKey(key.rawValue) } } diff --git a/suixinkan/DataStore/AppPermissionStore.swift b/suixinkan/DataStore/AppPermissionStore.swift index 432c0fd..722e770 100644 --- a/suixinkan/DataStore/AppPermissionStore.swift +++ b/suixinkan/DataStore/AppPermissionStore.swift @@ -26,8 +26,14 @@ final class AppPermissionStore { /// 旧版 role_id,用于权限匹配兜底。 var legacyRoleId: Int { - get { defaults.integer(forKey: scopedKey(.legacyRoleId)) } - set { defaults.set(newValue, forKey: scopedKey(.legacyRoleId)) } + get { + guard let key = scopedKey(.legacyRoleId) else { return 0 } + return defaults.integer(forKey: key) + } + set { + guard let key = scopedKey(.legacyRoleId) else { return } + defaults.set(newValue, forKey: key) + } } /// 保存完整 role-permission 列表。 @@ -71,21 +77,24 @@ final class AppPermissionStore { } /// 清除当前账号的全部权限缓存。 - func clear() { - Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + func clear(accountScope: String) { + guard !accountScope.isEmpty else { return } + Key.allCases.forEach { defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") } } private func save(_ value: Value, for key: Key) { - guard let data = try? JSONEncoder().encode(value) else { return } - defaults.set(data, forKey: scopedKey(key)) + guard let scopedKey = scopedKey(key), + let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: scopedKey) } private func load(_ type: Value.Type, for key: Key) -> Value? { - guard let data = defaults.data(forKey: scopedKey(key)) else { return nil } + guard let scopedKey = scopedKey(key), + let data = defaults.data(forKey: scopedKey) else { return nil } return try? JSONDecoder().decode(type, from: data) } - private func scopedKey(_ key: Key) -> String { - "\(session.accountCachePrefix)_\(key.rawValue)" + private func scopedKey(_ key: Key) -> String? { + session.accountScopedKey(key.rawValue) } } diff --git a/suixinkan/DataStore/AppScenicQueueStore.swift b/suixinkan/DataStore/AppScenicQueueStore.swift index 9891e76..5af5baf 100644 --- a/suixinkan/DataStore/AppScenicQueueStore.swift +++ b/suixinkan/DataStore/AppScenicQueueStore.swift @@ -9,12 +9,12 @@ import Foundation final class AppScenicQueueStore { private enum Key: String, CaseIterable { - case punchSpotId = "_scenic_queue_punch_spot_id" - case punchSpotName = "_scenic_queue_punch_spot_name" - case remark = "_scenic_queue_remark" - case settingsSnapshot = "_scenic_queue_settings_snapshot" - case useOfflineTTS = "_scenic_queue_offline_tts_v1" - case presetVoices = "_scenic_queue_preset_voices_v1" + case punchSpotId = "scenic_queue_punch_spot_id" + case punchSpotName = "scenic_queue_punch_spot_name" + case remark = "scenic_queue_remark" + case settingsSnapshot = "scenic_queue_settings_snapshot" + case useOfflineTTS = "scenic_queue_offline_tts_v1" + case presetVoices = "scenic_queue_preset_voices_v1" } private let defaults: UserDefaults @@ -28,26 +28,39 @@ final class AppScenicQueueStore { /// 已保存打卡点 ID。 var punchSpotId: String { - get { string(for: .punchSpotId) } - set { defaults.set(newValue, forKey: scopedKey(.punchSpotId)) } + get { + guard let key = scenicKey(.punchSpotId) else { return "" } + return defaults.string(forKey: key) ?? "" + } + set { + guard let key = scenicKey(.punchSpotId) else { return } + defaults.set(newValue, forKey: key) + } } /// 已保存打卡点名称。 var punchSpotName: String { - get { string(for: .punchSpotName) } - set { defaults.set(newValue, forKey: scopedKey(.punchSpotName)) } + get { spotString(for: .punchSpotName) } + set { setSpotString(newValue, for: .punchSpotName) } } /// 自定义播报文案。 var remark: String { - get { string(for: .remark) } - set { defaults.set(newValue, forKey: scopedKey(.remark)) } + get { spotString(for: .remark) } + set { setSpotString(newValue, for: .remark) } } /// 是否优先使用离线 TTS。 var useOfflineTTS: Bool { - get { defaults.bool(forKey: scopedKey(.useOfflineTTS)) } - set { defaults.set(newValue, forKey: scopedKey(.useOfflineTTS)) } + get { + guard let key = scenicKey(.useOfflineTTS), + defaults.object(forKey: key) != nil else { return true } + return defaults.bool(forKey: key) + } + set { + guard let key = scenicKey(.useOfflineTTS) else { return } + defaults.set(newValue, forKey: key) + } } /// 当前账号是否已经保存有效打卡点。 @@ -58,40 +71,61 @@ final class AppScenicQueueStore { /// 保存排队设置快照。 func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) { - guard let data = try? JSONEncoder().encode(snapshot) else { return } - defaults.set(data, forKey: scopedKey(.settingsSnapshot)) + guard let key = spotKey(.settingsSnapshot), + let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: key) } /// 读取排队设置快照。 func settingsSnapshot() -> ScenicQueueSettingsSnapshot? { - guard let data = defaults.data(forKey: scopedKey(.settingsSnapshot)) else { return nil } + guard let key = spotKey(.settingsSnapshot), + let data = defaults.data(forKey: key) else { return nil } return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) } /// 保存排队预设播报文案,最多 5 条。 func savePresetVoices(_ voices: [String]) { + guard let key = spotKey(.presetVoices) else { return } let normalized = voices .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .prefix(5) - defaults.set(Array(normalized), forKey: scopedKey(.presetVoices)) + defaults.set(Array(normalized), forKey: key) } /// 读取排队预设播报文案。 func presetVoices() -> [String] { - defaults.stringArray(forKey: scopedKey(.presetVoices)) ?? [] + guard let key = spotKey(.presetVoices) else { return [] } + return defaults.stringArray(forKey: key) ?? [] } /// 清除当前账号的景区排队缓存。 - func clear() { - Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + func clear(accountScope: String) { + guard !accountScope.isEmpty else { return } + let scenicPrefix = "\(accountScope)_scenic_" + let suffixes = Key.allCases.map { "_\($0.rawValue)" } + defaults.dictionaryRepresentation().keys + .filter { key in + key.hasPrefix(scenicPrefix) && suffixes.contains(where: key.hasSuffix) + } + .forEach { defaults.removeObject(forKey: $0) } } - private func string(for key: Key) -> String { - defaults.string(forKey: scopedKey(key)) ?? "" + private func scenicKey(_ key: Key) -> String? { + session.scenicScopedKey(key.rawValue) } - private func scopedKey(_ key: Key) -> String { - "\(session.accountCachePrefix)_\(key.rawValue)" + private func spotKey(_ key: Key) -> String? { + session.scenicSpotScopedKey(key.rawValue, spotId: punchSpotId) + } + + private func spotString(for key: Key) -> String { + guard let scopedKey = spotKey(key) else { return "" } + return defaults.string(forKey: scopedKey) ?? "" + } + + private func setSpotString(_ value: String, for key: Key) { + guard let scopedKey = spotKey(key) else { return } + defaults.set(value, forKey: scopedKey) } } diff --git a/suixinkan/DataStore/AppSessionStore.swift b/suixinkan/DataStore/AppSessionStore.swift index 9237d57..d07d443 100644 --- a/suixinkan/DataStore/AppSessionStore.swift +++ b/suixinkan/DataStore/AppSessionStore.swift @@ -44,13 +44,17 @@ final class AppSessionStore { case phone = "key_in_phone" case accountType = "key_in_account_type" case accountDisplayName = "key_in_account_display_name" + } + + private enum AccountKey: String, CaseIterable { case roleCode = "key_in_role_code" case roleName = "key_in_role_name" case currentScenicId = "key_in_current_scenic_id" case currentScenicName = "key_in_current_scenic_name" - case currentStoreId = "key_in_current_store_id" } + private static let currentStoreIdSuffix = "scenic_store_id" + private let defaults: UserDefaults /// 使用指定 UserDefaults 创建会话存储。 @@ -126,41 +130,74 @@ final class AppSessionStore { /// 后端返回的原始角色编码。 var roleCode: String { - get { string(for: .roleCode) } - set { defaults.set(newValue, forKey: Key.roleCode.rawValue) } + get { accountString(for: .roleCode) } + set { setAccountString(newValue, for: .roleCode) } } /// 当前角色名称。 var roleName: String { - get { string(for: .roleName) } - set { defaults.set(newValue, forKey: Key.roleName.rawValue) } + get { accountString(for: .roleName) } + set { setAccountString(newValue, for: .roleName) } } /// 当前景区 ID,0 表示未选择。 var currentScenicId: Int { - get { Int(string(for: .currentScenicId)) ?? 0 } - set { defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId.rawValue) } + get { Int(accountString(for: .currentScenicId)) ?? 0 } + set { setAccountString(newValue > 0 ? String(newValue) : "", for: .currentScenicId) } } /// 当前景区名称。 var currentScenicName: String { - get { string(for: .currentScenicName) } - set { defaults.set(newValue, forKey: Key.currentScenicName.rawValue) } + get { accountString(for: .currentScenicName) } + set { setAccountString(newValue, for: .currentScenicName) } } /// 当前店铺 ID,0 表示未选择。 var currentStoreId: Int { - get { defaults.integer(forKey: Key.currentStoreId.rawValue) } - set { defaults.set(newValue, forKey: Key.currentStoreId.rawValue) } + get { + guard let key = scenicScopedKey(Self.currentStoreIdSuffix) else { return 0 } + return defaults.integer(forKey: key) + } + set { + guard let key = scenicScopedKey(Self.currentStoreIdSuffix) else { return } + defaults.set(newValue, forKey: key) + } } /// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。 var accountCachePrefix: String { let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) let type = accountType.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - if uid.isEmpty { return "guest" } - if type.isEmpty { return uid } - return "\(uid)_\(type)" + guard !uid.isEmpty, !type.isEmpty else { return "" } + return "\(type)_\(uid)" + } + + /// 为当前完整账号生成账号作用域 Key;账号信息不完整时返回 nil。 + func accountScopedKey(_ suffix: String) -> String? { + let scope = accountCachePrefix + let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + guard !scope.isEmpty, !normalizedSuffix.isEmpty else { return nil } + return "\(scope)_\(normalizedSuffix)" + } + + /// 为当前账号与景区生成作用域 Key;缺少账号或景区时返回 nil。 + func scenicScopedKey(_ suffix: String, scenicId: Int? = nil) -> String? { + let resolvedScenicId = scenicId ?? currentScenicId + let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + guard !accountCachePrefix.isEmpty, resolvedScenicId > 0, !normalizedSuffix.isEmpty else { return nil } + return "\(accountCachePrefix)_scenic_\(resolvedScenicId)_\(normalizedSuffix)" + } + + /// 为当前账号、景区与点位生成作用域 Key;任一业务标识缺失时返回 nil。 + func scenicSpotScopedKey(_ suffix: String, spotId: String, scenicId: Int? = nil) -> String? { + let resolvedScenicId = scenicId ?? currentScenicId + let normalizedSpotId = spotId.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSuffix = suffix.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + guard !accountCachePrefix.isEmpty, + resolvedScenicId > 0, + !normalizedSpotId.isEmpty, + !normalizedSuffix.isEmpty else { return nil } + return "\(accountCachePrefix)_scenic_\(resolvedScenicId)_spot_\(normalizedSpotId)_\(normalizedSuffix)" } /// 当前解析后的业务角色。 @@ -211,23 +248,41 @@ final class AppSessionStore { } /// 清除登录态及账号快照,保留隐私授权和上次登录用户名。 - func clearAuthenticatedSession() { - token = "" - userId = "" - userName = "" - realName = "" - avatar = "" - phone = "" - accountType = .unknown("") - accountDisplayName = "" - roleCode = "" - roleName = "" - currentScenicId = 0 - currentScenicName = "" - currentStoreId = 0 + func clearAuthenticatedSession(accountScope: String) { + if !accountScope.isEmpty { + AccountKey.allCases.forEach { + defaults.removeObject(forKey: "\(accountScope)_\($0.rawValue)") + } + let storePrefix = "\(accountScope)_scenic_" + let storeSuffix = "_\(Self.currentStoreIdSuffix)" + defaults.dictionaryRepresentation().keys + .filter { $0.hasPrefix(storePrefix) && $0.hasSuffix(storeSuffix) } + .forEach(defaults.removeObject(forKey:)) + } + + [ + Key.token, + .userId, + .userName, + .realName, + .avatar, + .phone, + .accountType, + .accountDisplayName, + ].forEach { defaults.removeObject(forKey: $0.rawValue) } } private func string(for key: Key) -> String { defaults.string(forKey: key.rawValue) ?? "" } + + private func accountString(for key: AccountKey) -> String { + guard let scopedKey = accountScopedKey(key.rawValue) else { return "" } + return defaults.string(forKey: scopedKey) ?? "" + } + + private func setAccountString(_ value: String, for key: AccountKey) { + guard let scopedKey = accountScopedKey(key.rawValue) else { return } + defaults.set(value, forKey: scopedKey) + } } diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index 616316a..68a1308 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -28,6 +28,7 @@ final class AppStore { /// 使用指定 UserDefaults 创建模块化应用存储。 init(defaults: UserDefaults = .standard) { + AppStoreCacheMigrator.migrateIfNeeded(defaults: defaults) let session = AppSessionStore(defaults: defaults) self.session = session permissions = AppPermissionStore(defaults: defaults, session: session) @@ -38,10 +39,11 @@ final class AppStore { /// 清除当前账号业务快照与登录态,保留隐私授权和上次登录用户名。 func logout() { - permissions.clear() - location.clear() - payment.clear() - scenicQueue.clear() - session.clearAuthenticatedSession() + let accountScope = session.accountCachePrefix + permissions.clear(accountScope: accountScope) + location.clear(accountScope: accountScope) + payment.clear(accountScope: accountScope) + scenicQueue.clear(accountScope: accountScope) + session.clearAuthenticatedSession(accountScope: accountScope) } } diff --git a/suixinkan/DataStore/AppStoreCacheMigrator.swift b/suixinkan/DataStore/AppStoreCacheMigrator.swift new file mode 100644 index 0000000..d227af8 --- /dev/null +++ b/suixinkan/DataStore/AppStoreCacheMigrator.swift @@ -0,0 +1,116 @@ +// +// AppStoreCacheMigrator.swift +// suixinkan +// + +import Foundation + +/// 管理 AppStore 持久化结构版本,并在覆盖升级时清理不再兼容的旧缓存。 +enum AppStoreCacheMigrator { + + /// 当前 AppStore 缓存结构版本。 + static let currentSchemaVersion = 1 + + /// AppStore 缓存结构版本标记。 + static let schemaVersionKey = "suixinkan.cache.schema.version" + + private static let exactKeysToRemove: Set = [ + // 旧线上会话、推送与首页缓存。 + "suixinkan.session.v1", + "apns_device_token", + "apns_uploaded_token", + "home.common.menu.uris", + "home.common.menu.android.baseline.v2", + "home.routing.unknown.records", + "payment.voice.broadcast.enabled", + + // 重构版预发布阶段的全局会话字段。 + "key_in_token", + "key_last_login_username", + "key_privacy_agreement_accepted", + "key_in_user_id", + "key_in_user_name", + "key_in_real_name", + "key_in_avatar", + "key_in_phone", + "key_in_account_type", + "key_in_account_display_name", + "key_in_role_id", + "key_in_role_code", + "key_in_role_name", + "key_in_current_scenic_id", + "key_in_current_scenic_name", + "key_in_current_store_id", + + // 可能由早期实现写入的未分域业务字段。 + "key_role_permission_list", + "key_in_permission", + "key_current_role_scenic_list", + "key_online_status", + "key_last_location_report_time", + "key_location_reminder_minutes", + "key_is_open_receive_voice", + + // 旧线上排队全局偏好与残留数据。 + "scenic_queue_tts_enabled", + "scenic_queue_background_poll_enabled", + "scenic_queue_selected_spot_id", + "scenic_queue_selected_spot_name", + "scenic_queue_custom_tts_text", + "scenic_queue_photo_estimate_seconds", + "scenic_queue_broadcast_interval_seconds", + "scenic_queue_countdown_threshold_seconds", + "scenic_queue_show_start_shooting_button", + "scenic_queue_auto_call_ahead_count", + "scenic_queue_quick_call_button_enabled", + "scenic_queue_prepare_call_button_enabled", + "scenic_queue_config_logs", + "scenic_queue_settings_snapshot", + "scenic_queue_preset_voices", + ] + + private static let preReleaseAccountScopedSuffixes = [ + "key_in_role_id", + "key_role_permission_list", + "key_in_permission", + "key_current_role_scenic_list", + "key_online_status", + "key_last_location_report_time", + "key_location_reminder_minutes", + "key_is_open_receive_voice", + ] + + private static let dynamicKeyPatterns = [ + #"^account_.+_scenic_[^_]+_scenic_queue_selected_spot_(id|name)$"#, + #"^account_.+_scenic_[^_]+_scenic_queue_custom_tts_text_spot_[^_]+$"#, + #"^account_.+_scenic_[^_]+_scenic_queue_settings_snapshot_spot_[^_]+$"#, + #"^account_.+_scenic_[^_]+_scenic_queue_preset_voices_spot_[^_]+$"#, + #"^scenic_[^_]+_spot_[^_]+_scenic_queue_(settings_snapshot|preset_voices)$"#, + #"^scenic_[^_]+_scenic_queue_settings_snapshot$"#, + #"^.+_role_.*_common_uris$"#, + #"^.+__scenic_queue_(punch_spot_id|punch_spot_name|punch_spot_remark|remark|settings_snapshot|offline_tts_v1|preset_voices_v1)$"#, + ] + + /// 在缓存结构版本落后时执行一次旧数据清理,并在成功后写入当前版本。 + static func migrateIfNeeded(defaults: UserDefaults) { + guard defaults.integer(forKey: schemaVersionKey) < currentSchemaVersion else { return } + + let persistedKeys = Array(defaults.dictionaryRepresentation().keys) + for key in persistedKeys where shouldRemove(key) { + defaults.removeObject(forKey: key) + } + defaults.set(currentSchemaVersion, forKey: schemaVersionKey) + } + + private static func shouldRemove(_ key: String) -> Bool { + if exactKeysToRemove.contains(key) { + return true + } + if preReleaseAccountScopedSuffixes.contains(where: { key.hasSuffix("_\($0)") }) { + return true + } + return dynamicKeyPatterns.contains { pattern in + key.range(of: pattern, options: .regularExpression) != nil + } + } +} diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index 67f36b4..8134f36 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -16,12 +16,14 @@ final class HomeCommonMenuStore { /// 读取已保存的常用 URI;无记录时返回空数组。 func savedCommonURIs(accountScope: String, roleCode: String) -> [String] { - defaults.stringArray(forKey: storageKey(accountScope: accountScope, roleCode: roleCode)) ?? [] + guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] } + return defaults.stringArray(forKey: key) ?? [] } /// 保存常用 URI 列表。 func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) { - defaults.set(uris, forKey: storageKey(accountScope: accountScope, roleCode: roleCode)) + guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return } + defaults.set(uris, forKey: key) } /// 根据权限生成默认常用 URI(最多 4 个)。 @@ -54,7 +56,10 @@ final class HomeCommonMenuStore { return (common, more) } - private func storageKey(accountScope: String, roleCode: String) -> String { - "\(accountScope)_role_\(roleCode)_common_uris" + private func storageKey(accountScope: String, roleCode: String) -> String? { + let normalizedScope = accountScope.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRoleCode = roleCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedScope.isEmpty, !normalizedRoleCode.isEmpty else { return nil } + return "\(normalizedScope)_role_\(normalizedRoleCode)_common_uris" } } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index d1dc349..879258b 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -113,6 +113,9 @@ final class HomeViewModel { appStore.permissions.saveRolePermissionList(rolePermissionList) let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0] + if !matched.role.roleCode.isEmpty { + appStore.session.roleCode = matched.role.roleCode + } if !matched.role.name.isEmpty { appStore.session.roleName = matched.role.name } diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index c44e5f5..fdc5118 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -116,12 +116,12 @@ final class LoginViewController: BaseViewController { } welcomeLabel.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(48) + make.top.equalTo(view.safeAreaLayoutGuide).offset(50) make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24) } formCardShadowView.snp.makeConstraints { make in - make.top.equalTo(welcomeLabel.snp.bottom).offset(48) + make.top.equalTo(welcomeLabel.snp.bottom).offset(50) make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24) make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-24) } diff --git a/suixinkanTests/AppStoreCacheMigrationTests.swift b/suixinkanTests/AppStoreCacheMigrationTests.swift new file mode 100644 index 0000000..79be0cb --- /dev/null +++ b/suixinkanTests/AppStoreCacheMigrationTests.swift @@ -0,0 +1,160 @@ +// +// AppStoreCacheMigrationTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 验证覆盖升级清理、强制退出以及缓存结构版本幂等性。 +final class AppStoreCacheMigrationTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "AppStoreCacheMigrationTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testFirstInitializationClearsLegacyAndPreReleaseKeysAndForcesLogout() { + let exactKeysToRemove = [ + "suixinkan.session.v1", + "apns_device_token", + "apns_uploaded_token", + "home.common.menu.uris", + "home.common.menu.android.baseline.v2", + "home.routing.unknown.records", + "payment.voice.broadcast.enabled", + "key_in_token", + "key_last_login_username", + "key_privacy_agreement_accepted", + "key_in_user_id", + "key_in_user_name", + "key_in_real_name", + "key_in_avatar", + "key_in_phone", + "key_in_account_type", + "key_in_account_display_name", + "key_in_role_id", + "key_in_role_code", + "key_in_role_name", + "key_in_current_scenic_id", + "key_in_current_scenic_name", + "key_in_current_store_id", + "key_role_permission_list", + "key_in_permission", + "key_current_role_scenic_list", + "key_online_status", + "key_last_location_report_time", + "key_location_reminder_minutes", + "key_is_open_receive_voice", + "scenic_queue_tts_enabled", + "scenic_queue_background_poll_enabled", + "scenic_queue_selected_spot_id", + "scenic_queue_selected_spot_name", + "scenic_queue_custom_tts_text", + "scenic_queue_photo_estimate_seconds", + "scenic_queue_broadcast_interval_seconds", + "scenic_queue_countdown_threshold_seconds", + "scenic_queue_show_start_shooting_button", + "scenic_queue_auto_call_ahead_count", + "scenic_queue_quick_call_button_enabled", + "scenic_queue_prepare_call_button_enabled", + "scenic_queue_config_logs", + "scenic_queue_settings_snapshot", + "scenic_queue_preset_voices", + ] + let dynamicKeysToRemove = [ + "account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_id", + "account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_name", + "account_store_user_1001_31_22_scenic_22_scenic_queue_custom_tts_text_spot_33", + "account_store_user_1001_31_22_scenic_22_scenic_queue_settings_snapshot_spot_33", + "account_store_user_1001_31_22_scenic_22_scenic_queue_preset_voices_spot_33", + "scenic_22_spot_33_scenic_queue_settings_snapshot", + "scenic_22_spot_33_scenic_queue_preset_voices", + "scenic_22_scenic_queue_settings_snapshot", + "1001_store_user_key_in_role_id", + "1001_store_user_key_role_permission_list", + "1001_store_user_key_in_permission", + "1001_store_user_key_current_role_scenic_list", + "1001_store_user_key_online_status", + "1001_store_user_key_last_location_report_time", + "1001_store_user_key_location_reminder_minutes", + "1001_store_user_key_is_open_receive_voice", + "1001_store_user_role__common_uris", + "1001_store_user_role_photographer_common_uris", + "1001_store_user__scenic_queue_punch_spot_id", + "1001_store_user__scenic_queue_punch_spot_name", + "1001_store_user__scenic_queue_punch_spot_remark", + "1001_store_user__scenic_queue_remark", + "1001_store_user__scenic_queue_settings_snapshot", + "1001_store_user__scenic_queue_offline_tts_v1", + "1001_store_user__scenic_queue_preset_voices_v1", + ] + let keysToPreserve = [ + "third_party.sentinel", + "home.common.menu.uris.extra", + "account_store_user_1001_31_22_scenic_22_scenic_queue_selected_spot_identifier", + "account_store_user_1001_31_22_scenic_22_scenic_queue_custom_tts_text_spot_33_backup", + "scenic_22_spot_33_scenic_queue_settings_snapshot_backup", + "unrelated_key_in_permission_extra", + "scenic_user_1001_key_in_role_code", + "scenic_user_1001_scenic_22_scenic_queue_punch_spot_id", + "scenic_user_1001_scenic_22_spot_33_scenic_queue_settings_snapshot", + ] + let keysToRemove = exactKeysToRemove + dynamicKeysToRemove + keysToRemove.forEach { defaults.set("legacy", forKey: $0) } + keysToPreserve.forEach { defaults.set("keep", forKey: $0) } + defaults.set(true, forKey: "key_privacy_agreement_accepted") + + let store = AppStore(defaults: defaults) + + XCTAssertFalse(store.session.isLoggedIn) + XCTAssertNil(store.session.lastLoginUsername) + XCTAssertFalse(store.session.privacyAgreementAccepted) + keysToRemove.forEach { XCTAssertNil(defaults.object(forKey: $0), "未清理 Key:\($0)") } + keysToPreserve.forEach { + XCTAssertEqual(defaults.string(forKey: $0), "keep", "误清理 Key:\($0)") + } + XCTAssertEqual( + defaults.integer(forKey: AppStoreCacheMigrator.schemaVersionKey), + AppStoreCacheMigrator.currentSchemaVersion + ) + } + + func testMigrationRunsOnlyOnceAndPreservesNewSession() { + defaults.set("keep", forKey: "third_party.sentinel") + var store = AppStore(defaults: defaults) + store.session.token = "new-token" + store.session.lastLoginUsername = "13800000000" + store.session.privacyAgreementAccepted = true + store.session.userId = "1001" + store.session.accountType = .scenicUser + store.session.roleCode = "photographer" + store.session.currentScenicId = 22 + store.scenicQueue.punchSpotId = "33" + store.scenicQueue.punchSpotName = "东门" + + store = AppStore(defaults: defaults) + + XCTAssertTrue(store.session.isLoggedIn) + XCTAssertEqual(store.session.token, "new-token") + XCTAssertEqual(store.session.lastLoginUsername, "13800000000") + XCTAssertTrue(store.session.privacyAgreementAccepted) + XCTAssertEqual(store.session.roleCode, "photographer") + XCTAssertEqual(store.session.currentScenicId, 22) + XCTAssertEqual(store.scenicQueue.punchSpotId, "33") + XCTAssertEqual(store.scenicQueue.punchSpotName, "东门") + XCTAssertEqual(defaults.string(forKey: "third_party.sentinel"), "keep") + } +} diff --git a/suixinkanTests/AppStoreModuleTests.swift b/suixinkanTests/AppStoreModuleTests.swift index 98e3d85..839a902 100644 --- a/suixinkanTests/AppStoreModuleTests.swift +++ b/suixinkanTests/AppStoreModuleTests.swift @@ -38,7 +38,7 @@ final class AppStoreModuleTests: XCTestCase { XCTAssertEqual(AppAccountType.unknown("future_account").rawValue, "future_account") } - func testSessionUsesExistingKeysAndPreservesUnknownAccountType() { + func testSessionUsesGlobalIdentityAndScopedBusinessContext() { defaults.set("legacy-token", forKey: "key_in_token") defaults.set("future_account", forKey: "key_in_account_type") @@ -46,11 +46,37 @@ final class AppStoreModuleTests: XCTestCase { XCTAssertEqual(appStore.session.accountType, .unknown("future_account")) appStore.session.userId = "1001" + appStore.session.roleCode = "future_role" appStore.session.currentScenicId = 22 + appStore.session.currentScenicName = "测试景区" + appStore.session.currentStoreId = 31 XCTAssertEqual(defaults.string(forKey: "key_in_user_id"), "1001") - XCTAssertEqual(defaults.string(forKey: "key_in_current_scenic_id"), "22") - XCTAssertEqual(appStore.session.accountCachePrefix, "1001_future_account") + XCTAssertNil(defaults.object(forKey: "key_in_current_scenic_id")) + XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_role_code"), "future_role") + XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_current_scenic_id"), "22") + XCTAssertEqual(defaults.string(forKey: "future_account_1001_key_in_current_scenic_name"), "测试景区") + XCTAssertEqual(defaults.integer(forKey: "future_account_1001_scenic_22_scenic_store_id"), 31) + XCTAssertEqual(appStore.session.accountCachePrefix, "future_account_1001") + } + + func testIncompleteAccountScopeDoesNotWriteBusinessCache() { + appStore.session.userId = "1001" + appStore.session.currentScenicId = 22 + appStore.session.roleCode = "photographer" + appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")]) + appStore.location.onlineStatus = true + appStore.payment.isOpenReceiveVoice = true + appStore.scenicQueue.punchSpotId = "33" + + XCTAssertEqual(appStore.session.accountCachePrefix, "") + XCTAssertEqual(appStore.session.currentScenicId, 0) + XCTAssertEqual(appStore.session.roleCode, "") + XCTAssertTrue(appStore.permissions.permissionItems().isEmpty) + XCTAssertFalse(appStore.location.onlineStatus) + XCTAssertFalse(appStore.payment.isOpenReceiveVoice) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") + XCTAssertFalse(defaults.dictionaryRepresentation().keys.contains { $0.contains("guest") }) } func testFeatureStoresAreIsolatedByAccountAndDecodeSnapshots() { @@ -68,8 +94,11 @@ final class AppStoreModuleTests: XCTestCase { appStore.payment.isOpenReceiveVoice = true appStore.scenicQueue.punchSpotId = "33" appStore.scenicQueue.punchSpotName = "东门" + appStore.scenicQueue.remark = "请提前准备" appStore.scenicQueue.saveSettingsSnapshot(queueSettings) appStore.scenicQueue.savePresetVoices([" A ", "", "B"]) + XCTAssertTrue(appStore.scenicQueue.useOfflineTTS) + appStore.scenicQueue.useOfflineTTS = false selectAccount(userId: "2002", type: .storeUser) XCTAssertTrue(appStore.permissions.permissionItems().isEmpty) @@ -80,6 +109,7 @@ final class AppStoreModuleTests: XCTestCase { XCTAssertFalse(appStore.payment.isOpenReceiveVoice) XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") XCTAssertNil(appStore.scenicQueue.settingsSnapshot()) + XCTAssertTrue(appStore.scenicQueue.useOfflineTTS) selectAccount(userId: "1001", type: .scenicUser) XCTAssertEqual(appStore.permissions.permissionItems(), permissions) @@ -90,24 +120,68 @@ final class AppStoreModuleTests: XCTestCase { XCTAssertTrue(appStore.payment.isOpenReceiveVoice) XCTAssertTrue(appStore.scenicQueue.hasPunchSpotSaved()) XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门") + XCTAssertEqual(appStore.scenicQueue.remark, "请提前准备") XCTAssertEqual(appStore.scenicQueue.settingsSnapshot(), queueSettings) XCTAssertEqual(appStore.scenicQueue.presetVoices(), ["A", "B"]) + XCTAssertFalse(appStore.scenicQueue.useOfflineTTS) + } + + func testStoreAndQueueCachesAreIsolatedByScenicAndSpot() { + selectAccount(userId: "1001", type: .scenicUser, scenicId: 22) + appStore.session.currentStoreId = 220 + appStore.scenicQueue.punchSpotId = "33" + appStore.scenicQueue.punchSpotName = "东门" + appStore.scenicQueue.remark = "东门备注" + appStore.scenicQueue.useOfflineTTS = false + + appStore.scenicQueue.punchSpotId = "44" + XCTAssertEqual(appStore.scenicQueue.punchSpotName, "") + XCTAssertEqual(appStore.scenicQueue.remark, "") + appStore.scenicQueue.punchSpotName = "西门" + appStore.scenicQueue.remark = "西门备注" + + appStore.scenicQueue.punchSpotId = "33" + XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门") + XCTAssertEqual(appStore.scenicQueue.remark, "东门备注") + + appStore.session.currentScenicId = 23 + XCTAssertEqual(appStore.session.currentStoreId, 0) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") + XCTAssertTrue(appStore.scenicQueue.useOfflineTTS) + appStore.session.currentStoreId = 230 + appStore.scenicQueue.punchSpotId = "55" + appStore.scenicQueue.punchSpotName = "南门" + + appStore.session.currentScenicId = 22 + XCTAssertEqual(appStore.session.currentStoreId, 220) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "33") + XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门") + XCTAssertFalse(appStore.scenicQueue.useOfflineTTS) + + XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_scenic_queue_punch_spot_id"), "33") + XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_spot_33_scenic_queue_punch_spot_name"), "东门") + XCTAssertEqual(defaults.string(forKey: "scenic_user_1001_scenic_22_spot_33_scenic_queue_remark"), "东门备注") + XCTAssertFalse(defaults.dictionaryRepresentation().keys.contains { $0.contains("__scenic_queue") }) } func testLogoutClearsCurrentAccountModulesAndPreservesLoginPreferences() { appStore.session.lastLoginUsername = "13800000000" appStore.session.privacyAgreementAccepted = true - selectAccount(userId: "other", type: .storeUser) + selectAccount(userId: "other", type: .storeUser, scenicId: 22) appStore.scenicQueue.punchSpotId = "99" - selectAccount(userId: "current", type: .scenicUser) + selectAccount(userId: "current", type: .scenicUser, scenicId: 22) appStore.session.token = "token" appStore.permissions.legacyRoleId = 41 appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")]) appStore.location.onlineStatus = true appStore.payment.isOpenReceiveVoice = true appStore.scenicQueue.punchSpotId = "33" + appStore.scenicQueue.punchSpotName = "东门" + appStore.session.currentScenicId = 23 + appStore.scenicQueue.punchSpotId = "44" + appStore.scenicQueue.punchSpotName = "西门" appStore.logout() @@ -116,19 +190,22 @@ final class AppStoreModuleTests: XCTestCase { XCTAssertEqual(appStore.session.lastLoginUsername, "13800000000") XCTAssertTrue(appStore.session.privacyAgreementAccepted) - selectAccount(userId: "current", type: .scenicUser) + selectAccount(userId: "current", type: .scenicUser, scenicId: 22) XCTAssertEqual(appStore.permissions.legacyRoleId, 0) XCTAssertTrue(appStore.permissions.permissionItems().isEmpty) XCTAssertFalse(appStore.location.onlineStatus) XCTAssertFalse(appStore.payment.isOpenReceiveVoice) XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") + appStore.session.currentScenicId = 23 + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") - selectAccount(userId: "other", type: .storeUser) + selectAccount(userId: "other", type: .storeUser, scenicId: 22) XCTAssertEqual(appStore.scenicQueue.punchSpotId, "99") } - private func selectAccount(userId: String, type: AppAccountType) { + private func selectAccount(userId: String, type: AppAccountType, scenicId: Int = 22) { appStore.session.userId = userId appStore.session.accountType = type + appStore.session.currentScenicId = scenicId } } diff --git a/suixinkanTests/AppStoreTestSupport.swift b/suixinkanTests/AppStoreTestSupport.swift new file mode 100644 index 0000000..e4d9942 --- /dev/null +++ b/suixinkanTests/AppStoreTestSupport.swift @@ -0,0 +1,22 @@ +// +// AppStoreTestSupport.swift +// suixinkanTests +// + +@testable import suixinkan + +extension AppSessionStore { + + /// 为需要账号作用域缓存的测试建立完整且稳定的账号上下文。 + func configureTestAccount( + userId: String = "test-user", + accountType: AppAccountType = .scenicUser, + scenicId: Int? = nil + ) { + self.userId = userId + self.accountType = accountType + if let scenicId { + currentScenicId = scenicId + } + } +} diff --git a/suixinkanTests/CooperationAcquirerViewModelTests.swift b/suixinkanTests/CooperationAcquirerViewModelTests.swift index 7f3304b..e34ea4f 100644 --- a/suixinkanTests/CooperationAcquirerViewModelTests.swift +++ b/suixinkanTests/CooperationAcquirerViewModelTests.swift @@ -16,6 +16,7 @@ final class CooperationAcquirerViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "CooperationAcquirerViewModelTests")! defaults.removePersistentDomain(forName: "CooperationAcquirerViewModelTests") appStore = AppStore(defaults: defaults) + appStore.session.configureTestAccount(accountType: .storeUser, scenicId: 10) appStore.session.currentStoreId = 5 } diff --git a/suixinkanTests/DepositOrderListViewModelTests.swift b/suixinkanTests/DepositOrderListViewModelTests.swift index 52a2ca7..f94896a 100644 --- a/suixinkanTests/DepositOrderListViewModelTests.swift +++ b/suixinkanTests/DepositOrderListViewModelTests.swift @@ -9,9 +9,18 @@ import XCTest @MainActor final class DepositOrderListViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + AppStore.shared.logout() + AppStore.shared.session.configureTestAccount( + userId: "deposit-order-user", + accountType: .storeUser, + scenicId: 10 + ) + } + override func tearDown() { - AppStore.shared.session.currentStoreId = 0 - AppStore.shared.session.currentScenicId = 0 + AppStore.shared.logout() super.tearDown() } @@ -61,4 +70,4 @@ final class DepositOrderListViewModelTests: XCTestCase { let other = try JSONDecoder().decode(StoreOrderItem.self, from: otherJSON) XCTAssertFalse(viewModel.canOpenDetail(for: other)) } -} \ No newline at end of file +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index f5652d8..3839379 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -38,14 +38,26 @@ final class HomeCommonMenuStoreTests: XCTestCase { HomePermissionItem(id: "3", name: "任务", uri: "task_management"), ] let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) - store.saveCommonURIs(["cloud_management"], accountScope: "u1_scenic_user", roleCode: "photographer") + store.saveCommonURIs(["cloud_management"], accountScope: "scenic_user_u1", roleCode: "photographer") let common = store.commonMenus( from: allMenus, permissions: permissions, - accountScope: "u1_scenic_user", + accountScope: "scenic_user_u1", roleCode: "photographer" ) XCTAssertEqual(common.map(\.uri), ["cloud_management"]) + XCTAssertEqual( + defaults.stringArray(forKey: "scenic_user_u1_role_photographer_common_uris"), + ["cloud_management"] + ) + } + + func testIncompleteScopeDoesNotPersistCommonURIs() { + store.saveCommonURIs(["wallet"], accountScope: "", roleCode: "photographer") + store.saveCommonURIs(["wallet"], accountScope: "scenic_user_u1", roleCode: "") + + XCTAssertNil(defaults.object(forKey: "_role_photographer_common_uris")) + XCTAssertNil(defaults.object(forKey: "scenic_user_u1_role__common_uris")) } func testSplitMenusSeparatesCommonAndMore() { diff --git a/suixinkanTests/LocationProviderTests.swift b/suixinkanTests/LocationProviderTests.swift index 806bc31..6f9f4b1 100644 --- a/suixinkanTests/LocationProviderTests.swift +++ b/suixinkanTests/LocationProviderTests.swift @@ -18,6 +18,7 @@ final class LocationProviderTests: XCTestCase { defaults = UserDefaults(suiteName: "LocationProviderTests")! defaults.removePersistentDomain(forName: "LocationProviderTests") appStore = AppStore(defaults: defaults) + appStore.session.configureTestAccount() } func testScenicSelectionUsesInjectedProviderSnapshot() async { diff --git a/suixinkanTests/LocationReportServiceTests.swift b/suixinkanTests/LocationReportServiceTests.swift index 03f5153..d767ac7 100644 --- a/suixinkanTests/LocationReportServiceTests.swift +++ b/suixinkanTests/LocationReportServiceTests.swift @@ -17,8 +17,7 @@ final class LocationReportServiceTests: XCTestCase { defaults = UserDefaults(suiteName: "LocationReportServiceTests")! defaults.removePersistentDomain(forName: "LocationReportServiceTests") appStore = AppStore(defaults: defaults) - appStore.session.userId = "staff-1" - appStore.session.currentScenicId = 100 + appStore.session.configureTestAccount(userId: "staff-1", scenicId: 100) } override func tearDown() { diff --git a/suixinkanTests/OrderListViewModelTests.swift b/suixinkanTests/OrderListViewModelTests.swift index f8404ba..e2fef2e 100644 --- a/suixinkanTests/OrderListViewModelTests.swift +++ b/suixinkanTests/OrderListViewModelTests.swift @@ -9,10 +9,14 @@ import XCTest @MainActor final class OrderListViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + AppStore.shared.logout() + AppStore.shared.session.configureTestAccount(userId: "order-list-user", scenicId: 10) + } + override func tearDown() { - AppStore.shared.session.roleCode = "" - AppStore.shared.session.roleName = "" - AppStore.shared.session.currentScenicId = 0 + AppStore.shared.logout() super.tearDown() } diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift index 4d078a0..c7da538 100644 --- a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -16,8 +16,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")! defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests") appStore = AppStore(defaults: defaults) - appStore.session.userId = "10001" - appStore.session.currentScenicId = 10 + appStore.session.configureTestAccount(userId: "10001", scenicId: 10) appStore.session.currentScenicName = "测试景区" } diff --git a/suixinkanTests/PaymentCollectionRecordViewModelTests.swift b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift index d06329a..7ed09b8 100644 --- a/suixinkanTests/PaymentCollectionRecordViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift @@ -54,7 +54,7 @@ final class PaymentCollectionRecordViewModelTests: XCTestCase { let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")! defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests") let appStore = AppStore(defaults: defaults) - appStore.session.currentScenicId = 10 + appStore.session.configureTestAccount(scenicId: 10) let recordJSON = """ {"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}} diff --git a/suixinkanTests/PermissionApplyViewModelTests.swift b/suixinkanTests/PermissionApplyViewModelTests.swift index 6507f69..4acbf05 100644 --- a/suixinkanTests/PermissionApplyViewModelTests.swift +++ b/suixinkanTests/PermissionApplyViewModelTests.swift @@ -17,6 +17,7 @@ final class PermissionApplyViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "PermissionApplyViewModelTests")! defaults.removePersistentDomain(forName: "PermissionApplyViewModelTests") appStore = AppStore(defaults: defaults) + appStore.session.configureTestAccount() appStore.permissions.saveRolePermissionList([ RolePermissionResponse( role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"), diff --git a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift index d3b415a..f2036d7 100644 --- a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift +++ b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift @@ -20,14 +20,16 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")! defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") appStore = AppStore(defaults: defaults) - appStore.session.currentScenicId = 11 + appStore.session.configureTestAccount(scenicId: 11) + AppStore.shared.logout() + AppStore.shared.session.configureTestAccount(userId: "profile-space-shared", scenicId: 11) calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))! } override func tearDown() { - AppStore.shared.session.currentScenicId = 0 + AppStore.shared.logout() appStore.logout() defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") super.tearDown() diff --git a/suixinkanTests/ProfileViewModelTests.swift b/suixinkanTests/ProfileViewModelTests.swift index 4b58ca8..0b3225c 100644 --- a/suixinkanTests/ProfileViewModelTests.swift +++ b/suixinkanTests/ProfileViewModelTests.swift @@ -9,6 +9,18 @@ import XCTest @MainActor /// 个人信息页 ViewModel 测试。 final class ProfileViewModelTests: XCTestCase { + + override func setUp() { + super.setUp() + AppStore.shared.logout() + AppStore.shared.session.configureTestAccount(userId: "profile-user") + } + + override func tearDown() { + AppStore.shared.logout() + super.tearDown() + } + func testDisplayNicknameUsesFallbackWhenEmpty() { let viewModel = ProfileViewModel() XCTAssertEqual(viewModel.displayNickname, "未设置昵称") diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift index 830ef37..2728384 100644 --- a/suixinkanTests/ScenicQueueFeatureTests.swift +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -250,6 +250,7 @@ final class ScenicQueueFeatureTests: XCTestCase { store.session.userId = "1" store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 store.scenicQueue.punchSpotId = "22" store.scenicQueue.punchSpotName = "东门打卡点" store.scenicQueue.remark = "播报" @@ -258,16 +259,19 @@ final class ScenicQueueFeatureTests: XCTestCase { store.session.userId = "2" store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 XCTAssertEqual(store.scenicQueue.punchSpotId, "") XCTAssertNil(store.scenicQueue.settingsSnapshot()) store.session.userId = "1" store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 XCTAssertEqual(store.scenicQueue.punchSpotId, "22") XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"]) store.logout() store.session.userId = "1" store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 XCTAssertEqual(store.scenicQueue.punchSpotId, "") XCTAssertNil(store.scenicQueue.settingsSnapshot()) } @@ -323,6 +327,8 @@ final class ScenicQueueFeatureTests: XCTestCase { let store = AppStore(defaults: defaults) store.session.userId = "u1" store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + XCTAssertTrue(store.scenicQueue.useOfflineTTS) let tts = MockScenicQueueTTSManager() let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) var messages: [String] = [] diff --git a/suixinkanTests/ScenicSelectionViewModelTests.swift b/suixinkanTests/ScenicSelectionViewModelTests.swift index 2902927..d82798b 100644 --- a/suixinkanTests/ScenicSelectionViewModelTests.swift +++ b/suixinkanTests/ScenicSelectionViewModelTests.swift @@ -16,6 +16,7 @@ final class ScenicSelectionViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "ScenicSelectionViewModelTests")! defaults.removePersistentDomain(forName: "ScenicSelectionViewModelTests") appStore = AppStore(defaults: defaults) + appStore.session.configureTestAccount() } override func tearDown() { diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift index 374a408..56e45db 100644 --- a/suixinkanTests/StatisticsViewModelTests.swift +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -10,11 +10,14 @@ import XCTest /// 数据统计 ViewModel 测试。 final class StatisticsViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + AppStore.shared.logout() + AppStore.shared.session.configureTestAccount(userId: "statistics-user", scenicId: 10) + } + override func tearDown() { - AppStore.shared.session.roleCode = "" - AppStore.shared.session.roleName = "" - AppStore.shared.session.currentScenicId = 0 - AppStore.shared.session.currentStoreId = 0 + AppStore.shared.logout() super.tearDown() } diff --git a/suixinkanTests/TaskAddViewModelTests.swift b/suixinkanTests/TaskAddViewModelTests.swift index 1a0ff8e..08db5b6 100644 --- a/suixinkanTests/TaskAddViewModelTests.swift +++ b/suixinkanTests/TaskAddViewModelTests.swift @@ -17,7 +17,7 @@ final class TaskAddViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "TaskAddViewModelTests")! defaults.removePersistentDomain(forName: "TaskAddViewModelTests") appStore = AppStore(defaults: defaults) - appStore.session.currentScenicId = 10 + appStore.session.configureTestAccount(scenicId: 10) } func testValidateRequiresTaskName() async { diff --git a/suixinkanTests/TaskOrderSelectViewModelTests.swift b/suixinkanTests/TaskOrderSelectViewModelTests.swift index d7ae4fc..28367c4 100644 --- a/suixinkanTests/TaskOrderSelectViewModelTests.swift +++ b/suixinkanTests/TaskOrderSelectViewModelTests.swift @@ -17,7 +17,7 @@ final class TaskOrderSelectViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")! defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests") appStore = AppStore(defaults: defaults) - appStore.session.currentScenicId = 10 + appStore.session.configureTestAccount(scenicId: 10) } func testToggleSelectionSelectsAndDeselects() {