同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。
新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,11 +64,15 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
);
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -324,14 +328,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
@ -28,20 +28,34 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
|
|
||||||
1. `suixinkanApp` 创建 `RootView`。
|
1. `suixinkanApp` 创建 `RootView`。
|
||||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||||
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo`。
|
||||||
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
|
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页。
|
||||||
5. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
5. `SplashCoordinator.start` 并行执行最短 1.5 秒展示和 `SessionBootstrapper.restore`;UI Test 模式跳过 1.5 秒延迟。
|
||||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
|
||||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。
|
8. 无 token 时保持 `loggedOut`,Splash 结束后展示 `LoginView`。
|
||||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
9. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
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 启动约定
|
## UI Test 启动约定
|
||||||
|
|
||||||
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
- `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-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
|
||||||
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。
|
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。
|
||||||
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
|
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
|
||||||
@ -58,7 +72,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
|
|||||||
- `AppSession` 切换为 `loggedIn`。
|
- `AppSession` 切换为 `loggedIn`。
|
||||||
|
|
||||||
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
||||||
- 清空 Keychain token。
|
- 清空本地 token。
|
||||||
- 清空账号快照。
|
- 清空账号快照。
|
||||||
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
||||||
- `AppSession` 切换为 `loggedOut`。
|
- `AppSession` 切换为 `loggedOut`。
|
||||||
|
|||||||
@ -45,6 +45,10 @@ private struct AuthAPIEnvironmentKey: EnvironmentKey {
|
|||||||
static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct AppConfigAPIEnvironmentKey: EnvironmentKey {
|
||||||
|
static var defaultValue: AppConfigAPI { AppConfigAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
|
}
|
||||||
|
|
||||||
private struct InviteAPIEnvironmentKey: EnvironmentKey {
|
private struct InviteAPIEnvironmentKey: EnvironmentKey {
|
||||||
static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
}
|
}
|
||||||
@ -168,6 +172,11 @@ extension EnvironmentValues {
|
|||||||
set { self[AuthAPIEnvironmentKey.self] = newValue }
|
set { self[AuthAPIEnvironmentKey.self] = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appConfigAPI: AppConfigAPI {
|
||||||
|
get { self[AppConfigAPIEnvironmentKey.self] }
|
||||||
|
set { self[AppConfigAPIEnvironmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var inviteAPI: InviteAPI {
|
var inviteAPI: InviteAPI {
|
||||||
get { self[InviteAPIEnvironmentKey.self] }
|
get { self[InviteAPIEnvironmentKey.self] }
|
||||||
set { self[InviteAPIEnvironmentKey.self] = newValue }
|
set { self[InviteAPIEnvironmentKey.self] = newValue }
|
||||||
|
|||||||
@ -47,6 +47,8 @@ struct RootView: View {
|
|||||||
@State private var cooperationOrderAPI: CooperationOrderAPI
|
@State private var cooperationOrderAPI: CooperationOrderAPI
|
||||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||||
@State private var sessionBootstrapper: SessionBootstrapper
|
@State private var sessionBootstrapper: SessionBootstrapper
|
||||||
|
@StateObject private var splashCoordinator = SplashCoordinator()
|
||||||
|
@State private var appConfigAPI: AppConfigAPI
|
||||||
|
|
||||||
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
/// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。
|
||||||
init() {
|
init() {
|
||||||
@ -56,6 +58,7 @@ struct RootView: View {
|
|||||||
_snapshotStore = State(initialValue: snapshotStore)
|
_snapshotStore = State(initialValue: snapshotStore)
|
||||||
_apiClient = State(initialValue: apiClient)
|
_apiClient = State(initialValue: apiClient)
|
||||||
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
||||||
|
_appConfigAPI = State(initialValue: AppConfigAPI(client: apiClient))
|
||||||
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
||||||
let uploadAPI = UploadAPI(client: apiClient)
|
let uploadAPI = UploadAPI(client: apiClient)
|
||||||
_uploadAPI = State(initialValue: uploadAPI)
|
_uploadAPI = State(initialValue: uploadAPI)
|
||||||
@ -97,7 +100,15 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
rootContent
|
rootContent
|
||||||
|
|
||||||
|
if !splashCoordinator.isFinished {
|
||||||
|
SplashView()
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
.globalToastOverlay()
|
.globalToastOverlay()
|
||||||
.globalLoadingOverlay(loadingCenter: globalLoading)
|
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||||||
.environmentObject(appSession)
|
.environmentObject(appSession)
|
||||||
@ -110,6 +121,7 @@ struct RootView: View {
|
|||||||
.environment(\.accountSnapshotStore, snapshotStore)
|
.environment(\.accountSnapshotStore, snapshotStore)
|
||||||
.environment(\.apiClient, apiClient)
|
.environment(\.apiClient, apiClient)
|
||||||
.environment(\.authAPI, authAPI)
|
.environment(\.authAPI, authAPI)
|
||||||
|
.environment(\.appConfigAPI, appConfigAPI)
|
||||||
.environment(\.profileAPI, profileAPI)
|
.environment(\.profileAPI, profileAPI)
|
||||||
.environment(\.uploadAPI, uploadAPI)
|
.environment(\.uploadAPI, uploadAPI)
|
||||||
.environment(\.ossUploadService, ossUploadService)
|
.environment(\.ossUploadService, ossUploadService)
|
||||||
@ -139,7 +151,7 @@ struct RootView: View {
|
|||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||||
await globalLoading.withLoading {
|
await splashCoordinator.start {
|
||||||
await sessionBootstrapper.restore(
|
await sessionBootstrapper.restore(
|
||||||
appSession: appSession,
|
appSession: appSession,
|
||||||
accountContext: accountContext,
|
accountContext: accountContext,
|
||||||
|
|||||||
45
suixinkan/App/SplashCoordinator.swift
Normal file
45
suixinkan/App/SplashCoordinator.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -43,7 +43,7 @@ final class AuthSessionCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 完成正式登录,写入内存状态、Keychain token 和账号快照。
|
/// 完成正式登录,写入内存状态、本地 token 和账号快照。
|
||||||
func completeLogin(
|
func completeLogin(
|
||||||
with response: V9AuthResponse,
|
with response: V9AuthResponse,
|
||||||
username: String,
|
username: String,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/SplashLogo.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/SplashLogo.imageset/icon_splash.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@ -42,12 +42,12 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
本地缓存按安全级别拆分:
|
本地缓存按安全级别拆分:
|
||||||
- `SessionTokenStore`:使用 Keychain 保存正式 token。
|
- `SessionTokenStore`:使用 UserDefaults 保存正式 token。
|
||||||
- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。
|
- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。
|
||||||
- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。
|
- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。
|
||||||
|
|
||||||
缓存边界:
|
缓存边界:
|
||||||
- 正式 token 只放 Keychain。
|
- 正式 token 由 `SessionTokenStore` 写入 UserDefaults。
|
||||||
- 临时 token 只放内存。
|
- 临时 token 只放内存。
|
||||||
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
|
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
|
||||||
- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。
|
- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。
|
||||||
@ -86,7 +86,7 @@ Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部
|
|||||||
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
||||||
|
|
||||||
上传模块只保存内存状态:
|
上传模块只保存内存状态:
|
||||||
- STS token 不写入 Keychain 或 UserDefaults。
|
- STS token 不写入 UserDefaults。
|
||||||
- 用户选择的本地图片 Data 不落盘。
|
- 用户选择的本地图片 Data 不落盘。
|
||||||
- 上传进度只用于当前页面展示。
|
- 上传进度只用于当前页面展示。
|
||||||
|
|
||||||
|
|||||||
@ -6,33 +6,21 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
|
||||||
import Security
|
|
||||||
|
|
||||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
/// 正式登录 token 存储服务,封装 UserDefaults 读写并保持业务层不接触缓存细节。
|
||||||
enum SessionTokenStoreError: LocalizedError {
|
|
||||||
case unexpectedStatus(OSStatus)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case let .unexpectedStatus(status):
|
|
||||||
"登录凭证存储失败(\(status))"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
|
||||||
final class SessionTokenStore {
|
final class SessionTokenStore {
|
||||||
private let service: String
|
private let defaults: UserDefaults
|
||||||
private let account: String
|
private let key: String
|
||||||
|
|
||||||
/// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。
|
/// 初始化 token 存储服务,默认按 App Bundle 和账号名隔离 UserDefaults 键。
|
||||||
init(
|
init(
|
||||||
|
defaults: UserDefaults = .standard,
|
||||||
|
key: String? = nil,
|
||||||
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
|
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
|
||||||
account: String = "session.token"
|
account: String = "session.token"
|
||||||
) {
|
) {
|
||||||
self.service = service
|
self.defaults = defaults
|
||||||
self.account = account
|
self.key = key ?? "suixinkan.session.token.\(service).\(account)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存正式 token,空 token 会被视为清空凭证。
|
/// 保存正式 token,空 token 会被视为清空凭证。
|
||||||
@ -43,38 +31,12 @@ final class SessionTokenStore {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = Data(trimmedToken.utf8)
|
defaults.set(trimmedToken, forKey: key)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取本地正式 token,读取失败或无值时返回 nil。
|
/// 读取本地正式 token,读取失败或无值时返回 nil。
|
||||||
func load() -> String? {
|
func load() -> String? {
|
||||||
var query = baseQuery()
|
guard let token = defaults.string(forKey: key)?
|
||||||
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)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty else {
|
!token.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
@ -84,18 +46,6 @@ final class SessionTokenStore {
|
|||||||
|
|
||||||
/// 清空本地正式 token。
|
/// 清空本地正式 token。
|
||||||
func clear() throws {
|
func clear() throws {
|
||||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
defaults.removeObject(forKey: key)
|
||||||
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
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,4 +49,4 @@ Upload 模块负责 App 内通用文件上传能力,当前主要服务个人
|
|||||||
- OSS STS token 不落盘,只在一次上传流程中临时使用。
|
- OSS STS token 不落盘,只在一次上传流程中临时使用。
|
||||||
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
|
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
|
||||||
- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。
|
- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。
|
||||||
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。
|
- 正式登录 token 仍由 `SessionTokenStore` 使用 UserDefaults 保存,上传模块不直接读取或保存登录态。
|
||||||
|
|||||||
26
suixinkan/Features/Auth/API/AppConfigAPI.swift
Normal file
26
suixinkan/Features/Auth/API/AppConfigAPI.swift
Normal file
@ -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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,7 +56,7 @@ Auth 模块负责登录页、手机号密码登录、多账号选择和账号选
|
|||||||
## 缓存规则
|
## 缓存规则
|
||||||
|
|
||||||
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
|
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
|
||||||
- 正式 token 写 Keychain。
|
- 正式 token 写 UserDefaults。
|
||||||
- 上次手机号和协议状态写 UserDefaults。
|
- 上次手机号和协议状态写 UserDefaults。
|
||||||
- 账号资料、当前角色和业务作用域写入账号快照。
|
- 账号资料、当前角色和业务作用域写入账号快照。
|
||||||
|
|
||||||
|
|||||||
22
suixinkan/Features/Auth/Models/AppConfigModels.swift
Normal file
22
suixinkan/Features/Auth/Models/AppConfigModels.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -78,6 +78,7 @@ final class LoginViewModel: ObservableObject {
|
|||||||
@Published var showsPassword = false
|
@Published var showsPassword = false
|
||||||
@Published var showsAgreementSheet = false
|
@Published var showsAgreementSheet = false
|
||||||
@Published var pendingAccountSelection: AccountSelectionPayload?
|
@Published var pendingAccountSelection: AccountSelectionPayload?
|
||||||
|
@Published private(set) var enableRegister = false
|
||||||
|
|
||||||
var canSubmit: Bool {
|
var canSubmit: Bool {
|
||||||
isValidPhone && !trimmedPassword.isEmpty
|
isValidPhone && !trimmedPassword.isEmpty
|
||||||
@ -124,6 +125,16 @@ final class LoginViewModel: ObservableObject {
|
|||||||
showsAgreementSheet = false
|
showsAgreementSheet = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 加载 App 远程配置;请求失败时保持默认值,与 Android 登录页行为一致。
|
||||||
|
func loadAppConfig(appConfigAPI: AppConfigAPI) async {
|
||||||
|
do {
|
||||||
|
let config = try await appConfigAPI.fetchAppConfig()
|
||||||
|
enableRegister = config.enableRegister
|
||||||
|
} catch {
|
||||||
|
// 请求失败不做任何提示,保持默认值 false。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。
|
/// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。
|
||||||
func normalizeUsernameCountryCodeIfNeeded() {
|
func normalizeUsernameCountryCodeIfNeeded() {
|
||||||
let digits = username.filter(\.isNumber)
|
let digits = username.filter(\.isNumber)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ struct LoginView: View {
|
|||||||
@EnvironmentObject private var permissionContext: PermissionContext
|
@EnvironmentObject private var permissionContext: PermissionContext
|
||||||
@EnvironmentObject private var toastCenter: ToastCenter
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
@Environment(\.authAPI) private var authAPI
|
@Environment(\.authAPI) private var authAPI
|
||||||
|
@Environment(\.appConfigAPI) private var appConfigAPI
|
||||||
@Environment(\.profileAPI) private var profileAPI
|
@Environment(\.profileAPI) private var profileAPI
|
||||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||||
@ -85,6 +86,7 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
viewModel.applyPreferences(authSessionCoordinator.loginPreferences())
|
viewModel.applyPreferences(authSessionCoordinator.loginPreferences())
|
||||||
|
await viewModel.loadAppConfig(appConfigAPI: appConfigAPI)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
suixinkan/Features/Splash/Splash.md
Normal file
35
suixinkan/Features/Splash/Splash.md
Normal file
@ -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 补齐。
|
||||||
47
suixinkan/Features/Splash/Views/SplashView.swift
Normal file
47
suixinkan/Features/Splash/Views/SplashView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
@ -42,7 +42,14 @@
|
|||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchBackground</string>
|
||||||
|
<key>UIImageName</key>
|
||||||
|
<string>SplashLogo</string>
|
||||||
|
<key>UIImageRespectsSafeAreaInsets</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
87
suixinkanTests/AppConfigTests.swift
Normal file
87
suixinkanTests/AppConfigTests.swift
Normal file
@ -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)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -191,7 +191,7 @@ final class ProfileSecondaryPagesTests: XCTestCase {
|
|||||||
/// 测试接口数据已存在时不再被本地缓存覆盖。
|
/// 测试接口数据已存在时不再被本地缓存覆盖。
|
||||||
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
|
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
|
||||||
let viewModel = ProfileViewModel()
|
let viewModel = ProfileViewModel()
|
||||||
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000")
|
viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称")
|
||||||
viewModel.applyCachedProfile(
|
viewModel.applyCachedProfile(
|
||||||
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
|
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
|
||||||
)
|
)
|
||||||
|
|||||||
68
suixinkanTests/SplashCoordinatorTests.swift
Normal file
68
suixinkanTests/SplashCoordinatorTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,29 @@ import XCTest
|
|||||||
|
|
||||||
/// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。
|
/// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。
|
||||||
final class StorageTests: XCTestCase {
|
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 能保存并读取上次登录手机号和协议状态。
|
/// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。
|
||||||
func testAppPreferencesStorePersistsLoginPreferences() {
|
func testAppPreferencesStorePersistsLoginPreferences() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// ZLoginSmokeUITests.swift
|
// ZLoginSmokeUITests.swift
|
||||||
// suixinkanUITests
|
// suixinkanUITests
|
||||||
//
|
//
|
||||||
// 命名以 Z 开头,确保在全套件末尾执行,避免清空 Keychain 影响其它用例。
|
// 命名以 Z 开头,确保在全套件末尾执行,避免清空 UserDefaults 登录态影响其它用例。
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|||||||
@ -57,7 +57,7 @@ xcodebuild test \
|
|||||||
UI Test 进程会自动传入:
|
UI Test 进程会自动传入:
|
||||||
|
|
||||||
- `-suixinkan-ui-tests`:主开关,App 侧跳过推送注册与排队 WebSocket
|
- `-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-menu <菜单标题>`:登录后直达首页调试目录中的页面
|
||||||
- `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`)
|
- `-suixinkan-ui-tests-open-profile <路由名>`:登录后直达个人中心二级页(如 `settings`)
|
||||||
|
|
||||||
|
|||||||
@ -43,12 +43,23 @@ final class SuixinkanApp {
|
|||||||
hasLaunched = true
|
hasLaunched = true
|
||||||
}
|
}
|
||||||
dismissSystemAlertsIfNeeded()
|
dismissSystemAlertsIfNeeded()
|
||||||
|
waitForSplashToDisappearIfNeeded()
|
||||||
if waitForLoading {
|
if waitForLoading {
|
||||||
waitForGlobalLoadingToDisappear()
|
waitForGlobalLoadingToDisappear()
|
||||||
}
|
}
|
||||||
return self
|
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 层消失。
|
/// 等待全局 Loading 层消失。
|
||||||
func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) {
|
func waitForGlobalLoadingToDisappear(timeout: TimeInterval = 45) {
|
||||||
let loading = application.staticTexts["加载中"]
|
let loading = application.staticTexts["加载中"]
|
||||||
|
|||||||
@ -25,7 +25,7 @@ enum UITestSession {
|
|||||||
return launched
|
return launched
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 以指定首页菜单直达参数重启 App,并复用 Keychain 登录态。
|
/// 以指定首页菜单直达参数重启 App,并复用 UserDefaults 登录态。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func relaunch(
|
static func relaunch(
|
||||||
openMenu menuTitle: String,
|
openMenu menuTitle: String,
|
||||||
@ -44,7 +44,7 @@ enum UITestSession {
|
|||||||
return relaunched
|
return relaunched
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 以指定个人中心路由直达参数重启 App,并复用 Keychain 登录态。
|
/// 以指定个人中心路由直达参数重启 App,并复用 UserDefaults 登录态。
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func relaunch(
|
static func relaunch(
|
||||||
openProfile route: String,
|
openProfile route: String,
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 |
|
| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 |
|
||||||
| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
|
| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
|
||||||
| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
|
| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
|
||||||
| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
|
| 完成 | 登录态缓存 | token、账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
|
||||||
| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
|
| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
|
||||||
| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 |
|
| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 |
|
||||||
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
|
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user