升级 AppStore 缓存 Key 并在覆盖安装时强制重新登录。

统一账号作用域 Key 对齐 Android,新增缓存 schema 迁移清理旧数据,并同步更新相关模块与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 09:35:55 +08:00
parent 05804ba7d6
commit c083f1d4b3
31 changed files with 852 additions and 123 deletions

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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: Encodable>(_ 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<Value: Decodable>(_ 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)
}
}

View File

@ -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)
}
}

View File

@ -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) }
}
/// ID0
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) }
}
/// ID0
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)
}
}

View File

@ -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)
}
}

View File

@ -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<String> = [
// 线
"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
}
}
}

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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)
}