# App 模块业务逻辑

## 模块职责

App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。

该模块不直接处理具体页面业务，主要承担 App 级基础设施编排：
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的 `NavigationStack` 路径。
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。

## 核心对象

- `suixinkanApp`：SwiftUI 应用入口，挂载 `RootView`。
- `RootView`：创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API，并注入 SwiftUI Environment。
- `AppSession`：保存认证阶段和正式 token，只负责登录态，不承载业务资料。
- `AccountContext`：保存当前账号资料、账号类型、景区作用域和门店作用域，并提供账号级缓存前缀。
- `PermissionContext`：保存角色权限、当前角色和扁平化权限 URI。
- `ScenicSpotContext`：保存当前景区下的景点/打卡点列表和加载状态。
- `AppRouter`：保存当前 Tab 和每个 Tab 自己的导航路径。
- `ToastCenter`：管理当前全局 Toast 文案和自动隐藏任务。
- `AuthSessionCoordinator`：统一处理登录完成、退出登录、偏好读取和账号快照刷新。
- `SessionBootstrapper`：冷启动时读取本地 token 和账号快照，并向服务端校验登录态。
- `AccountContextLoader`：统一同步用户资料、角色权限、景区和门店。
- `AppServiceEnvironment.swift`：为无 UI 状态的服务定义 SwiftUI `EnvironmentKey`，供 View 通过 `@Environment(\.xxxAPI)` 读取。
- `UMengSDKBootstrap`：友盟统计与 U-APM 的隐私合规初始化入口，只在用户同意隐私政策且 AppKey 非空后初始化。

## 依赖注入：为何 API 走 Environment 而非单例

SwiftUI 的 `Environment` 在本项目中是**依赖注入（DI）通道**，不等于「UI 状态容器」。注入对象分两类：

| 类型 | 机制 | 代表 |
|------|------|------|
| 会驱动 UI 刷新的状态 | `environmentObject` + `ObservableObject` | `AppSession`、`ToastCenter` |
| 无 UI 状态的服务 | 自定义 `EnvironmentKey` | `OrdersAPI`、`ProfileAPI` |

### 数据流

1. `RootView.init()` 创建**一个** `APIClient`，再传给所有 `*API(client:)`。
2. `RootView.body` 通过 `.environment(\.ordersAPI, ordersAPI)` 等下发到整棵视图树。
3. `task` 中 `apiClient.bindAuthTokenProvider { appSession.token }` 绑定 token。
4. View 用 `@Environment(\.ordersAPI)` 读取，调用 ViewModel 时作为参数传入，例如 `await viewModel.reload(api: ordersAPI, ...)`。
5. ViewModel 不持有 API，方法签名接收 `XxxServing` 协议，单元测试直接传 Mock。

网络层细节见 [Networking 模块](../Core/Networking/Networking.md)。

### 为何不用「每个 API 一个单例」

**核心原因：需要共享的是 `APIClient`，不是 API 类本身。**

`AuthAPI`、`OrdersAPI` 等只是 `APIClient` 上的薄封装，本身无状态。全 App 必须只有一个 `APIClient`，才能保证 token provider 只绑定一次、登录后 token 对所有请求一致、`tokenOverride`（如 `set-user`）走同一客户端。若每个 API 各自 `static let shared = XxxAPI(client: APIClient())`，容易在不经意间创建多个 client，导致 token 不同步。

`RootView` 作为**组合根**集中创建并装配依赖，例如：

- `OSSUploadService(configService: uploadAPI)` 依赖 `UploadAPI`
- `SessionBootstrapper`、`AuthSessionCoordinator` 在 init 时注入具体 API
- `PushNotificationManager.shared.configure(api: pushAPI, ...)` 需要与 RootView 相同的 `pushAPI` 实例

### Environment 的其他收益

- **Preview / 局部替换**：可在子树 `.environment(\.ordersAPI, mockAPI)` 替换，不影响全局；`AppServiceEnvironment` 的 `defaultValue` 供 Preview 兜底。
- **测试**：ViewModel 测试靠 `XxxServing` 协议 + 方法参数注入 Mock，不依赖 Environment；Environment 主要给 View 层省掉层层传参。
- **历史选择**：iOS 17 迁移到 iOS 16 时，UI 状态统一 `environmentObject`，服务统一自定义 `EnvironmentKey`（见根目录 `iOS16兼容迁移记录.md`）。

### 单例是否可行

可以，但应有纪律。项目里已有单例先例：`PushNotificationManager.shared`、`PaymentVoiceSpeaker.shared`、`CloudTransferStore.shared`——特点是跨模块、与 UI 树无关、需在 AppDelegate 或后台回调中访问。

若全面改单例，推荐收敛为一个 `AppServices` 容器共享同一 `client`，而不是 20 个 `OrdersAPI.shared`。运行时行为可与当前方案等价，差别在可维护性和团队习惯。

### 当前方案的代价

1. `AppServiceEnvironment.swift` 约 25 组 Key，`RootView` 大量 `@State` / `.environment` 行，样板代码多。
2. `Environment` 命名易误导为 UI 绑定，实际是 service locator。
3. Key 的 `defaultValue` 会 `new` 独立 `APIClient()`，仅未从 `RootView` 注入时使用；Preview 需注意是否拿到真实实例。
4. View 用 `@Environment`，ViewModel 用方法参数，View 充当桥接层。

若将来觉得 Key 过多，可收敛为一个 `AppServices` 对象注入一次，属于简化写法，不是架构对错。

## 启动流程

> 完整启动接口清单、数据依赖与 Splash 优化方案见 [启动流程分析与优化方案](../../docs/startup-analysis.md)。

1. `suixinkanApp` 处理 UI Test 冷启动状态清理并创建 `RootView`。
2. `RootView` 初始化共享依赖，并把它们注入 Environment。
3. iOS 系统 Launch Screen 展示品牌页（白底、Logo、文案）。
4. `RootView` 挂载后在 bootstrap 完成前只创建透明占位，并以 `SplashView` 覆盖根视图，避免空白、登录页闪屏和登录页任务提前启动。
5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
6. 如果本地已记录用户同意隐私政策，则初始化高德与友盟 SDK；否则不触发第三方 SDK 初始化。
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。
8. 无 token 时完成 bootstrap，再保持 `loggedOut` 并展示 `LoginView`。
9. 有 token 时进入 `restoring`，先恢复本地账号快照。
10. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。
11. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新，不阻塞首屏。
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
13. 明确 token 失效时清空 token 和账号快照，bootstrap 完成后回到登录页。
14. 普通网络失败时保留本地登录态，使用账号快照进入主界面。
15. 冷启动 bootstrap 不使用 Lottie 全局 Loading；`.restoring` 期间展示 `SplashView`。
16. `LoginView` 只有在 bootstrap 判定完成且登录页真实可见后才创建，首屏出现后调用 `/api/app/config` 加载远程配置（如 `enable_register`），失败静默。

## 初始化分层

| 阶段 | 职责 | 主要对象 |
|------|------|----------|
| App 入口 | UI Test 状态清理 | `suixinkanApp`、`AppUITestLaunchState` |
| 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView`、`SessionBootstrapper`、`AccountContextLoader` |
| 首屏出现后 | 页面级远程配置 | `LoginView`、`AppConfigAPI` |
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager`、`ScenicSpotContext` |

## 第三方 SDK 隐私门禁

高德与友盟 SDK 均由 `RootView` 在隐私政策已同意后初始化，不再由 `suixinkanApp.init()` 直接触发。隐私同意状态保存在 `AppPreferencesStore`：
- 首次冷启动未同意时，不初始化高德、友盟统计或 U-APM。
- 登录页校验确认已同意后，`AuthSessionCoordinator.savePrivacyAgreementAccepted(true)` 会先持久化状态，再触发第三方 SDK 初始化。
- 后续冷启动读取到已同意状态时，会自动初始化第三方 SDK。
- 退出登录只清空 token 和账号快照，保留隐私同意状态。

友盟配置集中在 `UMengConfig`。`appKey` 为空时 `UMengSDKBootstrap` 不会调用友盟 SDK，便于在未配置真实 AppKey 前保持构建可用。U-APM 首版采用最小合规采集范围：开启崩溃、卡顿和启动分析，关闭网络分析、内存、OOM 与日志回捞。

## UI Test 启动约定

- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket，避免系统弹窗干扰自动化。
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 UserDefaults 中的 token、账号快照和偏好。
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页（如 `settings`、`realNameAuth`）。
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数，供 XCUITest 逐页验证。
- 详细运行方式见 `suixinkanUITests/README.md`。

## 登录和退出

登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理：
- 正式 token 写入 `SessionTokenStore`。
- 上次手机号和协议状态写入 `AppPreferencesStore`。
- 账号资料、景区列表、门店列表写入 `AccountContext`。
- 角色权限和当前角色写入 `PermissionContext`。
- 非敏感账号快照写入 `AccountSnapshotStore`。
- `AppSession` 切换为 `loggedIn`。

退出登录由 `AuthSessionCoordinator.logout` 统一处理：
- 清空本地 token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- `AppSession` 切换为 `loggedOut`。
- 保留上次手机号、协议状态等非敏感偏好。

## Toast

全局 Toast 由 `RootView` 挂载在页面最上层，业务页面只调用 `toastCenter.show(...)` 发出提示命令。

Toast 展示为屏幕中央的黑色半透明圆角卡片（`Color.black.opacity(0.78)`），左侧带警告图标，文案 16pt 展示，以透明度淡入淡出，不提供关闭按钮，默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时，旧的自动隐藏任务不会影响新的 Toast。

## 导航规则

主界面使用 `TabView`，每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`，切换 Tab 不会丢失该 Tab 的内部导航路径。

Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时，`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar，避免每个业务页面重复处理。

当前路由枚举 `AppRoute` 仍以占位详情页为主，后续新增真实页面时应优先扩展 `AppRoute`，再由对应 Tab 的 `NavigationStack` 处理跳转。
