feat: 添加云盘与消息中心功能

This commit is contained in:
2026-07-09 22:37:43 +08:00
parent 8e356973bd
commit f20ec7f06c
91 changed files with 5437 additions and 89 deletions

View File

@ -0,0 +1,682 @@
# 应用启动流程与接口数据存储梳理
本文按当前 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
```