diff --git a/suixinkan_ios/App/App.md b/suixinkan_ios/App/App.md index e43201c..f55127f 100644 --- a/suixinkan_ios/App/App.md +++ b/suixinkan_ios/App/App.md @@ -2,18 +2,19 @@ ## 模块职责 -App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。 +App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast / Loading。 该模块不直接处理具体页面业务,主要承担 App 级基础设施编排: - 根据登录状态展示登录页、恢复态或主 Tab。 - 创建并注入全局状态和共享服务。 -- 管理每个 Tab 独立的 `NavigationStack` 路径。 +- 管理每个 Tab 独立的导航栈与 `AppRouter` 路径。 - 协调登录成功、退出登录、冷启动恢复和本地缓存同步。 ## 核心对象 -- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。 -- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。 +- `AppDelegate` / `SceneDelegate`:UIKit 应用入口,挂载 `RootViewController`。 +- `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。 +- `RootViewController`:根据 `AppSession.phase` 切换登录页、恢复占位或 `MainTabBarController`,并挂载全局 Toast / Loading。 - `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。 - `AccountContext`:保存当前账号资料、景区作用域和门店作用域。 - `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。 @@ -24,16 +25,51 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 - `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 - `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。 +## AppServices 依赖约定 + +`AppServices.shared` 是唯一 Composition Root,集中创建 `APIClient`、各业务 API 与全局 Context,并绑定 token provider。 + +### 何时通过 `init(services:)` 注入 + +仅在 Composition 边界传参,便于 UI Test 或单元测试替换容器: + +- `RootViewController`(默认 `AppServices.shared`) +- `MainTabBarController` → `TabNavigationController` +- `LoginViewController` +- `AppRouteViewControllerFactory.makeViewController(..., services:)` + +根链路使用 `init(services: AppServices = .shared)`,生产代码无感,测试可传入自定义实例。 + +### 何时直接使用 `appServices` + +普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`。 + +导航辅助(`UIKitAppNavigation`、`HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`。 + +### ViewModel 层边界 + +ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `reload` / `load` / `submit` 等方法调用时传入所需 API 与 Context。 + +### 适合独立单例的类型 + +以下类型独立于 `AppServices`,因其生命周期或系统入口特殊: + +- `PushNotificationManager.shared`:`AppDelegate` 回调桥 +- `CloudTransferStore.shared`:跨页面云传任务状态 +- 第三方 SDK(如 `AMapServices`) + +`AppSession`、`AccountContext`、各 `*API` 等**不应**拆成多个 `.shared`,须由 `AppServices` 统一创建,并在登出时由 `RootViewController` 统一 reset。 + ## 启动流程 -1. `suixinkanApp` 创建 `RootView`。 -2. `RootView` 初始化共享依赖,并把它们注入 Environment。 +1. `AppDelegate` 创建 `RootViewController(services: .shared)`。 +2. `AppServices` 初始化共享依赖(`NetworkBundle` 内共享同一 `APIClient`)。 3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 -5. 无 token 时保持 `loggedOut`,展示 `LoginView`。 +5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。 6. 有 token 时进入 `restoring`,先恢复本地账号快照。 7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 -8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。 +8. 校验成功后进入 `loggedIn`,展示 `MainTabBarController`。 9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 10. 明确 token 失效时清空 token 和账号快照,回到登录页。 11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 @@ -66,14 +102,14 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 ## Toast -全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。 +全局 Toast 由 `RootViewController` 在 `viewDidAppear` 时挂载到 window,业务页面通过 `showToast(...)` 或 `appServices.toastCenter.show(...)` 发出提示命令。 Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 ## 导航规则 -主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 +主界面使用 `MainTabBarController`,每个 Tab 内部由独立的 `TabNavigationController`(`UINavigationController`)包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 -Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar,避免每个业务页面重复处理。 +Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。 -当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。 +跨 Tab 跳转通过 `AppRouter.select` / `navigateHome` / `navigateProfile` 处理;Tab 内 push 通过 `UIKitAppNavigation` 或当前 Nav 栈完成。新增页面时优先扩展 `AppRoute` / `HomeRoute`,在 `AppRouteViewControllerFactory` 注册对应 ViewController。 diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift index cccc9b2..0f45a17 100644 --- a/suixinkan_ios/App/AppServices.swift +++ b/suixinkan_ios/App/AppServices.swift @@ -12,46 +12,55 @@ import Foundation final class AppServices { static let shared = AppServices() - let appSession = AppSession() - let accountContext = AccountContext() - let permissionContext = PermissionContext() - let scenicSpotContext = ScenicSpotContext() + let session: SessionBundle + let context: AppContextBundle + let network: NetworkBundle + let ui: UIFeedbackBundle + let runtime: AppRuntimeBundle let appRouter = AppRouter() - let toastCenter = ToastCenter() - let globalLoading = GlobalLoadingCenter() - let scenicQueueRuntime = ScenicQueueRuntime() - let apiClient: APIClient - let authAPI: AuthAPI - let profileAPI: ProfileAPI - let uploadAPI: UploadAPI - let ossUploadService: OSSUploadService - let accountContextAPI: AccountContextAPI - let ordersAPI: OrdersAPI - let statisticsAPI: StatisticsAPI - let paymentAPI: PaymentAPI - let walletAPI: WalletAPI - let pushAPI: PushAPI - let scenicPermissionAPI: ScenicPermissionAPI - let scenicSettlementAPI: ScenicSettlementAPI - let messageCenterAPI: MessageCenterAPI - let scenicQueueAPI: ScenicQueueAPI - let liveAPI: LiveAPI - let operatingAreaAPI: OperatingAreaAPI - let pilotCertificationAPI: PilotCertificationAPI - let taskAPI: TaskAPI - let projectAPI: ProjectAPI - let scheduleAPI: ScheduleAPI - let inviteAPI: InviteAPI - let assetsAPI: AssetsAPI - let punchPointAPI: PunchPointAPI - let locationReportAPI: LocationReportAPI - - let authSessionCoordinator: AuthSessionCoordinator - let sessionBootstrapper: SessionBootstrapper private let tokenStore: SessionTokenStore private let snapshotStore: AccountSnapshotStore + // MARK: - 扁平访问(兼容现有调用方) + + var appSession: AppSession { session.appSession } + var accountContext: AccountContext { context.accountContext } + var permissionContext: PermissionContext { context.permissionContext } + var scenicSpotContext: ScenicSpotContext { context.scenicSpotContext } + var toastCenter: ToastCenter { ui.toastCenter } + var globalLoading: GlobalLoadingCenter { ui.globalLoading } + var scenicQueueRuntime: ScenicQueueRuntime { runtime.scenicQueueRuntime } + + var apiClient: APIClient { network.apiClient } + var authAPI: AuthAPI { network.authAPI } + var profileAPI: ProfileAPI { network.profileAPI } + var uploadAPI: UploadAPI { network.uploadAPI } + var ossUploadService: OSSUploadService { network.ossUploadService } + var accountContextAPI: AccountContextAPI { network.accountContextAPI } + var ordersAPI: OrdersAPI { network.ordersAPI } + var statisticsAPI: StatisticsAPI { network.statisticsAPI } + var paymentAPI: PaymentAPI { network.paymentAPI } + var walletAPI: WalletAPI { network.walletAPI } + var pushAPI: PushAPI { network.pushAPI } + var scenicPermissionAPI: ScenicPermissionAPI { network.scenicPermissionAPI } + var scenicSettlementAPI: ScenicSettlementAPI { network.scenicSettlementAPI } + var messageCenterAPI: MessageCenterAPI { network.messageCenterAPI } + var scenicQueueAPI: ScenicQueueAPI { network.scenicQueueAPI } + var liveAPI: LiveAPI { network.liveAPI } + var operatingAreaAPI: OperatingAreaAPI { network.operatingAreaAPI } + var pilotCertificationAPI: PilotCertificationAPI { network.pilotCertificationAPI } + var taskAPI: TaskAPI { network.taskAPI } + var projectAPI: ProjectAPI { network.projectAPI } + var scheduleAPI: ScheduleAPI { network.scheduleAPI } + var inviteAPI: InviteAPI { network.inviteAPI } + var assetsAPI: AssetsAPI { network.assetsAPI } + var punchPointAPI: PunchPointAPI { network.punchPointAPI } + var locationReportAPI: LocationReportAPI { network.locationReportAPI } + + var authSessionCoordinator: AuthSessionCoordinator { session.authSessionCoordinator } + var sessionBootstrapper: SessionBootstrapper { session.sessionBootstrapper } + /// 当前景区 ID。 var currentScenicId: Int? { accountContext.currentScenic?.id @@ -67,50 +76,74 @@ final class AppServices { accountContext.profile?.userId } - /// 初始化实例。 + /// 生产环境默认初始化。 private init() { - let client = APIClient() let tokenStore = SessionTokenStore() let snapshotStore = AccountSnapshotStore() + let preferencesStore = AppPreferencesStore() + let apiClient = APIClient() + self.tokenStore = tokenStore self.snapshotStore = snapshotStore - apiClient = client - authAPI = AuthAPI(client: client) - profileAPI = ProfileAPI(client: client) - uploadAPI = UploadAPI(client: client) - ossUploadService = OSSUploadService(configService: uploadAPI) - accountContextAPI = AccountContextAPI(client: client) - ordersAPI = OrdersAPI(client: client) - statisticsAPI = StatisticsAPI(client: client) - paymentAPI = PaymentAPI(client: client) - walletAPI = WalletAPI(client: client) - pushAPI = PushAPI(client: client) - scenicPermissionAPI = ScenicPermissionAPI(client: client) - scenicSettlementAPI = ScenicSettlementAPI(client: client) - messageCenterAPI = MessageCenterAPI(client: client) - scenicQueueAPI = ScenicQueueAPI(client: client) - liveAPI = LiveAPI(client: client) - operatingAreaAPI = OperatingAreaAPI(client: client) - pilotCertificationAPI = PilotCertificationAPI(client: client) - taskAPI = TaskAPI(client: client) - projectAPI = ProjectAPI(client: client) - scheduleAPI = ScheduleAPI(client: client) - inviteAPI = InviteAPI(client: client) - assetsAPI = AssetsAPI(client: client) - punchPointAPI = PunchPointAPI(client: client) - locationReportAPI = LocationReportAPI(client: client) - authSessionCoordinator = AuthSessionCoordinator( - tokenStore: tokenStore, - snapshotStore: snapshotStore, - preferencesStore: AppPreferencesStore() + context = AppContextBundle( + accountContext: AccountContext(), + permissionContext: PermissionContext(), + scenicSpotContext: ScenicSpotContext() ) - sessionBootstrapper = SessionBootstrapper( - tokenStore: tokenStore, - snapshotStore: snapshotStore + network = NetworkBundle(apiClient: apiClient) + ui = UIFeedbackBundle( + toastCenter: ToastCenter(), + globalLoading: GlobalLoadingCenter() ) - apiClient.bindAuthTokenProvider { [unowned self] in - appSession.token - } + runtime = AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime()) + session = SessionBundle( + appSession: AppSession(), + authSessionCoordinator: AuthSessionCoordinator( + tokenStore: tokenStore, + snapshotStore: snapshotStore, + preferencesStore: preferencesStore + ), + sessionBootstrapper: SessionBootstrapper( + tokenStore: tokenStore, + snapshotStore: snapshotStore + ) + ) + bindAuthTokenProvider() + } + + /// 测试专用初始化,允许注入 mock `APIClient` 与存储替身。 + init( + apiClient: APIClient, + tokenStore: SessionTokenStore = SessionTokenStore(), + snapshotStore: AccountSnapshotStore = AccountSnapshotStore(), + preferencesStore: AppPreferencesStore = AppPreferencesStore() + ) { + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + context = AppContextBundle( + accountContext: AccountContext(), + permissionContext: PermissionContext(), + scenicSpotContext: ScenicSpotContext() + ) + network = NetworkBundle(apiClient: apiClient) + ui = UIFeedbackBundle( + toastCenter: ToastCenter(), + globalLoading: GlobalLoadingCenter() + ) + runtime = AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime()) + session = SessionBundle( + appSession: AppSession(), + authSessionCoordinator: AuthSessionCoordinator( + tokenStore: tokenStore, + snapshotStore: snapshotStore, + preferencesStore: preferencesStore + ), + sessionBootstrapper: SessionBootstrapper( + tokenStore: tokenStore, + snapshotStore: snapshotStore + ) + ) + bindAuthTokenProvider() } /// 绑定推送与 UIKit 导航。 @@ -121,4 +154,11 @@ final class AppServices { router: appRouter ) } + + /// 将 `APIClient` 与当前 `AppSession` token 关联。 + private func bindAuthTokenProvider() { + apiClient.bindAuthTokenProvider { [unowned self] in + appSession.token + } + } } diff --git a/suixinkan_ios/App/AppServicesBundles.swift b/suixinkan_ios/App/AppServicesBundles.swift new file mode 100644 index 0000000..011ca1c --- /dev/null +++ b/suixinkan_ios/App/AppServicesBundles.swift @@ -0,0 +1,96 @@ +// +// AppServicesBundles.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +@MainActor +/// 登录与会话恢复相关依赖分组。 +struct SessionBundle { + let appSession: AppSession + let authSessionCoordinator: AuthSessionCoordinator + let sessionBootstrapper: SessionBootstrapper +} + +@MainActor +/// 账号、权限与景区景点等业务作用域 Context 分组。 +struct AppContextBundle { + let accountContext: AccountContext + let permissionContext: PermissionContext + let scenicSpotContext: ScenicSpotContext +} + +@MainActor +/// 网络层依赖分组,共享同一 `APIClient` 与 token 链路。 +struct NetworkBundle { + let apiClient: APIClient + let authAPI: AuthAPI + let profileAPI: ProfileAPI + let uploadAPI: UploadAPI + let ossUploadService: OSSUploadService + let accountContextAPI: AccountContextAPI + let ordersAPI: OrdersAPI + let statisticsAPI: StatisticsAPI + let paymentAPI: PaymentAPI + let walletAPI: WalletAPI + let pushAPI: PushAPI + let scenicPermissionAPI: ScenicPermissionAPI + let scenicSettlementAPI: ScenicSettlementAPI + let messageCenterAPI: MessageCenterAPI + let scenicQueueAPI: ScenicQueueAPI + let liveAPI: LiveAPI + let operatingAreaAPI: OperatingAreaAPI + let pilotCertificationAPI: PilotCertificationAPI + let taskAPI: TaskAPI + let projectAPI: ProjectAPI + let scheduleAPI: ScheduleAPI + let inviteAPI: InviteAPI + let assetsAPI: AssetsAPI + let punchPointAPI: PunchPointAPI + let locationReportAPI: LocationReportAPI + + /// 基于共享 `APIClient` 创建全部业务 API。 + init(apiClient: APIClient) { + self.apiClient = apiClient + authAPI = AuthAPI(client: apiClient) + profileAPI = ProfileAPI(client: apiClient) + uploadAPI = UploadAPI(client: apiClient) + ossUploadService = OSSUploadService(configService: uploadAPI) + accountContextAPI = AccountContextAPI(client: apiClient) + ordersAPI = OrdersAPI(client: apiClient) + statisticsAPI = StatisticsAPI(client: apiClient) + paymentAPI = PaymentAPI(client: apiClient) + walletAPI = WalletAPI(client: apiClient) + pushAPI = PushAPI(client: apiClient) + scenicPermissionAPI = ScenicPermissionAPI(client: apiClient) + scenicSettlementAPI = ScenicSettlementAPI(client: apiClient) + messageCenterAPI = MessageCenterAPI(client: apiClient) + scenicQueueAPI = ScenicQueueAPI(client: apiClient) + liveAPI = LiveAPI(client: apiClient) + operatingAreaAPI = OperatingAreaAPI(client: apiClient) + pilotCertificationAPI = PilotCertificationAPI(client: apiClient) + taskAPI = TaskAPI(client: apiClient) + projectAPI = ProjectAPI(client: apiClient) + scheduleAPI = ScheduleAPI(client: apiClient) + inviteAPI = InviteAPI(client: apiClient) + assetsAPI = AssetsAPI(client: apiClient) + punchPointAPI = PunchPointAPI(client: apiClient) + locationReportAPI = LocationReportAPI(client: apiClient) + } +} + +@MainActor +/// 全局 UI 反馈(Toast、Loading)分组。 +struct UIFeedbackBundle { + let toastCenter: ToastCenter + let globalLoading: GlobalLoadingCenter +} + +@MainActor +/// 与登录态联动的后台运行时依赖分组。 +struct AppRuntimeBundle { + let scenicQueueRuntime: ScenicQueueRuntime +} diff --git a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift index cec5414..66edfdc 100644 --- a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift +++ b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift @@ -9,8 +9,6 @@ import UIKit /// ViewController 通用工具,封装 Toast、Loading 和 ViewModel 绑定。 @MainActor enum ViewControllerHelpers { - static var services: AppServices { AppServices.shared } - /// 绑定 ViewModel 的 onChange 回调并在 deinit 时自动清理。 static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) { onChange?() diff --git a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift index 840126e..1a1575a 100644 --- a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift +++ b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift @@ -42,7 +42,8 @@ final class TitleSubtitleTableViewCell: UITableViewCell { /// 模块列表页基类,封装 UITableView Diffable、下拉刷新和 ViewModel onChange 绑定。 class ModuleTableViewController: UIViewController, UITableViewDelegate { - let services = AppServices.shared + /// 全局依赖容器快捷访问,等价于 `appServices`。 + var services: AppServices { appServices } let tableView = UITableView(frame: .zero, style: .insetGrouped) private let refreshControl = UIRefreshControl() private let activityIndicator = UIActivityIndicatorView(style: .medium) @@ -289,7 +290,8 @@ private extension Array { /// 模块复杂列表基类,封装 UICollectionView Diffable 与下拉刷新。 class ModuleCollectionViewController: UIViewController { - let services = AppServices.shared + /// 全局依赖容器快捷访问,等价于 `appServices`。 + var services: AppServices { appServices } let collectionView: UICollectionView private let refreshControl = UIRefreshControl() private let activityIndicator = UIActivityIndicatorView(style: .medium) diff --git a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift index 3563e05..5a6f880 100644 --- a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift +++ b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift @@ -10,7 +10,6 @@ import UIKit /// 摄影师邀请页。 final class PhotographerInviteViewController: UIViewController { - private let services = AppServices.shared private let viewModel = PhotographerInviteViewModel() private let scrollView = UIScrollView() private let contentStack = UIStackView() @@ -27,7 +26,7 @@ final class PhotographerInviteViewController: UIViewController { view.backgroundColor = UIColor(hex: 0xF5F7FA) setupUI() viewModel.onChange = { [weak self] in self?.render() } - Task { await viewModel.reload(api: services.inviteAPI) } + Task { await viewModel.reload(api: appServices.inviteAPI) } } /// 初始化UI相关 UI 或状态。 diff --git a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift index 32839e5..e811ee1 100644 --- a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift +++ b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift @@ -15,7 +15,6 @@ final class OperatingAreaViewController: UIViewController { private lazy var blockReasonView = AppContentUnavailableView(title: "无法展示地图", systemImage: "mappin.slash") private var dataSource: UITableViewDiffableDataSource! - private var services: AppServices { AppServices.shared } /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { @@ -112,12 +111,12 @@ final class OperatingAreaViewController: UIViewController { private func scopeText() -> String { switch viewModel.mode { case .storeAdmin: - return services.accountContext.currentStore?.name ?? "当前店铺" + return appServices.accountContext.currentStore?.name ?? "当前店铺" case .scenicAdmin: - return services.accountContext.currentScenic?.name ?? "当前景区" + return appServices.accountContext.currentScenic?.name ?? "当前景区" case nil: - return services.accountContext.currentScenic?.name - ?? services.accountContext.currentStore?.name + return appServices.accountContext.currentScenic?.name + ?? appServices.accountContext.currentStore?.name ?? "未选择业务范围" } } @@ -130,16 +129,16 @@ final class OperatingAreaViewController: UIViewController { } private func reload(showLoading: Bool) async { - if showLoading { services.globalLoading.show() } - defer { if showLoading { services.globalLoading.hide() } } + if showLoading { appServices.globalLoading.show() } + defer { if showLoading { appServices.globalLoading.hide() } } await viewModel.reload( - api: services.operatingAreaAPI, - accountContext: services.accountContext, - permissionContext: services.permissionContext + api: appServices.operatingAreaAPI, + accountContext: appServices.accountContext, + permissionContext: appServices.permissionContext ) if let message = viewModel.errorMessage { - services.toastCenter.show(message) + appServices.toastCenter.show(message) } } } diff --git a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift index 0e50279..64a18c4 100644 --- a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift +++ b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift @@ -10,7 +10,6 @@ import UIKit /// 收款页。 final class PaymentCollectionViewController: UIViewController { - private let services = AppServices.shared private let viewModel = PaymentCollectionViewModel() private let qrImageView = UIImageView() private let statusLabel = UILabel() @@ -25,7 +24,7 @@ final class PaymentCollectionViewController: UIViewController { view.backgroundColor = UIColor(hex: 0xF5F7FA) setupUI() viewModel.onChange = { [weak self] in self?.render() } - Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) } + Task { await viewModel.loadPayCode(api: appServices.paymentAPI, scenicId: appServices.currentScenicId) } } /// 初始化UI相关 UI 或状态。 @@ -76,8 +75,8 @@ final class PaymentCollectionViewController: UIViewController { @objc private func togglePolling() { Task { await viewModel.pollUntilPaymentDetected( - api: services.paymentAPI, - scenicId: services.currentScenicId + api: appServices.paymentAPI, + scenicId: appServices.currentScenicId ) } } diff --git a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift index 80a2931..25b4c83 100644 --- a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift +++ b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift @@ -141,8 +141,6 @@ final class PunchPointEditorViewController: UIViewController { private let mapPicker = PunchPointMapPickerView() private let locationProvider = ForegroundLocationProvider() - private var services: AppServices { AppServices.shared } - init(punchPointId: Int?) { self.punchPointId = punchPointId super.init(nibName: nil, bundle: nil) @@ -175,7 +173,7 @@ final class PunchPointEditorViewController: UIViewController { Task { guard let punchPointId else { return } - if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { + if let detail = try? await appServices.punchPointAPI.punchPointInfo(id: punchPointId) { viewModel.apply(detail) syncFieldsFromViewModel() } @@ -252,7 +250,7 @@ final class PunchPointEditorViewController: UIViewController { ) syncFieldsFromViewModel() } catch { - services.toastCenter.show("定位失败:\(error.localizedDescription)") + appServices.toastCenter.show("定位失败:\(error.localizedDescription)") } } } @@ -264,15 +262,15 @@ final class PunchPointEditorViewController: UIViewController { viewModel.longitudeText = longitudeField.text ?? "" Task { let success = await viewModel.submit( - scenicId: services.currentScenicId, - api: services.punchPointAPI, - uploadService: services.ossUploadService + scenicId: appServices.currentScenicId, + api: appServices.punchPointAPI, + uploadService: appServices.ossUploadService ) if success { - services.toastCenter.show("保存成功") + appServices.toastCenter.show("保存成功") navigationController?.popViewController(animated: true) } else if let message = viewModel.errorMessage { - services.toastCenter.show(message) + appServices.toastCenter.show(message) } } } diff --git a/suixinkan_iosTests/AppServicesTests.swift b/suixinkan_iosTests/AppServicesTests.swift new file mode 100644 index 0000000..4f61d4f --- /dev/null +++ b/suixinkan_iosTests/AppServicesTests.swift @@ -0,0 +1,104 @@ +// +// AppServicesTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/26. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// `AppServices` 依赖容器测试,覆盖分组结构与测试专用初始化。 +final class AppServicesTests: XCTestCase { + /// 测试专用初始化应创建独立实例,且各 API 共享同一 `APIClient`。 + func testTestingInitializerCreatesGroupedDependenciesWithSharedClient() async throws { + let session = AppServicesRecordingURLSession(responses: [ + try TestFixture.data(named: "writeoff_list_success"), + try TestFixture.data(named: "user_info_success") + ]) + let apiClient = APIClient(session: session) + let services = AppServices(apiClient: apiClient) + + XCTAssertNotIdentical(services.network.apiClient as AnyObject, AppServices.shared.network.apiClient as AnyObject) + _ = try await services.ordersAPI.writeOffList(scenicId: 1, storeId: nil, page: 1, pageSize: 1) + _ = try await services.profileAPI.userInfo() + XCTAssertEqual(session.requests.count, 2) + XCTAssertNotNil(services.session.appSession) + XCTAssertNotNil(services.context.accountContext) + XCTAssertNotNil(services.ui.toastCenter) + XCTAssertNotNil(services.runtime.scenicQueueRuntime) + } + + /// 扁平属性应转发到对应 bundle,保持现有调用方兼容。 + func testFlatAccessorsForwardToBundles() { + let services = AppServices(apiClient: APIClient(session: AppServicesRecordingURLSession())) + + XCTAssertIdentical(services.appSession as AnyObject, services.session.appSession as AnyObject) + XCTAssertIdentical(services.ordersAPI as AnyObject, services.network.ordersAPI as AnyObject) + XCTAssertIdentical(services.toastCenter as AnyObject, services.ui.toastCenter as AnyObject) + XCTAssertIdentical(services.scenicQueueRuntime as AnyObject, services.runtime.scenicQueueRuntime as AnyObject) + } + + /// `APIClient` 应通过 `AppSession.token` 提供鉴权 token。 + func testAuthTokenProviderUsesAppSessionToken() async throws { + let session = AppServicesRecordingURLSession(data: try TestFixture.data(named: "user_info_success")) + let apiClient = APIClient(session: session) + let services = AppServices(apiClient: apiClient) + services.appSession.markLoggedIn(token: "test-token") + + _ = try await services.profileAPI.userInfo() + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "test-token") + } + + /// 便捷属性应基于 `AccountContext` 派生当前用户与景区信息。 + func testConveniencePropertiesReflectAccountContext() { + let services = AppServices(apiClient: APIClient(session: AppServicesRecordingURLSession())) + services.accountContext.applyLogin( + profile: AccountProfile( + userId: "42", + displayName: "测试用户", + phone: "18600000000", + avatarURL: nil + ) + ) + services.accountContext.currentScenic = BusinessScope(id: 7, name: "测试景区", kind: .scenic) + + XCTAssertEqual(services.userId, "42") + XCTAssertEqual(services.staffId, 42) + XCTAssertEqual(services.currentScenicId, 7) + } +} + +/// 记录请求的 URLSession 测试替身。 +private final class AppServicesRecordingURLSession: URLSessionProtocol { + private(set) var requests: [URLRequest] = [] + private let responses: [Data] + private var responseIndex = 0 + + /// 初始化实例。 + init(data: Data = Data()) { + responses = [data] + } + + /// 按顺序返回多段响应数据。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回固定响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses[min(responseIndex, responses.count - 1)] + responseIndex += 1 + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (data, response) + } +}