修复旅拍相册 API 未注入共享 APIClient 导致未登录错误。

在 RootView 注入 travelAlbumAPI,并将 travel_album 纳入首页常用功能;补充 App/Networking 依赖注入说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 10:07:32 +08:00
parent 258c438f9a
commit 6769be60e4
8 changed files with 66 additions and 6 deletions

View File

@ -23,6 +23,59 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
- `AppServiceEnvironment.swift`:为无 UI 状态的服务定义 SwiftUI `EnvironmentKey`,供 View 通过 `@Environment(\.xxxAPI)` 读取。
## 依赖注入:为何 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不依赖 EnvironmentEnvironment 主要给 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` 对象注入一次,属于简化写法,不是架构对错。
## 启动流程

View File

@ -42,6 +42,7 @@ struct RootView: View {
@State private var scheduleAPI: ScheduleAPI
@State private var inviteAPI: InviteAPI
@State private var assetsAPI: AssetsAPI
@State private var travelAlbumAPI: TravelAlbumAPI
@State private var punchPointAPI: PunchPointAPI
@State private var locationReportAPI: LocationReportAPI
@StateObject private var homeLocationViewModel: HomeLocationViewModel
@ -82,6 +83,7 @@ struct RootView: View {
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
_inviteAPI = State(initialValue: InviteAPI(client: apiClient))
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
_travelAlbumAPI = State(initialValue: TravelAlbumAPI(client: apiClient))
_punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient))
let locationReportAPI = LocationReportAPI(client: apiClient)
_locationReportAPI = State(initialValue: locationReportAPI)
@ -147,6 +149,7 @@ struct RootView: View {
.environment(\.scheduleAPI, scheduleAPI)
.environment(\.inviteAPI, inviteAPI)
.environment(\.assetsAPI, assetsAPI)
.environment(\.travelAlbumAPI, travelAlbumAPI)
.environment(\.punchPointAPI, punchPointAPI)
.environment(\.locationReportAPI, locationReportAPI)
.environmentObject(homeLocationViewModel)

View File

@ -108,6 +108,8 @@ token: <token>
空 token 不会写入 Header。
全 App 应只存在一个 `APIClient` 实例,由 `RootView` 创建并传给各业务 `*API`token provider 也只在此 client 上绑定一次。为何通过 Environment 下发而非单例,见 [App 模块依赖注入说明](../App/App.md#依赖注入为何-api-走-environment-而非单例)。
## 环境选择
`APIEnvironment.current` 根据编译环境选择接口地址:

View File

@ -83,7 +83,7 @@ allFunctions = 顶层权限
`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限:
- `basic_info``photographer_stats``photographer_orders`
- `location_info``scan_qr``payment_qr``travel_album`
- `location_info``scan_qr``payment_qr`
- `album_list``material_upload` 等未登记 URI
子权限 URI 也不会出现(未 flatten
@ -206,4 +206,4 @@ Debug 构建下可用 Xcode Console 过滤 `HomeAllFunctions`
- 不展开子权限
- 精确 URI 拆分 common / more
- 常用区顺序跟随 `allFunctions`
- 与 Android 样例账号27 顶层 → 20 可用 → 3 常用 + 17 更多)一致
- 与 Android 样例账号27 顶层 → 21 可用 → 3 常用 + 18 更多)一致

View File

@ -149,7 +149,7 @@ enum HomeMenuRouter {
return .destination(.liveManagement)
case "live_album":
return .destination(.liveAlbum)
case "travel_album", "task_new_entry":
case "travel_album":
return .destination(.travelAlbumEntry)
case "operating-area":
return .destination(.operatingArea)

View File

@ -27,6 +27,7 @@ struct HomeCommonMenuStore {
"live_stream_management",
"verification_order",
"live_album",
"travel_album",
"pm",
"location_report",
"registration_invitation",

View File

@ -48,7 +48,7 @@ enum HomeIconCatalog {
"checkmark.seal.fill"
case "live_album":
"play.rectangle.on.rectangle.fill"
case "travel_album", "task_new_entry":
case "travel_album":
"photo.on.rectangle.angled"
case "pm", "pm_manager", "project_edit":
"circle.grid.2x2.fill"