// // HomeCommonMenuStore.swift // suixinkan // // Created by Codex on 2026/6/22. // import Foundation /// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。 struct HomeCommonMenuStore { static let storageKeyPrefix = "home.common.menu.uris.role." static let accountScopedStorageKeyPrefix = "home.common.menu.uris.account." /// 与 Android `Constants.menuList` 对齐的首页菜单 URI 白名单。 static let androidHomeMenuURIs: Set = [ "space_settings", "wallet", "cloud_management", "asset_management", "task_management", "schedule_management", "system_settings", "message_center", "checkin_points", "sample_management", "live_stream_management", "verification_order", "live_album", "travel_album", "pm", "location_report", "report_photographer", "registration_invitation", "store", "fly", "pilot_cert", "task_management_editor", "pm_manager", "pilot_controller", "/scenic-queue", "operating-area", "/scenic-order-manage", "cooperation_order" ] private let defaults: UserDefaults /// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。 init(defaults: UserDefaults = .standard) { self.defaults = defaults } /// 按 Android 规则生成默认常用 URI:先取顶层前 4 个 URI,再与 menuList 白名单求交。 static func defaultCommonURIs(orderedTopLevelURIs: [String]) -> [String] { let rawTopLevel = orderedTopLevelURIs.compactMap { uri -> String? in let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } let candidates = rawTopLevel.count > 4 ? Array(rawTopLevel.prefix(4)) : rawTopLevel return candidates.filter { isAndroidHomeMenuURI($0) } } /// 返回顶层权限中属于 Android menuList 的 URI,顺序与 API 一致。 static func availableTopLevelMenuURIs(from orderedTopLevelURIs: [String]) -> [String] { orderedTopLevelURIs.compactMap { uri -> String? in let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, isAndroidHomeMenuURI(trimmed) else { return nil } return trimmed } } /// 读取常用 URI;无角色时返回空,有 saved 时精确匹配且不回退默认。 func load( menuItems: [HomeMenuItem], roleCode: String?, accountScope: String? = nil, legacyRoleId: Int? = nil, topLevelPermissionURIs: [String] ) -> [String] { guard let roleCode, !roleCode.isEmpty else { return [] } migrateLegacyStorageIfNeeded(roleCode: roleCode, accountScope: accountScope, legacyRoleId: legacyRoleId) let rawTopLevel = topLevelPermissionURIs.compactMap { uri -> String? in let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } let availableTopLevel = Self.availableTopLevelMenuURIs(from: rawTopLevel) let availableTopLevelSet = Set(availableTopLevel) let key = Self.storageKey(roleCode: roleCode, accountScope: accountScope) let saved = defaults.stringArray(forKey: key) ?? [] let defaultCommon = Self.defaultCommonURIs(orderedTopLevelURIs: rawTopLevel) HomeCommonMenuDiagnostics.log( step: "context", fields: [ "roleCode": roleCode, "accountScope": accountScope ?? "", "storageKey": key ] ) HomeCommonMenuDiagnostics.log( step: "rawTopLevel", fields: [ "count": String(rawTopLevel.count), "uris": HomeCommonMenuDiagnostics.formatURIList(rawTopLevel) ] ) HomeCommonMenuDiagnostics.log( step: "availableFunctions", fields: [ "count": String(availableTopLevel.count), "uris": HomeCommonMenuDiagnostics.formatURIList(availableTopLevel) ] ) HomeCommonMenuDiagnostics.log( step: "savedCommon", fields: [ "uris": HomeCommonMenuDiagnostics.formatURIList(saved) ] ) HomeCommonMenuDiagnostics.log( step: "defaultCommon", fields: [ "uris": HomeCommonMenuDiagnostics.formatURIList(defaultCommon) ] ) let finalCommon: [String] if !saved.isEmpty { finalCommon = saved.filter { availableTopLevelSet.contains($0) } } else { finalCommon = defaultCommon save(finalCommon, roleCode: roleCode, accountScope: accountScope) } HomeCommonMenuDiagnostics.log( step: "finalCommon", fields: [ "count": String(finalCommon.count), "uris": HomeCommonMenuDiagnostics.formatURIList(finalCommon) ] ) _ = menuItems return finalCommon } /// 将指定 URI 加入常用应用,使用精确 URI 匹配。 func add( _ uri: String, current: [String], topLevelPermissionURIs: [String], roleCode: String?, accountScope: String? = nil ) -> [String] { guard let roleCode, !roleCode.isEmpty else { return current } let availableTopLevel = Set(Self.availableTopLevelMenuURIs(from: topLevelPermissionURIs)) let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) guard availableTopLevel.contains(trimmed), !current.contains(trimmed) else { return current } let next = current + [trimmed] save(next, roleCode: roleCode, accountScope: accountScope) return next } /// 从常用应用中移除指定 URI,使用精确 URI 匹配。 func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] { guard let roleCode, !roleCode.isEmpty else { return current } let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) let next = current.filter { $0 != trimmed } save(next, roleCode: roleCode, accountScope: accountScope) return next } /// 保存指定角色的常用 URI。 func save(_ uris: [String], roleCode: String, accountScope: String? = nil) { defaults.set(uris, forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope)) } /// 若账号级 key 无数据,则从旧 roleCode 或 legacy role id key 一次性迁移常用菜单。 func migrateLegacyStorageIfNeeded(roleCode: String, accountScope: String? = nil, legacyRoleId: Int?) { let newKey = Self.storageKey(roleCode: roleCode, accountScope: accountScope) guard defaults.stringArray(forKey: newKey) == nil else { return } let legacyKeys = [ legacyStorageKey(for: roleCode), legacyRoleId.map { legacyStorageKey(for: $0) } ].compactMap { $0 } for legacyKey in legacyKeys { guard let legacyData = defaults.stringArray(forKey: legacyKey), !legacyData.isEmpty else { continue } defaults.set(legacyData, forKey: newKey) defaults.removeObject(forKey: legacyKey) return } } /// 生成按账号和 role_code 隔离的 UserDefaults key。 static func storageKey(roleCode: String, accountScope: String?) -> String { let normalizedScope = accountScope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if normalizedScope.isEmpty { return storageKeyPrefix + roleCode } return accountScopedStorageKeyPrefix + normalizedScope + ".role." + roleCode } /// 旧版按 numeric role id 存储的 key。 private func legacyStorageKey(for roleId: Int) -> String { Self.storageKeyPrefix + String(roleId) } /// 旧版按 role_code 存储的 key。 private func legacyStorageKey(for roleCode: String) -> String { Self.storageKeyPrefix + roleCode } /// 判断 URI 是否属于 Android 首页菜单白名单。 private static func isAndroidHomeMenuURI(_ uri: String) -> Bool { androidHomeMenuURIs.contains(uri) } }