Refactor AppServices into assembly, feature facades, and lifecycle coordinator.

Extract dependency wiring and session lifecycle from the container and RootViewController, split NetworkBundle by domain, and add module-level feature services while keeping flat accessors for compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 09:32:44 +08:00
parent 1a6d4a1791
commit 0d8f97417b
11 changed files with 686 additions and 197 deletions

View File

@ -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` 并行请求用户资料和角色权限,再补全景区与门店。

View File

@ -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) }
}

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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 ToastLoading
struct UIFeedbackBundle {

View File

@ -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()
}
}

View File

@ -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

View File

@ -18,6 +18,7 @@ enum AppScenePhase {
@MainActor
///
final class ScenicQueueRuntime {
static let shared = ScenicQueueRuntime()
var onChange: (() -> Void)?
private(set) var isMonitoring = false { didSet { onChange?() } }

View File

@ -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)
}
}

View File

@ -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)
}