From 5bdf4a7dbfee4cfb78f75bca04b7b6b7fba47f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 29 Jun 2026 14:59:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20Android=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=A1=B5=E5=88=B0=20iOS=EF=BC=8C=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=E5=86=B7=E5=90=AF=E5=8A=A8=E6=9C=9F=E9=97=B4=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=99=BB=E5=BD=95=E6=80=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/App/App.md | 36 +++++--- suixinkan/App/AppServiceEnvironment.swift | 9 ++ suixinkan/App/RootView.swift | 16 +++- suixinkan/App/SplashCoordinator.swift | 45 +++++++++ .../App/State/AuthSessionCoordinator.swift | 2 +- .../LaunchBackground.colorset/Contents.json | 20 ++++ .../SplashLogo.imageset/Contents.json | 21 +++++ .../SplashLogo.imageset/icon_splash.png | Bin 0 -> 26356 bytes suixinkan/Core/Core.md | 6 +- .../Core/Storage/SessionTokenStore.swift | 72 +++------------ suixinkan/Core/Upload/Upload.md | 2 +- .../Features/Auth/API/AppConfigAPI.swift | 26 ++++++ suixinkan/Features/Auth/Auth.md | 2 +- .../Auth/Models/AppConfigModels.swift | 22 +++++ .../Auth/ViewModels/LoginViewModel.swift | 11 +++ suixinkan/Features/Auth/Views/LoginView.swift | 2 + suixinkan/Features/Splash/Splash.md | 35 +++++++ .../Features/Splash/Views/SplashView.swift | 47 ++++++++++ suixinkan/Info.plist | 9 +- suixinkanTests/AppConfigTests.swift | 87 ++++++++++++++++++ .../ProfileSecondaryPagesTests.swift | 2 +- suixinkanTests/SplashCoordinatorTests.swift | 68 ++++++++++++++ suixinkanTests/StorageTests.swift | 23 +++++ .../Auth/ZLoginSmokeUITests.swift | 2 +- suixinkanUITests/README.md | 2 +- .../Support/SuixinkanUIApplication.swift | 11 +++ suixinkanUITests/Support/UITestSession.swift | 4 +- 功能同步Checklist.md | 2 +- 29 files changed, 501 insertions(+), 91 deletions(-) create mode 100644 suixinkan/App/SplashCoordinator.swift create mode 100644 suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png create mode 100644 suixinkan/Features/Auth/API/AppConfigAPI.swift create mode 100644 suixinkan/Features/Auth/Models/AppConfigModels.swift create mode 100644 suixinkan/Features/Splash/Splash.md create mode 100644 suixinkan/Features/Splash/Views/SplashView.swift create mode 100644 suixinkanTests/AppConfigTests.swift create mode 100644 suixinkanTests/SplashCoordinatorTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f20a244..45a585d 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -324,14 +328,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index 0ba5e32..c85a7df 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -28,20 +28,34 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 1. `suixinkanApp` 创建 `RootView`。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。 -3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 -4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 -5. 无 token 时保持 `loggedOut`,展示 `LoginView`。 -6. 有 token 时进入 `restoring`,先恢复本地账号快照。 -7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 -8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。 -9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 -10. 明确 token 失效时清空 token 和账号快照,回到登录页。 -11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 +3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。 +4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。 +5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示和 `SessionBootstrapper.restore`;UI Test 模式跳过 1.5 秒延迟。 +6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 +7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。 +8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。 +9. 有 token 时进入 `restoring`,先恢复本地账号快照。 +10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 +11. 校验成功后进入 `loggedIn`,Splash 结束后展示 `MainTabsView`。 +12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 +13. 明确 token 失效时清空 token 和账号快照,回到登录页。 +14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 +15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;Splash 即启动等待层。 +16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。 + +## 初始化分层 + +| 阶段 | 职责 | 主要对象 | +|------|------|----------| +| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp`、`AppUITestLaunchState` | +| Splash | 品牌展示 + 登录态恢复 | `SplashView`、`SplashCoordinator`、`SessionBootstrapper` | +| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` | +| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` | ## UI Test 启动约定 - `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。 -- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。 +- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 UserDefaults 中的 token、账号快照和偏好。 - `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。 - `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。 - `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。 @@ -58,7 +72,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `AppSession` 切换为 `loggedIn`。 退出登录由 `AuthSessionCoordinator.logout` 统一处理: -- 清空 Keychain token。 +- 清空本地 token。 - 清空账号快照。 - 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 - `AppSession` 切换为 `loggedOut`。 diff --git a/suixinkan/App/AppServiceEnvironment.swift b/suixinkan/App/AppServiceEnvironment.swift index 11fa053..712b597 100644 --- a/suixinkan/App/AppServiceEnvironment.swift +++ b/suixinkan/App/AppServiceEnvironment.swift @@ -45,6 +45,10 @@ private struct AuthAPIEnvironmentKey: EnvironmentKey { static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) } } +private struct AppConfigAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: AppConfigAPI { AppConfigAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + private struct InviteAPIEnvironmentKey: EnvironmentKey { static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) } } @@ -168,6 +172,11 @@ extension EnvironmentValues { set { self[AuthAPIEnvironmentKey.self] = newValue } } + var appConfigAPI: AppConfigAPI { + get { self[AppConfigAPIEnvironmentKey.self] } + set { self[AppConfigAPIEnvironmentKey.self] = newValue } + } + var inviteAPI: InviteAPI { get { self[InviteAPIEnvironmentKey.self] } set { self[InviteAPIEnvironmentKey.self] = newValue } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 6108e0e..a9d1c0e 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -47,6 +47,8 @@ struct RootView: View { @State private var cooperationOrderAPI: CooperationOrderAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper + @StateObject private var splashCoordinator = SplashCoordinator() + @State private var appConfigAPI: AppConfigAPI /// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。 init() { @@ -56,6 +58,7 @@ struct RootView: View { _snapshotStore = State(initialValue: snapshotStore) _apiClient = State(initialValue: apiClient) _authAPI = State(initialValue: AuthAPI(client: apiClient)) + _appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient)) _profileAPI = State(initialValue: ProfileAPI(client: apiClient)) let uploadAPI = UploadAPI(client: apiClient) _uploadAPI = State(initialValue: uploadAPI) @@ -97,7 +100,15 @@ struct RootView: View { } var body: some View { - rootContent + ZStack { + rootContent + + if !splashCoordinator.isFinished { + SplashView() + .transition(.opacity) + .zIndex(1) + } + } .globalToastOverlay() .globalLoadingOverlay(loadingCenter: globalLoading) .environmentObject(appSession) @@ -110,6 +121,7 @@ struct RootView: View { .environment(\.accountSnapshotStore, snapshotStore) .environment(\.apiClient, apiClient) .environment(\.authAPI, authAPI) + .environment(\.appConfigAPI, appConfigAPI) .environment(\.profileAPI, profileAPI) .environment(\.uploadAPI, uploadAPI) .environment(\.ossUploadService, ossUploadService) @@ -139,7 +151,7 @@ struct RootView: View { .task { apiClient.bindAuthTokenProvider { appSession.token } PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) - await globalLoading.withLoading { + await splashCoordinator.start { await sessionBootstrapper.restore( appSession: appSession, accountContext: accountContext, diff --git a/suixinkan/App/SplashCoordinator.swift b/suixinkan/App/SplashCoordinator.swift new file mode 100644 index 0000000..0382c93 --- /dev/null +++ b/suixinkan/App/SplashCoordinator.swift @@ -0,0 +1,45 @@ +// +// SplashCoordinator.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Combine +import Foundation + +@MainActor +/// 启动页时序协调器,保证品牌页最短展示时长与冷启动 bootstrap 并行完成。 +final class SplashCoordinator: ObservableObject { + @Published private(set) var isFinished = false + + private let minimumDisplayDuration: TimeInterval + private let skipsMinimumDuration: Bool + + /// 创建启动页协调器,并配置最短展示时长与 UI Test 跳过策略。 + init( + minimumDisplayDuration: TimeInterval = 1.5, + skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests + ) { + self.minimumDisplayDuration = minimumDisplayDuration + self.skipsMinimumDuration = skipsMinimumDuration + } + + /// 并行执行 bootstrap 与最短展示时长,两者都完成后结束 Splash。 + func start(bootstrap: @escaping () async -> Void) async { + guard !isFinished else { return } + + async let minimumDelay: Void = waitForMinimumDisplayDuration() + async let bootstrapWork: Void = bootstrap() + + _ = await (minimumDelay, bootstrapWork) + isFinished = true + } + + /// 等待最短展示时长;UI Test 模式下直接返回。 + private func waitForMinimumDisplayDuration() async { + guard !skipsMinimumDuration else { return } + let nanoseconds = UInt64(max(minimumDisplayDuration, 0) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index 43b405b..411bae4 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -43,7 +43,7 @@ final class AuthSessionCoordinator { ) } - /// 完成正式登录,写入内存状态、Keychain token 和账号快照。 + /// 完成正式登录,写入内存状态、本地 token 和账号快照。 func completeLogin( with response: V9AuthResponse, username: String, diff --git a/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json b/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..97650a1 --- /dev/null +++ b/suixinkan/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json b/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json new file mode 100644 index 0000000..644d848 --- /dev/null +++ b/suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_splash.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png b/suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png new file mode 100644 index 0000000000000000000000000000000000000000..2cfbec3697a77ba44c515b51488a4519bb2bc237 GIT binary patch literal 26356 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 QU#mFUxty##e);Zy0kGY}H2?qr literal 0 HcmV?d00001 diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index fe983cf..d3bb50e 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -42,12 +42,12 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 ## Storage 本地缓存按安全级别拆分: -- `SessionTokenStore`:使用 Keychain 保存正式 token。 +- `SessionTokenStore`:使用 UserDefaults 保存正式 token。 - `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。 - `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。 缓存边界: -- 正式 token 只放 Keychain。 +- 正式 token 由 `SessionTokenStore` 写入 UserDefaults。 - 临时 token 只放内存。 - 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 - 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。 @@ -86,7 +86,7 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部 `UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 上传模块只保存内存状态: -- STS token 不写入 Keychain 或 UserDefaults。 +- STS token 不写入 UserDefaults。 - 用户选择的本地图片 Data 不落盘。 - 上传进度只用于当前页面展示。 diff --git a/suixinkan/Core/Storage/SessionTokenStore.swift b/suixinkan/Core/Storage/SessionTokenStore.swift index a24c823..b960170 100644 --- a/suixinkan/Core/Storage/SessionTokenStore.swift +++ b/suixinkan/Core/Storage/SessionTokenStore.swift @@ -6,33 +6,21 @@ // import Foundation -import Combine -import Security -/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。 -enum SessionTokenStoreError: LocalizedError { - case unexpectedStatus(OSStatus) - - var errorDescription: String? { - switch self { - case let .unexpectedStatus(status): - "登录凭证存储失败(\(status))" - } - } -} - -/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。 +/// 正式登录 token 存储服务,封装 UserDefaults 读写并保持业务层不接触缓存细节。 final class SessionTokenStore { - private let service: String - private let account: String + private let defaults: UserDefaults + private let key: String - /// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。 + /// 初始化 token 存储服务,默认按 App Bundle 和账号名隔离 UserDefaults 键。 init( + defaults: UserDefaults = .standard, + key: String? = nil, service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan", account: String = "session.token" ) { - self.service = service - self.account = account + self.defaults = defaults + self.key = key ?? "suixinkan.session.token.\(service).\(account)" } /// 保存正式 token,空 token 会被视为清空凭证。 @@ -43,38 +31,12 @@ final class SessionTokenStore { return } - let data = Data(trimmedToken.utf8) - let query = baseQuery() - let updateAttributes: [String: Any] = [kSecValueData as String: data] - - let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) - if updateStatus == errSecSuccess { - return - } - guard updateStatus == errSecItemNotFound else { - throw SessionTokenStoreError.unexpectedStatus(updateStatus) - } - - var addQuery = query - updateAttributes.forEach { addQuery[$0.key] = $0.value } - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw SessionTokenStoreError.unexpectedStatus(addStatus) - } + defaults.set(trimmedToken, forKey: key) } /// 读取本地正式 token,读取失败或无值时返回 nil。 func load() -> String? { - var query = baseQuery() - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, - let data = result as? Data, - let token = String(data: data, encoding: .utf8)? + guard let token = defaults.string(forKey: key)? .trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { return nil @@ -84,18 +46,6 @@ final class SessionTokenStore { /// 清空本地正式 token。 func clear() throws { - let status = SecItemDelete(baseQuery() as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw SessionTokenStoreError.unexpectedStatus(status) - } - } - - /// 构造当前 App 使用的 Keychain 查询条件。 - private func baseQuery() -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] + defaults.removeObject(forKey: key) } } diff --git a/suixinkan/Core/Upload/Upload.md b/suixinkan/Core/Upload/Upload.md index 6f91c45..f15d545 100644 --- a/suixinkan/Core/Upload/Upload.md +++ b/suixinkan/Core/Upload/Upload.md @@ -49,4 +49,4 @@ Upload 模块负责 App 内通用文件上传能力,当前主要服务个人 - OSS STS token 不落盘,只在一次上传流程中临时使用。 - 原始图片 Data、压缩后图片 Data、上传进度不落盘。 - 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。 -- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。 +- 正式登录 token 仍由 `SessionTokenStore` 使用 UserDefaults 保存,上传模块不直接读取或保存登录态。 diff --git a/suixinkan/Features/Auth/API/AppConfigAPI.swift b/suixinkan/Features/Auth/API/AppConfigAPI.swift new file mode 100644 index 0000000..87e046d --- /dev/null +++ b/suixinkan/Features/Auth/API/AppConfigAPI.swift @@ -0,0 +1,26 @@ +// +// AppConfigAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// App 远程配置 API,封装 `/api/app/config` 接口。 +final class AppConfigAPI { + private let client: APIClient + + /// 初始化 App 配置 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取 App 远程配置,例如是否开放注册入口。 + func fetchAppConfig() async throws -> AppConfigResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + } +} diff --git a/suixinkan/Features/Auth/Auth.md b/suixinkan/Features/Auth/Auth.md index 716d13a..8f13f0a 100644 --- a/suixinkan/Features/Auth/Auth.md +++ b/suixinkan/Features/Auth/Auth.md @@ -56,7 +56,7 @@ Auth 模块负责登录页、手机号密码登录、多账号选择和账号选 ## 缓存规则 Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责: -- 正式 token 写 Keychain。 +- 正式 token 写 UserDefaults。 - 上次手机号和协议状态写 UserDefaults。 - 账号资料、当前角色和业务作用域写入账号快照。 diff --git a/suixinkan/Features/Auth/Models/AppConfigModels.swift b/suixinkan/Features/Auth/Models/AppConfigModels.swift new file mode 100644 index 0000000..e0f5d69 --- /dev/null +++ b/suixinkan/Features/Auth/Models/AppConfigModels.swift @@ -0,0 +1,22 @@ +// +// AppConfigModels.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// App 远程配置响应,对齐 Android `AppConfigResponse`。 +struct AppConfigResponse: Decodable, Equatable { + let enableRegister: Bool + + enum CodingKeys: String, CodingKey { + case enableRegister = "enable_register" + } + + /// 创建 App 配置响应,默认关闭注册入口。 + init(enableRegister: Bool = false) { + self.enableRegister = enableRegister + } +} diff --git a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift index 426211b..1691b6f 100644 --- a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift +++ b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift @@ -78,6 +78,7 @@ final class LoginViewModel: ObservableObject { @Published var showsPassword = false @Published var showsAgreementSheet = false @Published var pendingAccountSelection: AccountSelectionPayload? + @Published private(set) var enableRegister = false var canSubmit: Bool { isValidPhone && !trimmedPassword.isEmpty @@ -124,6 +125,16 @@ final class LoginViewModel: ObservableObject { showsAgreementSheet = false } + /// 加载 App 远程配置;请求失败时保持默认值,与 Android 登录页行为一致。 + func loadAppConfig(appConfigAPI: AppConfigAPI) async { + do { + let config = try await appConfigAPI.fetchAppConfig() + enableRegister = config.enableRegister + } catch { + // 请求失败不做任何提示,保持默认值 false。 + } + } + /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 func normalizeUsernameCountryCodeIfNeeded() { let digits = username.filter(\.isNumber) diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift index d0647e1..9d4cd5b 100644 --- a/suixinkan/Features/Auth/Views/LoginView.swift +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -14,6 +14,7 @@ struct LoginView: View { @EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.authAPI) private var authAPI + @Environment(\.appConfigAPI) private var appConfigAPI @Environment(\.profileAPI) private var profileAPI @Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.authSessionCoordinator) private var authSessionCoordinator @@ -85,6 +86,7 @@ struct LoginView: View { } .task { viewModel.applyPreferences(authSessionCoordinator.loginPreferences()) + await viewModel.loadAppConfig(appConfigAPI: appConfigAPI) } } diff --git a/suixinkan/Features/Splash/Splash.md b/suixinkan/Features/Splash/Splash.md new file mode 100644 index 0000000..0162188 --- /dev/null +++ b/suixinkan/Features/Splash/Splash.md @@ -0,0 +1,35 @@ +# Splash 模块业务逻辑 + +## 模块职责 + +Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范。 + +该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制。 + +## 核心对象 + +- `SplashView`:展示白底、180pt Logo、「随心瞰 / 商家版」文案。 +- `SplashCoordinator`:并行执行最短 1.5 秒展示与 `SessionBootstrapper.restore`,两者都完成后结束 Splash。 +- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen`。 + +## 展示规则 + +- 背景色:白色 `#FFFFFF` +- Logo:`SplashLogo`,180×180 pt +- 标题:`随心瞰`,40pt,bold + italic,`AppDesign.primary` +- 副标题:`商家版`,20pt,bold,距底 32pt + +## 启动时序 + +1. iOS 系统 Launch Screen 展示白底 + Logo。 +2. `RootView` 挂载后立即显示 `SplashView`。 +3. `SplashCoordinator.start` 并行执行: + - 最短展示 1.5 秒(UI Test 模式跳过) + - `SessionBootstrapper.restore` 静默恢复登录态 +4. 两者都完成后淡出 Splash,进入 `LoginView` 或 `MainTabsView`。 +5. 冷启动期间不显示 Lottie 全局 Loading。 + +## 与 Android 的差异 + +- Android 首启隐私弹窗在 Splash 上;iOS 仍在登录页勾选协议。 +- Android 系统 Launch Screen 与应用内 Splash 使用同一 XML;iOS 系统 Launch Screen 仅展示 Logo,文案由 SwiftUI Splash 补齐。 diff --git a/suixinkan/Features/Splash/Views/SplashView.swift b/suixinkan/Features/Splash/Views/SplashView.swift new file mode 100644 index 0000000..dbf8613 --- /dev/null +++ b/suixinkan/Features/Splash/Views/SplashView.swift @@ -0,0 +1,47 @@ +// +// SplashView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 启动页视图,对齐 Android `activity_splash.xml` 的品牌展示布局。 +struct SplashView: View { + var body: some View { + ZStack { + Color.white + .ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + Image("SplashLogo") + .resizable() + .scaledToFit() + .frame(width: 180, height: 180) + .accessibilityIdentifier("splash.logo") + + Text("随心瞰") + .font(.system(size: 40, weight: .bold)) + .italic() + .foregroundStyle(AppDesign.primary) + .accessibilityIdentifier("splash.title") + + Spacer() + + Text("商家版") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(AppDesign.primary) + .padding(.bottom, 32) + .accessibilityIdentifier("splash.subtitle") + } + } + .accessibilityIdentifier("splash.root") + } +} + +#Preview { + SplashView() +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 6edd58f..9e281b3 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -42,7 +42,14 @@ remote-notification UILaunchScreen - + + UIColorName + LaunchBackground + UIImageName + SplashLogo + UIImageRespectsSafeAreaInsets + + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/suixinkanTests/AppConfigTests.swift b/suixinkanTests/AppConfigTests.swift new file mode 100644 index 0000000..3fcb77e --- /dev/null +++ b/suixinkanTests/AppConfigTests.swift @@ -0,0 +1,87 @@ +// +// AppConfigTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// App 配置接口测试,覆盖 API 请求路径和登录页配置加载行为。 +final class AppConfigTests: XCTestCase { + /// 测试 App 配置接口使用正确路径并解析 enable_register 字段。 + func testAppConfigAPIRequestsConfigEndpoint() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "enable_register": true + } + } + """.data(using: .utf8)! + let session = AppConfigURLSession(responses: [response]) + let api = AppConfigAPI(client: APIClient(session: session)) + + let config = try await api.fetchAppConfig() + + XCTAssertTrue(config.enableRegister) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/config") + } + + /// 测试登录页加载 App 配置成功时会更新 enableRegister。 + func testLoginViewModelLoadAppConfigUpdatesEnableRegister() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "enable_register": true + } + } + """.data(using: .utf8)! + let session = AppConfigURLSession(responses: [response]) + let api = AppConfigAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + + await viewModel.loadAppConfig(appConfigAPI: api) + + XCTAssertTrue(viewModel.enableRegister) + } + + /// 测试登录页加载 App 配置失败时保持默认值。 + func testLoginViewModelLoadAppConfigKeepsDefaultOnFailure() async { + let session = AppConfigURLSession(responses: [Data()]) + let api = AppConfigAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + + await viewModel.loadAppConfig(appConfigAPI: api) + + XCTAssertFalse(viewModel.enableRegister) + } +} + +/// App 配置测试用 URLSession 替身,按顺序返回预设响应。 +private final class AppConfigURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index 5de60af..807d398 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -191,7 +191,7 @@ final class ProfileSecondaryPagesTests: XCTestCase { /// 测试接口数据已存在时不再被本地缓存覆盖。 func testApplyCachedProfileIgnoredAfterUserInfoLoaded() { let viewModel = ProfileViewModel() - viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000") + viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称") viewModel.applyCachedProfile( AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000") ) diff --git a/suixinkanTests/SplashCoordinatorTests.swift b/suixinkanTests/SplashCoordinatorTests.swift new file mode 100644 index 0000000..a136add --- /dev/null +++ b/suixinkanTests/SplashCoordinatorTests.swift @@ -0,0 +1,68 @@ +// +// SplashCoordinatorTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 启动页协调器测试,覆盖最短展示时长、bootstrap 并行和 UI Test 跳过策略。 +final class SplashCoordinatorTests: XCTestCase { + /// 测试 bootstrap 完成后仍需等待最短展示时长。 + func testStartWaitsForMinimumDisplayDuration() async { + let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false) + let startedAt = Date() + + await coordinator.start { + try? await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertTrue(coordinator.isFinished) + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.19) + } + + /// 测试 bootstrap 耗时超过最短展示时长时,以 bootstrap 完成时间为准。 + func testStartWaitsForSlowBootstrap() async { + let coordinator = SplashCoordinator(minimumDisplayDuration: 0.05, skipsMinimumDuration: false) + let startedAt = Date() + + await coordinator.start { + try? await Task.sleep(nanoseconds: 150_000_000) + } + + XCTAssertTrue(coordinator.isFinished) + XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.14) + } + + /// 测试 UI Test 模式会跳过最短展示时长。 + func testStartSkipsMinimumDurationWhenRequested() async { + let coordinator = SplashCoordinator(minimumDisplayDuration: 1.0, skipsMinimumDuration: true) + let startedAt = Date() + + await coordinator.start { + try? await Task.sleep(nanoseconds: 10_000_000) + } + + XCTAssertTrue(coordinator.isFinished) + XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.2) + } + + /// 测试重复调用 start 不会重置已完成状态。 + func testStartIsIdempotentAfterFinished() async { + let coordinator = SplashCoordinator(minimumDisplayDuration: 0, skipsMinimumDuration: true) + var bootstrapCount = 0 + + await coordinator.start { + bootstrapCount += 1 + } + await coordinator.start { + bootstrapCount += 1 + } + + XCTAssertTrue(coordinator.isFinished) + XCTAssertEqual(bootstrapCount, 1) + } +} diff --git a/suixinkanTests/StorageTests.swift b/suixinkanTests/StorageTests.swift index 0dd0b11..b356e1e 100644 --- a/suixinkanTests/StorageTests.swift +++ b/suixinkanTests/StorageTests.swift @@ -10,6 +10,29 @@ import XCTest /// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。 final class StorageTests: XCTestCase { + /// 测试 SessionTokenStore 使用 UserDefaults 保存、读取、修剪和清空正式 token。 + func testSessionTokenStorePersistsTokenInUserDefaults() throws { + let defaults = makeIsolatedDefaults() + let store = SessionTokenStore(defaults: defaults, key: "session.token.test") + + try store.save(" token-123 ") + XCTAssertEqual(store.load(), "token-123") + + try store.clear() + XCTAssertNil(store.load()) + } + + /// 测试保存空 token 会清空本地正式 token。 + func testSessionTokenStoreClearsWhenSavingBlankToken() throws { + let defaults = makeIsolatedDefaults() + let store = SessionTokenStore(defaults: defaults, key: "session.token.blank.test") + + try store.save("token-123") + try store.save(" \n") + + XCTAssertNil(store.load()) + } + /// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。 func testAppPreferencesStorePersistsLoginPreferences() { let defaults = makeIsolatedDefaults() diff --git a/suixinkanUITests/Auth/ZLoginSmokeUITests.swift b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift index a738e2a..5f70ad0 100644 --- a/suixinkanUITests/Auth/ZLoginSmokeUITests.swift +++ b/suixinkanUITests/Auth/ZLoginSmokeUITests.swift @@ -2,7 +2,7 @@ // ZLoginSmokeUITests.swift // suixinkanUITests // -// 命名以 Z 开头,确保在全套件末尾执行,避免清空 Keychain 影响其它用例。 +// 命名以 Z 开头,确保在全套件末尾执行,避免清空 UserDefaults 登录态影响其它用例。 // import XCTest diff --git a/suixinkanUITests/README.md b/suixinkanUITests/README.md index 485c958..7783f1e 100644 --- a/suixinkanUITests/README.md +++ b/suixinkanUITests/README.md @@ -57,7 +57,7 @@ xcodebuild test \ UI Test 进程会自动传入: - `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket -- `-suixinkan-ui-tests-reset-state`:冷启动清理 Keychain / UserDefaults +- `-suixinkan-ui-tests-reset-state`:冷启动清理 UserDefaults 中的 token、账号快照和偏好 - `-suixinkan-ui-tests-open-menu <菜单标题>`:登录后直达首页调试目录中的页面 - `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`) diff --git a/suixinkanUITests/Support/SuixinkanUIApplication.swift b/suixinkanUITests/Support/SuixinkanUIApplication.swift index b915937..750dc7a 100644 --- a/suixinkanUITests/Support/SuixinkanUIApplication.swift +++ b/suixinkanUITests/Support/SuixinkanUIApplication.swift @@ -43,12 +43,23 @@ final class SuixinkanApp { hasLaunched = true } dismissSystemAlertsIfNeeded() + waitForSplashToDisappearIfNeeded() if waitForLoading { waitForGlobalLoadingToDisappear() } return self } + /// 等待启动页消失后再继续后续断言。 + func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) { + let splash = application.otherElements["splash.root"] + guard splash.waitForExistence(timeout: 2) else { return } + + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash) + _ = XCTWaiter.wait(for: [expectation], timeout: timeout) + } + /// 等待全局 Loading 层消失。 func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) { let loading = application.staticTexts["加载中"] diff --git a/suixinkanUITests/Support/UITestSession.swift b/suixinkanUITests/Support/UITestSession.swift index 3857ffe..7284684 100644 --- a/suixinkanUITests/Support/UITestSession.swift +++ b/suixinkanUITests/Support/UITestSession.swift @@ -25,7 +25,7 @@ enum UITestSession { return launched } - /// 以指定首页菜单直达参数重启 App,并复用 Keychain 登录态。 + /// 以指定首页菜单直达参数重启 App,并复用 UserDefaults 登录态。 @discardableResult static func relaunch( openMenu menuTitle: String, @@ -44,7 +44,7 @@ enum UITestSession { return relaunched } - /// 以指定个人中心路由直达参数重启 App,并复用 Keychain 登录态。 + /// 以指定个人中心路由直达参数重启 App,并复用 UserDefaults 登录态。 @discardableResult static func relaunch( openProfile route: String, diff --git a/功能同步Checklist.md b/功能同步Checklist.md index f77b2f1..01385d6 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -16,7 +16,7 @@ | 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | | 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | | 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | -| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | +| 完成 | 登录态缓存 | token、账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | | 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | | 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | | 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |