# 应用启动流程与接口数据存储梳理

本文按当前 iOS 工程代码梳理应用从冷启动到各首屏展示期间发生的初始化、接口调用顺序，以及接口数据和本地状态的存储方式。范围仅覆盖当前 iOS 实现，不额外补充 Android 中存在但 iOS 尚未接入的能力。

## 1. 冷启动立即发生

冷启动入口在 `AppDelegate.application(_:didFinishLaunchingWithOptions:)`。

1. 调用 `UmengBootstrap.configureIfNeeded()`。
   - 友盟初始化受 `AppStore.shared.privacyAgreementAccepted` 控制。
   - 未同意隐私协议时直接返回，不初始化 SDK。
2. 调用 `WeChatManager.shared.registerIfNeeded()`。
   - 校验 Universal Link。
   - 调用微信 SDK `WXApi.registerApp` 注册 AppID。
3. 若 `AppStore.shared.privacyAgreementAccepted == true` 且 `AppStore.shared.token` 非空，则调用 `AMapBootstrap.configureIfNeeded()`。
   - 写入高德隐私合规状态。
   - 设置 `AMapServices.shared().apiKey` 和 HTTPS。
4. 此阶段不直接调用任何业务后端接口。

随后系统进入 `SceneDelegate.scene(_:willConnectTo:options:)`。

1. 调用 `AppNavigationBarAppearance.applyGlobalAppearance()` 设置全局导航栏样式。
2. 创建 `UIWindow`。
3. 设置 `window.rootViewController = AppRouter.makeRootViewController()`。
4. `AppRouter` 通过 `AppStore.shared.isLoggedIn` 判断根页面：
   - token 非空：进入 `MainTabBarController`。
   - token 为空：进入 `UINavigationController(rootViewController: LoginViewController())`。
5. 注册全局通知：
   - `sessionDidExpire`：清除登录态并切回登录页。
   - `userDidLogout`：清除登录态并切回登录页。
   - `userDidLogin`：切到主 Tab。

## 2. 网络依赖初始化

`NetworkServices.shared` 是全局网络依赖容器，不在 `AppDelegate` 中主动创建，而是在页面或 Tab 首次访问业务 API 时懒加载。

初始化顺序如下：

1. 创建 `APIClient(environment: .current)`。
2. 用同一个 `APIClient` 创建各业务 API：
   - `AuthAPI`
   - `ProfileAPI`
   - `StatisticsAPI`
   - `OrderAPI`
   - `HomeAPI`
   - 以及其他业务模块 API。
3. 创建 `OSSUploadService(configService: uploadAPI)`。
4. 调用 `client.bindAuthTokenProvider`，从 `AppStore.shared.token` 读取 token。

`APIClient` 的公共请求规则：

- `APIEnvironment.current`
  - Debug：`https://api-test.zhifly.cn`
  - Release：`https://api.zhifly.cn`
- 每个请求默认写入 Header：
  - `Content-Type: application/json`
  - `Accept: application/json`
  - `X-APP-VERSION`
  - `X-OS-TYPE: iOS`
  - token 非空时写入 `token`
- 响应通过 `APIEnvelope` 解包。
  - 成功业务码：`100000`
  - 非成功业务码抛出 `APIError.serverCode`
  - 认证失效时广播 `sessionDidExpire`，由 `SceneDelegate` 清会话并回登录页。

## 3. 未登录启动与登录流程

未登录时根页面为登录页。

### 3.1 登录页展示前

`LoginViewController.setupUI` 会调用 `viewModel.applyStoredPreferences()`，从 `AppStore` 读取：

- `lastLoginUsername`
- `privacyAgreementAccepted`

这一步只恢复本地表单状态，不调用后端接口。

### 3.2 登录页出现后

`LoginViewController.viewDidAppear` 调用：

```text
GET /api/app/config
```

调用链：

```text
LoginViewController.viewDidAppear
-> LoginViewModel.loadAppConfig(authAPI:)
-> AuthAPI.getAppConfig()
```

用途：

- 读取 App 配置。
- 当前只使用 `enableRegister` 控制验证码登录/注册入口显隐。
- 配置加载失败会静默忽略，不影响密码登录。
- 该结果只保存在 `LoginViewModel.enableRegister` 内存态，不写入 `AppStore`。

### 3.3 点击登录后的接口顺序

用户点击登录按钮后，先做本地校验：

- 手机号必须是 11 位且以 `1` 开头。
- 密码不能为空。
- 必须勾选隐私协议。

校验通过后发起登录：

```text
POST /api/app/v9/login
```

调用链：

```text
LoginViewController.performLogin()
-> LoginViewModel.login(authAPI:)
-> AuthAPI.login(username:password:)
```

返回数据包含临时 token 和可选账号列表。随后根据账号数量分流：

- 只有一个账号：立即调用 `set-user` 换正式 token。
- 多个账号：展示账号选择弹窗，用户确认后再调用 `set-user`。

正式账号选择接口：

```text
POST /api/app/v9/set-user
```

调用链：

```text
单账号:
LoginViewModel.resolveLoginResponse(...)
-> AuthAPI.setUser(..., tokenOverride: 临时 token)

多账号:
LoginViewController.selectAccount(_:)
-> LoginViewModel.selectAccount(_:authAPI:)
-> AuthAPI.setUser(..., tokenOverride: 临时 token)
```

### 3.4 登录完成后的存储与跳转

登录完成调用：

```text
AuthSessionHelper.completeLogin(...)
```

写入 `AppStore` 的内容：

- `token`
- `lastLoginUsername`
- `privacyAgreementAccepted`
- `userId`
- `userName`
- `realName`
- `avatar`
- `phone`
- `accountType`
- `accountDisplayName`
- `roleCode`
- `roleName`
- `currentScenicId`
- `currentScenicName`
- `currentStoreId`

其中账号上下文来自 `AccountSwitchAccount`、`V9ScenicUser` 或 `V9StoreUser`。

如果用户已同意隐私协议，还会再次触发：

- `AMapBootstrap.configureIfNeeded()`
- `UmengBootstrap.configureIfNeeded()`

最后发送：

```text
NotificationName.userDidLogin
```

`SceneDelegate.handleUserDidLogin()` 收到通知后调用：

```text
AppRouter.setRoot(.mainTab, on: window)
```

根页面切换到主 Tab。

## 4. 已登录启动与默认首页首次展示

已登录时，`AppRouter.makeRootViewController()` 创建 `MainTabBarController`。

### 4.1 主 Tab 创建

`MainTabBarController.viewDidLoad` 顺序：

1. 设置 `delegate`。
2. 配置 TabBar 外观。
3. 调用 `configureTabs()` 创建五个 Tab slot：
   - 首页：`HomeViewController`
   - 订单：`OrdersViewController`
   - 中间扫码占位页
   - 数据：`StatisticsViewController`
   - 我的：`ProfileViewController`
4. 绑定订单角标 ViewModel。
5. 绑定扫码处理器。
6. 调用 `badgeViewModel.refreshPendingWriteOffCount()`。

注意：`MainTabBadgeViewModel.refreshPendingWriteOffCount()` 当前仅执行：

```text
pendingWriteOffCount = nil
```

代码注释标明“接口未接入前保持为空”，因此这里不记录为真实后端接口调用。

### 4.2 默认首页首次出现

默认选中首页。`HomeViewController.viewDidAppear` 首次出现时调用：

```text
initializeHome()
```

调用顺序：

1. 展示全局 loading。
2. `HomeViewModel.initialize(api:)`
3. 隐藏 loading。
4. 刷新首页 UI。
5. 延迟评估弹窗。

`HomeViewModel.initialize(api:)` 内部顺序：

1. `locationStateStore.restoreStateIfNeeded()`
   - 从 `AppStore` 恢复在线状态、上次位置上报时间、提醒分钟数。
   - 如果上次在线状态已过期，会清除在线状态和上次上报时间。
   - 不调用后端接口。
2. `refreshLocalDisplayState()`
   - 从 `AppStore` 读取当前景区、当前角色。
   - 无有效景区时清空 `currentScenicId/currentScenicName`。
3. 强制调用 `loadPermissions(api:force: true)`。

权限接口：

```text
GET /api/yf-handset-app/role-permission
```

调用链：

```text
HomeViewModel.initialize(api:)
-> HomeViewModel.loadPermissions(api:force: true)
-> HomeAPI.rolePermissions()
```

权限返回后的存储：

- 完整角色权限列表：`AppStore.saveRolePermissionList(_:)`
- 当前匹配角色的景区列表：`AppStore.saveRoleScenicList(_:)`
- 当前匹配角色的扁平权限：`AppStore.savePermissionItems(_:)`
- 匹配角色名：必要时写入 `AppStore.roleName`

这些权限数据都按账号作用域 key 写入 `UserDefaults`，值为 JSON Data。

随后首页重建常用应用：

- 从 `AppStore.permissionItems()` 读取权限。
- `HomeMenuCatalog.visibleMenus(from:)` 生成可见菜单。
- `HomeCommonMenuStore` 读取或写入常用应用 URI。
- 常用应用 key 由 `accountScope + roleCode` 组成。

### 4.3 首页门店接口

权限加载完成后会调用：

```text
HomeViewModel.loadStoreListIfNeeded(api:)
```

只有同时满足以下条件才会请求门店：

- 当前角色为 `storeAdmin`
- `AppStore.currentScenicId > 0`

接口：

```text
GET /api/app/store/all
```

调用链：

```text
HomeViewModel.loadStoreListIfNeeded(api:)
-> HomeAPI.storeList()
```

返回后的处理：

- 根据 `currentScenicId` 和已保存的 `currentStoreId` 匹配当前门店。
- 匹配结果保存在 `HomeViewModel.storeItem`。
- 若匹配到门店，写入 `AppStore.currentStoreId`。
- 门店列表本身不持久化。

### 4.4 首页弹窗评估与位置详情接口

首页 UI 刷新后调用：

```text
evaluateDialogsWithDelay()
-> HomeViewModel.evaluateDialogs(api:)
```

弹窗优先级：

1. 权限弹窗。
2. 景区弹窗。
3. 位置超时弹窗。

如果不需要权限弹窗、不需要景区弹窗、不是简化顶部角色，并且本地设置了位置提醒分钟数，则可能调用位置详情接口：

```text
GET /api/yf-handset-app/photog/loacation/detail?staff_id=...
```

调用链：

```text
HomeViewModel.evaluateDialogs(api:)
-> HomeViewModel.checkLocationTimeout(api:)
-> HomeAPI.locationDetail(staffId:)
```

返回后的处理：

- 只用于判断是否展示位置超时提醒。
- 结果不写入 `AppStore`。
- 弹窗状态保存在 `HomeViewModel.showLocationTimeoutDialog` 等内存属性中。

## 5. 其他 Tab 首次展示时的接口

其他 Tab 的根控制器会在 `MainTabBarController.configureTabs()` 时创建，但各自的数据接口通常在首次展示时才调用。

### 5.1 订单 Tab

`OrdersViewController.viewDidAppear` 首次出现时调用：

```text
initializeList()
```

按当前角色选择接口。

摄影师：

```text
GET /api/yf-handset-app/photog/order/listv2
```

景区管理员：

```text
GET /api/app/scenic-admin/order/list
```

门店管理员：

```text
GET /api/app/store/order/list
```

调用链：

```text
OrdersViewController.initializeList()
-> OrderListViewModel.refreshOrderList(api:)
-> OrderAPI.photographerOrderList(...) 或 OrderAPI.scenicAdminOrderList(...)

或

OrdersViewController.initializeList()
-> DepositOrderListViewModel.refreshOrderList(api:)
-> OrderAPI.storeOrderList(...)
```

数据存储：

- 摄影师/景区管理员订单列表保存在 `OrderListViewModel.orderList`。
- 门店管理员订单列表保存在 `DepositOrderListViewModel.orderList`。
- 分页状态、筛选状态、搜索状态都保存在对应 ViewModel 内存属性中。
- 首屏订单列表不写入 `AppStore`。

### 5.2 数据 Tab

`StatisticsViewController.viewDidAppear` 首次出现时调用：

```text
initializeStatistics()
-> StatisticsViewModel.initStatistics(api:)
```

内部顺序：

1. 加载汇总数据。
2. 设置日明细日期范围为今日并刷新列表。

摄影师汇总：

```text
GET /api/yf-handset-app/photog/analyse/user
```

摄影师日明细：

```text
GET /api/yf-handset-app/photog/analyse/user/daily
```

景区管理员汇总：

```text
GET /api/app/scenic-admin/analyse
```

景区管理员日明细：

```text
GET /api/app/scenic-admin/analyse/daily
```

门店管理员汇总：

```text
GET /api/app/store/analyse
```

门店管理员日明细：

```text
GET /api/app/store/analyse/daily
```

数据存储：

- 汇总数据保存在 `StatisticsViewModel.statistics`。
- 日明细列表保存在 `StatisticsViewModel.statisticsList`。
- 当前周期、日期范围、分页状态保存在 `StatisticsViewModel` 内存属性中。
- 统计数据不写入 `AppStore`。

### 5.3 我的 Tab

`ProfileViewController.viewWillAppear` 每次出现都会调用：

```text
reloadProfile(showGlobalLoading:)
-> ProfileViewModel.reload(api:)
```

第一步固定调用用户资料接口：

```text
GET /api/yf-handset-app/userinfo
```

调用链：

```text
ProfileViewModel.reload(api:)
-> ProfileAPI.userInfo()
```

返回后的处理：

- 保存在 `ProfileViewModel.userInfo`。
- 调用 `AppStore.applyUserInfo(_:)` 回写展示字段：
  - `userName`
  - `realName`
  - `avatar`
  - `phone`
  - `roleName`，仅在本地为空时由 `applyUserInfo` 写入；随后 `ProfileViewModel.reload` 也会在接口 `roleName` 非空时写入。

如果当前为摄影师角色，则并发调用：

```text
GET /api/yf-handset-app/photog/real-name/info
GET /api/yf-handset-app/photog/wallet/bank-card/info
```

调用链：

```text
async let realName = api.realNameInfo()
async let bankCard = api.bankCardInfo()
```

返回后的处理：

- 实名信息保存在 `ProfileViewModel.realNameInfo`。
- 银行卡信息保存在 `ProfileViewModel.bankCardInfo`。
- 这两个结果不写入 `AppStore`。

## 6. 数据存储总览

### 6.1 AppStore / UserDefaults

`AppStore` 是本地会话和业务快照的统一入口，底层使用 `UserDefaults`。

非账号作用域的主要 key：

- `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_code`
- `key_in_role_name`
- `key_in_current_scenic_id`
- `key_in_current_scenic_name`
- `key_in_current_store_id`

账号作用域由 `accountCachePrefix` 生成：

```text
userId_accountType
```

如果没有 userId，则使用 `guest`。账号作用域 key 用于存储：

- 旧版角色 ID：`key_in_role_id`
- 完整 role-permission 列表：`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`
- 排队叫号相关本地设置。

权限和景区列表以 JSON Data 写入 `UserDefaults`：

- `saveRolePermissionList(_:)`
- `savePermissionItems(_:)`
- `saveRoleScenicList(_:)`

### 6.2 HomeCommonMenuStore

首页常用应用由 `HomeCommonMenuStore` 单独管理，底层也是 `UserDefaults`。

存储 key：

```text
{accountScope}_role_{roleCode}_common_uris
```

值为 URI 字符串数组。

首次有权限但没有常用应用记录时，会从权限列表生成最多 4 个默认常用入口并写入本地。

### 6.3 HomeLocationStateStore

`HomeLocationStateStore` 负责首页在线状态和 2 小时位置上报倒计时。

持久化到 `AppStore` 的字段：

- `onlineStatus`
- `lastLocationReportTime`
- `locationReminderMinutes`

内存态字段：

- `isOnline`
- `elapsedSeconds`
- `nextReportCountdownSeconds`
- `reminderMinutes`
- 倒计时 `Task`

启动恢复逻辑：

- 如果本地 `onlineStatus == false`，停止倒计时。
- 如果没有 `lastLocationReportTime`，清除在线状态。
- 如果距离上次上报已超过 2 小时，清除在线状态和上次上报时间。
- 否则恢复倒计时。

### 6.4 ViewModel 内存态

多数页面接口结果只保存在 ViewModel 内存属性中，不持久化。

典型示例：

- 登录配置：`LoginViewModel.enableRegister`
- 首页门店卡片：`HomeViewModel.storeItem`
- 首页弹窗状态：`HomeViewModel.showPermissionDialog`、`showScenicDialog`、`showLocationTimeoutDialog`
- 订单列表：`OrderListViewModel.orderList`、`DepositOrderListViewModel.orderList`
- 数据统计：`StatisticsViewModel.statistics`、`statisticsList`
- 我的页面实名/银行卡状态：`ProfileViewModel.realNameInfo`、`bankCardInfo`

例外是明确调用 `AppStore` 的数据：

- 登录 token 与账号上下文。
- 用户资料展示字段。
- 权限、景区、角色相关快照。
- 门店当前 ID。
- 位置在线状态与倒计时锚点。

## 7. 启动接口顺序速查

### 7.1 未登录冷启动

```text
AppDelegate.didFinishLaunching
-> 友盟/微信/高德 SDK 条件初始化
-> SceneDelegate.willConnect
-> AppRouter.makeRootViewController()
-> LoginViewController.setupUI
-> LoginViewController.viewDidAppear
-> GET /api/app/config
```

点击登录后：

```text
POST /api/app/v9/login
-> POST /api/app/v9/set-user
-> AppStore 写入登录态和账号上下文
-> userDidLogin
-> AppRouter.setRoot(.mainTab)
```

### 7.2 已登录冷启动默认首页

```text
AppDelegate.didFinishLaunching
-> 友盟/微信/高德 SDK 条件初始化
-> SceneDelegate.willConnect
-> AppRouter.makeRootViewController()
-> MainTabBarController.viewDidLoad
-> HomeViewController.viewDidAppear
-> HomeLocationStateStore.restoreStateIfNeeded()
-> GET /api/yf-handset-app/role-permission
-> 条件满足时 GET /api/app/store/all
-> 条件满足时 GET /api/yf-handset-app/photog/loacation/detail?staff_id=...
```

### 7.3 用户首次切到订单 Tab

```text
OrdersViewController.viewDidAppear
-> 摄影师 GET /api/yf-handset-app/photog/order/listv2
   或 景区管理员 GET /api/app/scenic-admin/order/list
   或 门店管理员 GET /api/app/store/order/list
```

### 7.4 用户首次切到数据 Tab

```text
StatisticsViewController.viewDidAppear
-> 按角色 GET 汇总接口
-> 按角色 GET 日明细接口
```

### 7.5 用户切到我的 Tab

```text
ProfileViewController.viewWillAppear
-> GET /api/yf-handset-app/userinfo
-> 摄影师角色并发:
   GET /api/yf-handset-app/photog/real-name/info
   GET /api/yf-handset-app/photog/wallet/bank-card/info
```
