diff --git a/suixinkan_ios/App/App.md b/suixinkan_ios/App/App.md index d45e78e..e55c5e0 100644 --- a/suixinkan_ios/App/App.md +++ b/suixinkan_ios/App/App.md @@ -13,7 +13,10 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 ## 核心对象 - `AppDelegate` / `SceneDelegate`:UIKit 应用入口;`SceneDelegate` 挂载 `RootViewController` 并持有其强引用。 -- `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。 +- `AppServices`:全局依赖容器(Composition Root),等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;自身只持有 Session / Context / Network / UI / Runtime 分组和导航器。 +- `AppServicesAssembly`:负责创建生产或测试依赖图,避免装配细节堆在容器本体中。 +- `AppFeatureServices`:按业务域提供模块级 Facade,避免页面散取全局 API、Context、Toast、Loading 和运行时依赖。 +- `AppSessionLifecycleCoordinator`:处理冷启动恢复、登录后景点刷新、登出清理和推送授权;`RootViewController` 只负责根控制器切换。 - `RootViewController`:监听 `AppSession.phase`,未登录时在自身上展示登录/恢复页,登录后将 `MainTabBarController` 设为 window 根控制器,并挂载全局 Toast / Loading。 - `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。 - `AccountContext`:保存当前账号资料、景区作用域和门店作用域。 @@ -27,7 +30,19 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 ## AppServices 依赖约定 -`AppServices.shared` 是唯一 Composition Root,集中创建 `APIClient`、各业务 API 与全局 Context,并绑定 token provider。 +`AppServices.shared` 是唯一 Composition Root,通过 `AppServicesAssembly` 集中创建 `APIClient`、各业务 API 与全局 Context,并绑定 token provider。 + +`AppServices` 本体保持轻量:只保存已装配的依赖分组、绑定跨分组关系和暴露 App 生命周期入口。兼容旧调用的扁平属性统一放在 `AppServices+Accessors.swift`,新增复杂业务不得继续塞进容器本体。 + +`NetworkBundle` 内部按业务域拆分为 Account / Commerce / Operation / Content 四组,所有 API 仍共享同一 `APIClient`。旧的 `services.ordersAPI`、`services.profileAPI` 等扁平访问器继续保留,仅作为兼容层。 + +新增页面优先通过模块 Facade 获取依赖: + +- `ordersFeatureServices`:订单 API、景区/门店/角色、订单导航、Toast / Loading。 +- `profileFeatureServices`:登录、资料、账号上下文和退出登录清理。 +- `homeFeatureServices`:首页菜单、账号权限上下文和首页导航。 +- `assetsFeatureServices`:资产 API、OSS 上传、当前景区和云传记录仓库。 +- `queueFeatureServices`:排队 API、用户/景区/景点上下文和排队运行时。 ### 何时通过 `init(services:)` 注入 @@ -56,16 +71,17 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 ` - `PushNotificationManager.shared`:`AppDelegate` 回调桥 - `CloudTransferStore.shared`:跨页面云传任务状态 +- `ScenicQueueRuntime.shared`:跨页面排队轮询、后台任务和语音播报运行时 - 第三方 SDK(如 `AMapServices`) -`AppSession`、`AccountContext`、各 `*API` 等**不应**拆成多个 `.shared`,须由 `AppServices` 统一创建,并在登出时由 `RootViewController` 统一 reset。 +`AppSession`、`AccountContext`、各 `*API`、`AppNavigator`、持久化 Store 等**不应**拆成多个 `.shared`,须由 `AppServices` 统一创建,并在登出时由 `AppSessionLifecycleCoordinator` 统一 reset。 ## 启动流程 1. `AppDelegate` 创建 `RootViewController(services: .shared)`。 -2. `AppServices` 初始化共享依赖(`NetworkBundle` 内共享同一 `APIClient`)。 +2. `AppServices` 初始化共享依赖(`NetworkBundle` 子分组内共享同一 `APIClient`)。 3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 -4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 +4. `AppSessionLifecycleCoordinator` 调用 `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`。 6. 有 token 时进入 `restoring`,先恢复本地账号快照。 7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 diff --git a/suixinkan_ios/App/AppFeatureServices.swift b/suixinkan_ios/App/AppFeatureServices.swift new file mode 100644 index 0000000..59e6c32 --- /dev/null +++ b/suixinkan_ios/App/AppFeatureServices.swift @@ -0,0 +1,139 @@ +// +// AppFeatureServices.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// 订单模块依赖门面,集中提供订单页面常用 API、上下文和导航反馈能力。 +struct OrdersFeatureServices { + private let services: AppServices + + /// 初始化订单模块依赖门面。 + init(services: AppServices) { + self.services = services + } + + var api: OrdersAPI { services.ordersAPI } + var currentScenicId: Int? { services.currentScenicId } + var currentStoreId: Int? { services.accountContext.currentStore?.id } + var currentRoleId: Int? { services.permissionContext.currentRole?.id } + var navigator: AppNavigator { services.appNavigator } + var toastCenter: ToastCenter { services.toastCenter } + var globalLoading: GlobalLoadingCenter { services.globalLoading } +} + +@MainActor +/// 个人中心与认证模块依赖门面,集中提供账号资料、登录协调和退出登录能力。 +struct ProfileFeatureServices { + private let services: AppServices + + /// 初始化个人中心模块依赖门面。 + init(services: AppServices) { + self.services = services + } + + var authAPI: AuthAPI { services.authAPI } + var profileAPI: ProfileAPI { services.profileAPI } + var accountContextAPI: AccountContextAPI { services.accountContextAPI } + var uploader: OSSUploadService { services.ossUploadService } + var appSession: AppSession { services.appSession } + var accountContext: AccountContext { services.accountContext } + var permissionContext: PermissionContext { services.permissionContext } + var scenicSpotContext: ScenicSpotContext { services.scenicSpotContext } + var authSessionCoordinator: AuthSessionCoordinator { services.authSessionCoordinator } + var currentScenicId: Int? { services.currentScenicId } + var navigator: AppNavigator { services.appNavigator } + var toastCenter: ToastCenter { services.toastCenter } + var globalLoading: GlobalLoadingCenter { services.globalLoading } + + /// 执行完整退出登录,并停止跨登录态的排队运行时。 + func logout() { + authSessionCoordinator.logout( + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + scenicSpotContext: scenicSpotContext, + appNavigator: navigator, + toastCenter: toastCenter + ) + services.scenicQueueRuntime.stop() + } +} + +@MainActor +/// 首页模块依赖门面,集中提供首页菜单、账号权限上下文和首页导航能力。 +struct HomeFeatureServices { + private let services: AppServices + + /// 初始化首页模块依赖门面。 + init(services: AppServices) { + self.services = services + } + + var accountContext: AccountContext { services.accountContext } + var permissionContext: PermissionContext { services.permissionContext } + var commonMenuStore: HomeCommonMenuStore { HomeCommonMenuStore() } + var navigator: AppNavigator { services.appNavigator } + var toastCenter: ToastCenter { services.toastCenter } +} + +@MainActor +/// 资产模块依赖门面,集中提供素材云盘 API、上传服务、景区上下文和传输状态仓库。 +struct AssetsFeatureServices { + private let services: AppServices + + /// 初始化资产模块依赖门面。 + init(services: AppServices) { + self.services = services + } + + var api: AssetsAPI { services.assetsAPI } + var uploader: OSSUploadService { services.ossUploadService } + var currentScenicId: Int? { services.currentScenicId } + var transferStore: CloudTransferStore { CloudTransferStore.shared } + var toastCenter: ToastCenter { services.toastCenter } + var globalLoading: GlobalLoadingCenter { services.globalLoading } +} + +@MainActor +/// 排队模块依赖门面,集中提供排队 API、账号景区上下文、景点上下文和排队运行时。 +struct QueueFeatureServices { + private let services: AppServices + + /// 初始化排队模块依赖门面。 + init(services: AppServices) { + self.services = services + } + + var api: ScenicQueueAPI { services.scenicQueueAPI } + var currentScenicId: Int? { services.currentScenicId } + var userId: String? { services.userId } + var scenicSpotContext: ScenicSpotContext { services.scenicSpotContext } + var runtime: ScenicQueueRuntime { ScenicQueueRuntime.shared } + var toastCenter: ToastCenter { services.toastCenter } + var globalLoading: GlobalLoadingCenter { services.globalLoading } + + /// 使用当前账号和景区上下文刷新 App 级排队运行时。 + func updateRuntime(scenePhase: AppScenePhase) { + runtime.update( + api: api, + userId: userId, + scenicId: currentScenicId, + scenePhase: scenePhase + ) + } +} + +@MainActor +/// `AppServices` 的模块依赖门面访问器,供新代码按功能域读取依赖。 +extension AppServices { + var ordersFeatureServices: OrdersFeatureServices { OrdersFeatureServices(services: self) } + var profileFeatureServices: ProfileFeatureServices { ProfileFeatureServices(services: self) } + var homeFeatureServices: HomeFeatureServices { HomeFeatureServices(services: self) } + var assetsFeatureServices: AssetsFeatureServices { AssetsFeatureServices(services: self) } + var queueFeatureServices: QueueFeatureServices { QueueFeatureServices(services: self) } +} diff --git a/suixinkan_ios/App/AppServices+Accessors.swift b/suixinkan_ios/App/AppServices+Accessors.swift new file mode 100644 index 0000000..34ce1db --- /dev/null +++ b/suixinkan_ios/App/AppServices+Accessors.swift @@ -0,0 +1,64 @@ +// +// AppServices+Accessors.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// `AppServices` 兼容访问器,供既有页面继续按扁平属性读取依赖。 +extension AppServices { + 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 + } + + /// 当前用户 staffId,用于钱包等接口。 + var staffId: Int? { + Int(accountContext.profile?.userId ?? "") + } + + /// 当前用户 ID 字符串。 + var userId: String? { + accountContext.profile?.userId + } +} diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift index d575c68..2309675 100644 --- a/suixinkan_ios/App/AppServices.swift +++ b/suixinkan_ios/App/AppServices.swift @@ -17,132 +17,39 @@ final class AppServices { let network: NetworkBundle let ui: UIFeedbackBundle let runtime: AppRuntimeBundle - let appNavigator = AppNavigator() - - 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 - } - - /// 当前用户 staffId,用于钱包等接口。 - var staffId: Int? { - Int(accountContext.profile?.userId ?? "") - } - - /// 当前用户 ID 字符串。 - var userId: String? { - accountContext.profile?.userId - } + let appNavigator: AppNavigator /// 生产环境默认初始化。 - private init() { - let tokenStore = SessionTokenStore() - let snapshotStore = AccountSnapshotStore() - let preferencesStore = AppPreferencesStore() - let apiClient = APIClient() - - 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() + private convenience init() { + self.init(assembly: AppServicesAssembly()) } /// 测试专用初始化,允许注入 mock `APIClient` 与存储替身。 - init( + convenience 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( + self.init( + assembly: AppServicesAssembly( + apiClient: apiClient, tokenStore: tokenStore, snapshotStore: snapshotStore, preferencesStore: preferencesStore - ), - sessionBootstrapper: SessionBootstrapper( - tokenStore: tokenStore, - snapshotStore: snapshotStore ) ) + } + + /// 使用装配器完成容器初始化并建立跨 bundle 绑定。 + private init(assembly: AppServicesAssembly) { + let dependencies = assembly.makeDependencies() + session = dependencies.session + context = dependencies.context + network = dependencies.network + ui = dependencies.ui + runtime = dependencies.runtime + appNavigator = dependencies.appNavigator bindAuthTokenProvider() } diff --git a/suixinkan_ios/App/AppServicesAssembly.swift b/suixinkan_ios/App/AppServicesAssembly.swift new file mode 100644 index 0000000..28a2e15 --- /dev/null +++ b/suixinkan_ios/App/AppServicesAssembly.swift @@ -0,0 +1,84 @@ +// +// AppServicesAssembly.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// `AppServices` 的装配输入,集中保存生产与测试环境可替换的底层依赖。 +struct AppServicesAssembly { + private let apiClient: APIClient + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore + private let preferencesStore: AppPreferencesStore + + /// 初始化生产装配器。 + init() { + apiClient = APIClient() + tokenStore = SessionTokenStore() + snapshotStore = AccountSnapshotStore() + preferencesStore = AppPreferencesStore() + } + + /// 初始化测试装配器,允许传入网络与存储替身。 + init( + apiClient: APIClient, + tokenStore: SessionTokenStore, + snapshotStore: AccountSnapshotStore, + preferencesStore: AppPreferencesStore + ) { + self.apiClient = apiClient + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + self.preferencesStore = preferencesStore + } + + /// 创建 `AppServices` 所需的完整依赖图。 + func makeDependencies() -> AppServicesDependencies { + AppServicesDependencies( + session: makeSessionBundle(), + context: AppContextBundle( + accountContext: AccountContext(), + permissionContext: PermissionContext(), + scenicSpotContext: ScenicSpotContext() + ), + network: NetworkBundle(apiClient: apiClient), + ui: UIFeedbackBundle( + toastCenter: ToastCenter(), + globalLoading: GlobalLoadingCenter() + ), + runtime: AppRuntimeBundle(scenicQueueRuntime: ScenicQueueRuntime.shared), + appNavigator: AppNavigator() + ) + } + + /// 创建登录恢复与退出登录相关依赖。 + private func makeSessionBundle() -> SessionBundle { + SessionBundle( + appSession: AppSession(), + authSessionCoordinator: AuthSessionCoordinator( + tokenStore: tokenStore, + snapshotStore: snapshotStore, + preferencesStore: preferencesStore + ), + sessionBootstrapper: SessionBootstrapper( + tokenStore: tokenStore, + snapshotStore: snapshotStore + ) + ) + } +} + +@MainActor +/// 已装配好的应用级依赖集合,供 `AppServices` 直接持有。 +struct AppServicesDependencies { + let session: SessionBundle + let context: AppContextBundle + let network: NetworkBundle + let ui: UIFeedbackBundle + let runtime: AppRuntimeBundle + let appNavigator: AppNavigator +} diff --git a/suixinkan_ios/App/AppServicesBundles.swift b/suixinkan_ios/App/AppServicesBundles.swift index 011ca1c..8c60509 100644 --- a/suixinkan_ios/App/AppServicesBundles.swift +++ b/suixinkan_ios/App/AppServicesBundles.swift @@ -27,61 +27,130 @@ struct AppContextBundle { /// 网络层依赖分组,共享同一 `APIClient` 与 token 链路。 struct NetworkBundle { let apiClient: APIClient + let account: AccountNetworkBundle + let commerce: CommerceNetworkBundle + let operation: OperationNetworkBundle + let content: ContentNetworkBundle + + var authAPI: AuthAPI { account.authAPI } + var profileAPI: ProfileAPI { account.profileAPI } + var accountContextAPI: AccountContextAPI { account.accountContextAPI } + var pushAPI: PushAPI { account.pushAPI } + var ordersAPI: OrdersAPI { commerce.ordersAPI } + var paymentAPI: PaymentAPI { commerce.paymentAPI } + var walletAPI: WalletAPI { commerce.walletAPI } + var inviteAPI: InviteAPI { commerce.inviteAPI } + var scenicSettlementAPI: ScenicSettlementAPI { commerce.scenicSettlementAPI } + var statisticsAPI: StatisticsAPI { operation.statisticsAPI } + var scenicPermissionAPI: ScenicPermissionAPI { operation.scenicPermissionAPI } + var scenicQueueAPI: ScenicQueueAPI { operation.scenicQueueAPI } + var operatingAreaAPI: OperatingAreaAPI { operation.operatingAreaAPI } + var pilotCertificationAPI: PilotCertificationAPI { operation.pilotCertificationAPI } + var taskAPI: TaskAPI { operation.taskAPI } + var scheduleAPI: ScheduleAPI { operation.scheduleAPI } + var punchPointAPI: PunchPointAPI { operation.punchPointAPI } + var locationReportAPI: LocationReportAPI { operation.locationReportAPI } + var uploadAPI: UploadAPI { content.uploadAPI } + var ossUploadService: OSSUploadService { content.ossUploadService } + var messageCenterAPI: MessageCenterAPI { content.messageCenterAPI } + var liveAPI: LiveAPI { content.liveAPI } + var projectAPI: ProjectAPI { content.projectAPI } + var assetsAPI: AssetsAPI { content.assetsAPI } + + /// 基于共享 `APIClient` 创建全部业务 API 分组。 + init(apiClient: APIClient) { + self.apiClient = apiClient + account = AccountNetworkBundle(apiClient: apiClient) + commerce = CommerceNetworkBundle(apiClient: apiClient) + operation = OperationNetworkBundle(apiClient: apiClient) + content = ContentNetworkBundle(apiClient: apiClient) + } +} + +@MainActor +/// 账号、登录、资料和推送相关网络依赖。 +struct AccountNetworkBundle { let authAPI: AuthAPI let profileAPI: ProfileAPI - let uploadAPI: UploadAPI - let ossUploadService: OSSUploadService let accountContextAPI: AccountContextAPI + let pushAPI: PushAPI + + /// 基于共享 `APIClient` 创建账号网络依赖。 + init(apiClient: APIClient) { + authAPI = AuthAPI(client: apiClient) + profileAPI = ProfileAPI(client: apiClient) + accountContextAPI = AccountContextAPI(client: apiClient) + pushAPI = PushAPI(client: apiClient) + } +} + +@MainActor +/// 订单、收款、钱包、邀请和结算相关网络依赖。 +struct CommerceNetworkBundle { let ordersAPI: OrdersAPI - let statisticsAPI: StatisticsAPI let paymentAPI: PaymentAPI let walletAPI: WalletAPI - let pushAPI: PushAPI - let scenicPermissionAPI: ScenicPermissionAPI + let inviteAPI: InviteAPI let scenicSettlementAPI: ScenicSettlementAPI - let messageCenterAPI: MessageCenterAPI + + /// 基于共享 `APIClient` 创建交易网络依赖。 + init(apiClient: APIClient) { + ordersAPI = OrdersAPI(client: apiClient) + paymentAPI = PaymentAPI(client: apiClient) + walletAPI = WalletAPI(client: apiClient) + inviteAPI = InviteAPI(client: apiClient) + scenicSettlementAPI = ScenicSettlementAPI(client: apiClient) + } +} + +@MainActor +/// 运营管理、权限、排队、任务、排期和位置上报相关网络依赖。 +struct OperationNetworkBundle { + let statisticsAPI: StatisticsAPI + let scenicPermissionAPI: ScenicPermissionAPI 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。 + /// 基于共享 `APIClient` 创建运营网络依赖。 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 +/// 内容、素材、直播、项目和消息中心相关网络依赖。 +struct ContentNetworkBundle { + let uploadAPI: UploadAPI + let ossUploadService: OSSUploadService + let messageCenterAPI: MessageCenterAPI + let liveAPI: LiveAPI + let projectAPI: ProjectAPI + let assetsAPI: AssetsAPI + + /// 基于共享 `APIClient` 创建内容网络依赖。 + init(apiClient: APIClient) { + uploadAPI = UploadAPI(client: apiClient) + ossUploadService = OSSUploadService(configService: uploadAPI) + messageCenterAPI = MessageCenterAPI(client: apiClient) + liveAPI = LiveAPI(client: apiClient) + projectAPI = ProjectAPI(client: apiClient) + assetsAPI = AssetsAPI(client: apiClient) + } +} + @MainActor /// 全局 UI 反馈(Toast、Loading)分组。 struct UIFeedbackBundle { diff --git a/suixinkan_ios/App/AppSessionLifecycleCoordinator.swift b/suixinkan_ios/App/AppSessionLifecycleCoordinator.swift new file mode 100644 index 0000000..3d20f32 --- /dev/null +++ b/suixinkan_ios/App/AppSessionLifecycleCoordinator.swift @@ -0,0 +1,84 @@ +// +// AppSessionLifecycleCoordinator.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +@MainActor +/// 应用会话生命周期协调器,负责登录恢复、登录后上下文刷新、登出清理和推送注册。 +final class AppSessionLifecycleCoordinator { + private let services: AppServices + + /// 初始化会话生命周期协调器。 + init(services: AppServices) { + self.services = services + } + + /// 配置系统推送入口。 + func configurePushNotifications() { + services.configurePushNotifications() + } + + /// 冷启动时恢复本地会话,并在已登录时申请推送权限。 + func bootstrapSession() { + Task { + await services.globalLoading.withLoading { + await services.sessionBootstrapper.restore( + appSession: services.appSession, + accountContext: services.accountContext, + permissionContext: services.permissionContext, + profileAPI: services.profileAPI, + accountContextAPI: services.accountContextAPI, + toastCenter: services.toastCenter + ) + } + + requestPushAuthorizationIfNeeded() + } + } + + /// 登录阶段变化后的业务状态处理,根视图切换由调用方先完成。 + func handleSessionPhaseChange() { + switch services.appSession.phase { + case .loggedIn: + requestPushAuthorizationIfNeeded() + Task { await reloadScenicSpotContextIfNeeded() } + case .loggedOut: + resetStateAfterLogout() + case .restoring: + break + } + } + + /// 登录成功后按当前景区 ID 刷新景区上下文。 + private func reloadScenicSpotContextIfNeeded() async { + guard services.appSession.isLoggedIn else { + services.scenicSpotContext.reset() + return + } + await services.scenicSpotContext.reload( + scenicId: services.accountContext.currentScenic?.id, + api: services.accountContextAPI + ) + } + + /// 登出后重置账号、权限、路由与排队运行时,避免残留旧状态。 + private func resetStateAfterLogout() { + services.accountContext.reset() + services.permissionContext.reset() + services.scenicSpotContext.reset() + services.appNavigator.resetAllStacks() + services.appNavigator.detach() + services.toastCenter.dismiss() + services.scenicQueueRuntime.stop() + } + + /// 非 UI Test 环境下申请推送权限并注册 APNs。 + private func requestPushAuthorizationIfNeeded() { + guard services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests else { return } + PushNotificationManager.shared.requestAuthorizationAndRegister() + } +} diff --git a/suixinkan_ios/App/ViewControllers/RootViewController.swift b/suixinkan_ios/App/ViewControllers/RootViewController.swift index 8d38313..d2aa3b4 100644 --- a/suixinkan_ios/App/ViewControllers/RootViewController.swift +++ b/suixinkan_ios/App/ViewControllers/RootViewController.swift @@ -11,11 +11,13 @@ import UIKit final class RootViewController: UIViewController { private let services: AppServices + private let lifecycleCoordinator: AppSessionLifecycleCoordinator private var currentAuthChild: UIViewController? /// 初始化实例。 init(services: AppServices = .shared) { self.services = services + lifecycleCoordinator = AppSessionLifecycleCoordinator(services: services) super.init(nibName: nil, bundle: nil) } @@ -34,64 +36,14 @@ final class RootViewController: UIViewController { } applyRoot(for: services.appSession.phase) - bootstrapSession() - services.configurePushNotifications() - } - - /// 冷启动时恢复本地会话,并在已登录时申请推送权限。 - private func bootstrapSession() { - Task { - await services.globalLoading.withLoading { - await services.sessionBootstrapper.restore( - appSession: services.appSession, - accountContext: services.accountContext, - permissionContext: services.permissionContext, - profileAPI: services.profileAPI, - accountContextAPI: services.accountContextAPI, - toastCenter: services.toastCenter - ) - } - - if services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests { - PushNotificationManager.shared.requestAuthorizationAndRegister() - } - } + lifecycleCoordinator.bootstrapSession() + lifecycleCoordinator.configurePushNotifications() } /// 登录阶段变化时切换 window 根控制器,并在登出时清理各上下文状态。 private func handleSessionPhaseChange() { applyRoot(for: services.appSession.phase) - - switch services.appSession.phase { - case .loggedIn: - if !AppUITestLaunchState.isRunningUITests { - PushNotificationManager.shared.requestAuthorizationAndRegister() - } - Task { await reloadScenicSpotContextIfNeeded() } - case .loggedOut: - // 登出后重置账号、权限、路由与排队运行时,避免残留旧状态 - services.accountContext.reset() - services.permissionContext.reset() - services.scenicSpotContext.reset() - services.appNavigator.resetAllStacks() - services.appNavigator.detach() - services.toastCenter.dismiss() - services.scenicQueueRuntime.stop() - case .restoring: - break - } - } - - /// 登录成功后按当前景区 ID 刷新景区上下文。 - private func reloadScenicSpotContextIfNeeded() async { - guard services.appSession.isLoggedIn else { - services.scenicSpotContext.reset() - return - } - await services.scenicSpotContext.reload( - scenicId: services.accountContext.currentScenic?.id, - api: services.accountContextAPI - ) + lifecycleCoordinator.handleSessionPhaseChange() } /// 按登录阶段切换 window 根控制器。 diff --git a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift index 29ce773..1f3c163 100644 --- a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift +++ b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift @@ -18,6 +18,7 @@ enum AppScenePhase { @MainActor /// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。 final class ScenicQueueRuntime { + static let shared = ScenicQueueRuntime() var onChange: (() -> Void)? private(set) var isMonitoring = false { didSet { onChange?() } } diff --git a/suixinkan_iosTests/AppFeatureServicesTests.swift b/suixinkan_iosTests/AppFeatureServicesTests.swift new file mode 100644 index 0000000..2c0bb84 --- /dev/null +++ b/suixinkan_iosTests/AppFeatureServicesTests.swift @@ -0,0 +1,169 @@ +// +// AppFeatureServicesTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 模块依赖门面测试,覆盖上下文派生和独立单例运行时边界。 +final class AppFeatureServicesTests: XCTestCase { + /// 订单门面应从全局账号和权限上下文派生当前业务参数。 + func testOrdersFeatureServicesReflectsCurrentContext() { + let services = makeServices() + applyContext(to: services) + + let feature = services.ordersFeatureServices + + XCTAssertIdentical(feature.api as AnyObject, services.ordersAPI as AnyObject) + XCTAssertEqual(feature.currentScenicId, 7) + XCTAssertEqual(feature.currentStoreId, 9) + XCTAssertEqual(feature.currentRoleId, 3) + XCTAssertIdentical(feature.navigator as AnyObject, services.appNavigator as AnyObject) + XCTAssertIdentical(feature.toastCenter as AnyObject, services.toastCenter as AnyObject) + XCTAssertIdentical(feature.globalLoading as AnyObject, services.globalLoading as AnyObject) + } + + /// 资产门面应提供共享传输仓库和上传依赖。 + func testAssetsFeatureServicesUsesSharedTransferStore() { + let services = makeServices() + applyContext(to: services) + + let feature = services.assetsFeatureServices + + XCTAssertIdentical(feature.api as AnyObject, services.assetsAPI as AnyObject) + XCTAssertIdentical(feature.uploader as AnyObject, services.ossUploadService as AnyObject) + XCTAssertIdentical(feature.transferStore as AnyObject, CloudTransferStore.shared as AnyObject) + XCTAssertEqual(feature.currentScenicId, 7) + } + + /// 排队门面应暴露 App 级共享运行时和当前用户景区上下文。 + func testQueueFeatureServicesUsesSharedRuntimeAndContext() { + let services = makeServices() + applyContext(to: services) + + let feature = services.queueFeatureServices + + XCTAssertIdentical(feature.api as AnyObject, services.scenicQueueAPI as AnyObject) + XCTAssertIdentical(feature.runtime as AnyObject, ScenicQueueRuntime.shared as AnyObject) + XCTAssertIdentical(feature.scenicSpotContext as AnyObject, services.scenicSpotContext as AnyObject) + XCTAssertEqual(feature.currentScenicId, 7) + XCTAssertEqual(feature.userId, "42") + } + + /// 个人中心门面执行退出登录时,应清理登录态并停止共享排队运行时。 + func testProfileFeatureLogoutStopsQueueRuntime() { + let services = makeServices() + applyContext(to: services) + services.appSession.markLoggedIn(token: "token") + ScenicQueueSettingsStore.saveSelectedSpot(id: 11, name: "一号点", userId: "42", scenicId: 7) + + let runtime = ScenicQueueRuntime.shared + defer { + runtime.stop() + clearQueueSelection(userId: "42", scenicId: 7) + } + runtime.update(api: AppFeatureScenicQueueMock(), userId: "42", scenicId: 7, scenePhase: .active) + XCTAssertTrue(runtime.isMonitoring) + + services.profileFeatureServices.logout() + + XCTAssertFalse(runtime.isMonitoring) + XCTAssertFalse(services.appSession.isLoggedIn) + XCTAssertNil(services.accountContext.profile) + } + + /// 创建带隔离网络会话的测试容器。 + private func makeServices() -> AppServices { + AppServices(apiClient: APIClient(session: AppFeatureRecordingURLSession())) + } + + /// 写入测试所需的账号、景区、门店和角色上下文。 + private func applyContext(to services: AppServices) { + services.accountContext.applyLogin( + profile: AccountProfile( + userId: "42", + displayName: "测试用户", + phone: "18600000000", + avatarURL: nil + ) + ) + services.accountContext.replaceScopes( + scenic: [BusinessScope(id: 7, name: "测试景区", kind: .scenic)], + stores: [BusinessScope(id: 9, name: "测试门店", kind: .store, parentScenicId: 7)] + ) + services.permissionContext.currentRole = RoleInfo(id: 3, name: "店长") + } + + /// 清理排队运行时测试写入的本地选中点。 + private func clearQueueSelection(userId: String, scenicId: Int) { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) + defaults.removeObject(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) + defaults.removeObject(forKey: ScenicQueueSettingsStore.scopedKey( + base: ScenicQueueLocalSettings.selectedSpotIdKey, + userId: userId, + scenicId: scenicId + )) + defaults.removeObject(forKey: ScenicQueueSettingsStore.scopedKey( + base: ScenicQueueLocalSettings.selectedSpotNameKey, + userId: userId, + scenicId: scenicId + )) + } +} + +/// 排队运行时测试替身,避免触发真实网络请求。 +@MainActor +private final class AppFeatureScenicQueueMock: ScenicQueueServing { + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData { + ScenicQueueStatsData() + } + + func scenicQueueHome( + scenicId: Int, + scenicSpotId: Int, + type: Int, + page: Int, + pageSize: Int + ) async throws -> ScenicQueueHomeData { + ScenicQueueHomeData() + } + + func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") } + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { ScenicQueueCallData(id: id) } + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { ScenicQueuePassData(id: id) } + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { ScenicQueueFinishData(id: id) } + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {} + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {} + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { ScenicQueueSettingData() } + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {} + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData { + ScenicQueueShootQueueQRCodeData(qrcodeUrl: "") + } + func scenicQueueSettingChangeLog( + scenicId: Int, + scenicSpotId: Int?, + page: Int, + pageSize: Int + ) async throws -> ScenicQueueSettingChangeLogData { + ScenicQueueSettingChangeLogData() + } +} + +/// 不发起真实网络请求的 URLSession 测试替身。 +private final class AppFeatureRecordingURLSession: URLSessionProtocol { + /// 返回空响应数据。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (Data(), response) + } +} diff --git a/suixinkan_iosTests/AppServicesTests.swift b/suixinkan_iosTests/AppServicesTests.swift index 4f61d4f..ae2213d 100644 --- a/suixinkan_iosTests/AppServicesTests.swift +++ b/suixinkan_iosTests/AppServicesTests.swift @@ -28,6 +28,7 @@ final class AppServicesTests: XCTestCase { XCTAssertNotNil(services.context.accountContext) XCTAssertNotNil(services.ui.toastCenter) XCTAssertNotNil(services.runtime.scenicQueueRuntime) + XCTAssertIdentical(services.network.apiClient as AnyObject, services.apiClient as AnyObject) } /// 扁平属性应转发到对应 bundle,保持现有调用方兼容。 @@ -35,7 +36,10 @@ final class AppServicesTests: XCTestCase { 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.authAPI as AnyObject, services.network.account.authAPI as AnyObject) + XCTAssertIdentical(services.ordersAPI as AnyObject, services.network.commerce.ordersAPI as AnyObject) + XCTAssertIdentical(services.scenicQueueAPI as AnyObject, services.network.operation.scenicQueueAPI as AnyObject) + XCTAssertIdentical(services.assetsAPI as AnyObject, services.network.content.assetsAPI as AnyObject) XCTAssertIdentical(services.toastCenter as AnyObject, services.ui.toastCenter as AnyObject) XCTAssertIdentical(services.scenicQueueRuntime as AnyObject, services.runtime.scenicQueueRuntime as AnyObject) }