Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,93 @@
# Core 模块业务逻辑
## 模块职责
Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存储和通用设计常量。业务页面不应直接重复实现这些能力。
主要子模块:
- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。
- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。
- `Design`:统一颜色、字号、间距、控件尺寸和圆角。
- `Upload`:统一阿里云 OSS 上传、图片压缩、上传策略和 Kingfisher 网络图片展示。
## Networking
`APIClient` 是统一网络客户端,负责:
- 根据 `APIRequest` 生成 `URLRequest`
- 注入公共 Header`Content-Type``Accept`、App 版本和系统类型。
- 通过 token provider 或 `tokenOverride` 注入登录 token。
- 发送请求并处理 URLSession 错误。
- 校验 HTTP 状态码。
- 解码后端统一 `APIEnvelope`
- 将 HTTP 错误、业务错误、解析错误转成 `APIError`
业务模块只应该封装自己的 API 类,例如 `AuthAPI``ProfileAPI`,然后调用 `APIClient.send`。页面和 ViewModel 不应直接拼接 URL 或处理原始响应体。
### 响应约定
后端响应通过 `APIEnvelope<Response>` 解包:
- `code` 表示业务状态。
- `msg` 表示业务提示。
- `data` 是真正业务数据。
`APIEnvelope.isSuccess` 为 false 时,`APIClient` 抛出 `APIError.serverCode`
### token 失效判断
`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态:
- HTTP 401 / 403 视为登录失效。
- 业务码 `200001` 视为登录失效。
- 错误文案包含 token、过期、登录失效、重新登录、unauthorized、验证失败等关键词时视为登录失效。
## Storage
本地缓存按安全级别拆分:
- `SessionTokenStore`:使用 Keychain 保存正式 token。
- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。
- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。
缓存边界:
- 正式 token 只放 Keychain。
- 临时 token 只放内存。
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
- 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 Data。
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
- `AccountProfile`
- 账号类型和业务账号 ID
- 当前角色 ID
- 景区作用域和门店作用域
- 当前景区 ID 和当前门店 ID
## Design
`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。
新增页面时优先使用 `AppMetrics``AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
### 全局 Loading
`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show``hide``updateMessage``withLoading``withOptionalLoading`,不要在 `body` 中读取 `isVisible``message` 等展示状态。
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示。
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
### 全局 Toast
`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。
Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。
## Upload
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
上传模块只保存内存状态:
- STS token 不写入 Keychain 或 UserDefaults。
- 用户选择的本地图片 Data 不落盘。
- 上传进度只用于当前页面展示。
网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。

View File

@ -0,0 +1,111 @@
//
// AppContentUnavailableView.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// iOS 16
final class AppContentUnavailableView: UIView {
private let contentStack = UIStackView()
private let labelStack = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let actionsContainer = UIStackView()
/// 使
init(
title: String,
systemImage: String,
description: String? = nil,
actions: [UIView] = []
) {
super.init(frame: .zero)
configureViews(title: title, systemImage: systemImage, description: description, actions: actions)
}
required init?(coder: NSCoder) {
nil
}
///
func update(title: String, systemImage: String, description: String? = nil) {
titleLabel.text = title
iconView.image = UIImage(systemName: systemImage)
descriptionLabel.text = description
descriptionLabel.isHidden = description?.isEmpty != false
}
///
func setActions(_ actions: [UIView]) {
actionsContainer.arrangedSubviews.forEach { view in
actionsContainer.removeArrangedSubview(view)
view.removeFromSuperview()
}
actions.forEach { actionsContainer.addArrangedSubview($0) }
actionsContainer.isHidden = actions.isEmpty
}
private func configureViews(
title: String,
systemImage: String,
description: String?,
actions: [UIView]
) {
contentStack.axis = .vertical
contentStack.alignment = .center
contentStack.spacing = AppMetrics.Spacing.small
labelStack.axis = .vertical
labelStack.alignment = .center
labelStack.spacing = AppMetrics.Spacing.xSmall
iconView.image = UIImage(systemName: systemImage)
iconView.tintColor = AppDesign.textSecondary
iconView.contentMode = .scaleAspectFit
iconView.setContentHuggingPriority(.required, for: .vertical)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
descriptionLabel.textColor = AppDesign.textSecondary
descriptionLabel.textAlignment = .center
descriptionLabel.numberOfLines = 0
descriptionLabel.isHidden = description?.isEmpty != false
actionsContainer.axis = .vertical
actionsContainer.alignment = .center
actionsContainer.spacing = AppMetrics.Spacing.xSmall
actionsContainer.isHidden = actions.isEmpty
actions.forEach { actionsContainer.addArrangedSubview($0) }
labelStack.addArrangedSubview(iconView)
labelStack.addArrangedSubview(titleLabel)
if description?.isEmpty == false {
labelStack.addArrangedSubview(descriptionLabel)
}
contentStack.addArrangedSubview(labelStack)
if !actions.isEmpty {
contentStack.addArrangedSubview(actionsContainer)
}
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.large)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,21 @@
//
// AppDesign.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
///
enum AppDesign {
static let primary = UIColor(hex: 0x0073FF)
static let primarySoft = UIColor(hex: 0xEFF6FF)
static let textPrimary = UIColor(hex: 0x1F2937)
static let textSecondary = UIColor(hex: 0x6B7280)
static let placeholder = UIColor(hex: 0xA8B2C1)
static let success = UIColor(hex: 0x14964A)
static let warning = UIColor(hex: 0xFF7B00)
static let inputBackground = UIColor(hex: 0xF8FAFC)
static let inputBorder = UIColor(hex: 0xE2E8F0)
}

View File

@ -0,0 +1,68 @@
//
// AppMetrics.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import CoreGraphics
/// App
final class AppMetrics {
private init() {}
/// App
enum FontSize {
static let caption: CGFloat = 12
static let footnote: CGFloat = 13
static let subheadline: CGFloat = 14
static let body: CGFloat = 16
static let callout: CGFloat = 17
static let title3: CGFloat = 18
static let title2: CGFloat = 20
static let title: CGFloat = 24
static let largeTitle: CGFloat = 30
}
///
enum Spacing {
static let xxxSmall: CGFloat = 2
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let substantial: CGFloat = 14
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 18
static let large: CGFloat = 20
static let sheet: CGFloat = 22
static let xLarge: CGFloat = 24
static let xxLarge: CGFloat = 30
static let pageHorizontal: CGFloat = 16
static let pageVertical: CGFloat = 24
}
///
enum ControlSize {
static let smallIcon: CGFloat = 16
static let checkboxIcon: CGFloat = 20
static let passwordIcon: CGFloat = 22
static let progressWidth: CGFloat = 22
static let checkboxTapArea: CGFloat = 28
static let iconTapArea: CGFloat = 32
static let sheetButtonHeight: CGFloat = 48
static let primaryButtonHeight: CGFloat = 50
static let inputHeight: CGFloat = 52
}
///
enum LineSpacing {
static let title: CGFloat = 3
}
///
enum CornerRadius {
static let input: CGFloat = 12
static let button: CGFloat = 12
static let card: CGFloat = 16
}
}

View File

@ -0,0 +1,266 @@
//
// GlobalLoadingCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
import UIKit
#if canImport(Lottie)
import Lottie
#endif
@MainActor
/// Loading
final class GlobalLoadingCenter {
fileprivate let state = GlobalLoadingState()
private weak var overlayView: GlobalLoadingOverlayView?
/// Loading
func show(message: String = "") {
if !message.isEmpty {
state.message = message
}
state.activeCount += 1
state.isVisible = true
}
/// Loading
func hide() {
state.activeCount = max(0, state.activeCount - 1)
guard state.activeCount == 0 else { return }
state.isVisible = false
state.message = ""
}
/// Loading Loading
func updateMessage(_ message: String) {
guard state.isVisible, !message.isEmpty else { return }
state.message = message
}
/// Loading
func withLoading<T>(
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
show(message: message)
defer { hide() }
return try await operation()
}
/// Loading loading
func withOptionalLoading<T>(
_ enabled: Bool,
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
if enabled {
return try await withLoading(message: message, operation: operation)
}
return try await operation()
}
/// Loading
func attachToWindow(_ window: UIWindow) {
if let overlayView, overlayView.superview === window {
return
}
let overlay = GlobalLoadingOverlayView(state: state)
overlay.frame = window.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(overlay)
overlayView = overlay
}
#if DEBUG
/// Loading
var snapshotForTests: GlobalLoadingSnapshot {
GlobalLoadingSnapshot(
isVisible: state.isVisible,
message: state.message,
activeCount: state.activeCount
)
}
#endif
}
@MainActor
/// Loading Overlay
final class GlobalLoadingState {
var onChange: (() -> Void)?
fileprivate var isVisible = false { didSet { onChange?() } }
fileprivate var message = "" { didSet { onChange?() } }
fileprivate var activeCount = 0 { didSet { onChange?() } }
}
/// Loading Loading
fileprivate final class GlobalLoadingOverlayView: UIView {
private let state: GlobalLoadingState
private let dimmingView = UIView()
private let cardView = UIView()
private let animationContainer = UIView()
private var animationView: UIView?
/// 使 Loading
init(state: GlobalLoadingState) {
self.state = state
super.init(frame: .zero)
configureViews()
state.onChange = { [weak self] in
self?.refreshPresentation(animated: true)
}
refreshPresentation(animated: false)
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
isUserInteractionEnabled = true
accessibilityLabel = "加载中"
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.28)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 18
cardView.layer.cornerCurve = .continuous
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 24
cardView.layer.shadowOffset = CGSize(width: 0, height: 10)
animationView = Self.makeAnimationView()
if let animationView {
animationContainer.addSubview(animationView)
animationView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: animationContainer.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: animationContainer.trailingAnchor),
animationView.topAnchor.constraint(equalTo: animationContainer.topAnchor),
animationView.bottomAnchor.constraint(equalTo: animationContainer.bottomAnchor)
])
}
addSubview(dimmingView)
addSubview(cardView)
cardView.addSubview(animationContainer)
dimmingView.translatesAutoresizingMaskIntoConstraints = false
cardView.translatesAutoresizingMaskIntoConstraints = false
animationContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor),
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
animationContainer.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 10),
animationContainer.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -10),
animationContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
animationContainer.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
animationContainer.widthAnchor.constraint(equalToConstant: 132),
animationContainer.heightAnchor.constraint(equalToConstant: 132)
])
}
private func refreshPresentation(animated: Bool) {
let shouldShow = state.isVisible
let updates = {
self.isHidden = !shouldShow
self.alpha = shouldShow ? 1 : 0
}
guard animated else {
updates()
return
}
if shouldShow {
isHidden = false
alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
self.alpha = 1
}
} else {
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: {
self.alpha = 0
}, completion: { _ in
self.isHidden = true
})
}
}
/// Loading
private static var hasAnimationResource: Bool {
loadAnimation() != nil
}
/// Lottie
private static func makeAnimationView() -> UIView? {
#if canImport(Lottie)
if hasAnimationResource {
let container = UIView()
container.backgroundColor = .clear
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.backgroundBehavior = .pauseAndRestore
animationView.animation = loadAnimation()
animationView.play()
container.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
animationView.topAnchor.constraint(equalTo: container.topAnchor),
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
return container
}
#endif
let indicator = UIActivityIndicatorView(style: .large)
indicator.color = AppDesign.primary
indicator.startAnimating()
return indicator
}
/// loading Resources
#if canImport(Lottie)
private static func loadAnimation() -> LottieAnimation? {
if let animation = LottieAnimation.named("loading") {
return animation
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
return LottieAnimation.filepath(url.path)
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
return LottieAnimation.filepath(url.path)
}
return nil
}
#else
private static func loadAnimation() -> Any? {
nil
}
#endif
}
#if DEBUG
/// Loading
struct GlobalLoadingSnapshot: Equatable {
let isVisible: Bool
let message: String
let activeCount: Int
}
#endif

View File

@ -0,0 +1,105 @@
//
// ForegroundLocationProvider.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import CoreLocation
import Foundation
///
struct ForegroundLocationResult: Equatable {
let latitude: Double
let longitude: Double
let address: String
}
/// Provider
@MainActor
final class ForegroundLocationProvider: NSObject {
private let manager = CLLocationManager()
private let geocoder = CLGeocoder()
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
///
func requestCurrentLocation() async throws -> ForegroundLocationResult {
if let continuation {
continuation.resume(throwing: LocationProviderError.duplicatedRequest)
self.continuation = nil
}
let status = manager.authorizationStatus
if status == .notDetermined {
manager.requestWhenInUseAuthorization()
}
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.requestLocation()
}
}
/// CLLocation
private func makeResult(from location: CLLocation) async -> ForegroundLocationResult {
let address: String
if let placemark = try? await geocoder.reverseGeocodeLocation(location).first {
address = [
placemark.administrativeArea,
placemark.locality,
placemark.subLocality,
placemark.thoroughfare,
placemark.name
]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "")
} else {
address = "当前位置"
}
return ForegroundLocationResult(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
address: address.isEmpty ? "当前位置" : address
)
}
}
extension ForegroundLocationProvider: CLLocationManagerDelegate {
///
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { @MainActor in
guard let continuation = self.continuation else { return }
self.continuation = nil
let result = await self.makeResult(from: location)
continuation.resume(returning: result)
}
}
///
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
Task { @MainActor in
guard let continuation = self.continuation else { return }
self.continuation = nil
continuation.resume(throwing: error)
}
}
}
/// Provider
enum LocationProviderError: LocalizedError {
case duplicatedRequest
var errorDescription: String? {
switch self {
case .duplicatedRequest:
"已有定位请求正在执行"
}
}
}

View File

@ -0,0 +1,166 @@
//
// SharedBusinessModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct AvailableOrderResponse: Decodable, Identifiable, Hashable {
var id: String { orderNumber }
let projectName: String
let orderNumber: String
let orderStatus: Int
let orderStatusLabel: String
let payTime: String
let userPhone: String
/// 线
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case orderNumber = "order_number"
case orderStatus = "order_status"
case orderStatusLabel = "order_status_label"
case payTime = "pay_time"
case userPhone = "user_phone"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
projectName = try container.decodeSharedLossyString(forKey: .projectName)
orderNumber = try container.decodeSharedLossyString(forKey: .orderNumber)
orderStatus = try container.decodeSharedLossyInt(forKey: .orderStatus) ?? 0
orderStatusLabel = try container.decodeSharedLossyString(forKey: .orderStatusLabel)
payTime = try container.decodeSharedLossyString(forKey: .payTime)
userPhone = try container.decodeSharedLossyString(forKey: .userPhone)
}
}
///
struct PhotographerProjectItem: Decodable, Identifiable, Hashable {
let id: Int
let type: Int
let typeName: String
let status: Int
let statusName: String
let name: String
let coverProject: String
let coverVideo: String
let price: String
let otPrice: String
let priceDeposit: String
let attrLabel: [String]
let label: String
///
init(
id: Int,
type: Int = 0,
typeName: String = "",
status: Int = 0,
statusName: String = "",
name: String,
coverProject: String = "",
coverVideo: String = "",
price: String = "",
otPrice: String = "",
priceDeposit: String = "",
attrLabel: [String] = [],
label: String = ""
) {
self.id = id
self.type = type
self.typeName = typeName
self.status = status
self.statusName = statusName
self.name = name
self.coverProject = coverProject
self.coverVideo = coverVideo
self.price = price
self.otPrice = otPrice
self.priceDeposit = priceDeposit
self.attrLabel = attrLabel
self.label = label
}
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case name
case coverProject = "cover_project"
case coverVideo = "cover_video"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
case attrLabel = "attr_label"
case label
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeSharedLossyInt(forKey: .id) ?? 0
type = try container.decodeSharedLossyInt(forKey: .type) ?? 0
typeName = try container.decodeSharedLossyString(forKey: .typeName)
status = try container.decodeSharedLossyInt(forKey: .status) ?? 0
statusName = try container.decodeSharedLossyString(forKey: .statusName)
name = try container.decodeSharedLossyString(forKey: .name)
coverProject = try container.decodeSharedLossyString(forKey: .coverProject)
coverVideo = try container.decodeSharedLossyString(forKey: .coverVideo)
price = try container.decodeSharedLossyString(forKey: .price)
otPrice = try container.decodeSharedLossyString(forKey: .otPrice)
priceDeposit = try container.decodeSharedLossyString(forKey: .priceDeposit)
label = try container.decodeSharedLossyString(forKey: .label)
if let labels = try? container.decodeIfPresent([String].self, forKey: .attrLabel) {
attrLabel = labels
} else if let text = try? container.decodeIfPresent(String.self, forKey: .attrLabel) {
attrLabel = text
.components(separatedBy: CharacterSet(charactersIn: ","))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
} else {
attrLabel = []
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeSharedLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// StringDouble Int Int
func decodeSharedLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
}

View File

@ -0,0 +1,255 @@
//
// APIClient.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// URLSession
protocol URLSessionProtocol {
/// URLRequest
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
@MainActor
/// token Envelope
final class APIClient {
private let session: URLSessionProtocol
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment
private let appVersion: String
private let osType: String
///
init(
environment: APIEnvironment = .current,
session: URLSessionProtocol = APIClient.defaultSession,
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder(),
appVersion: String = AppClientInfo.appVersion(),
osType: String = AppClientInfo.osType
) {
self.environment = environment
self.session = session
self.encoder = encoder
self.decoder = decoder
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
}
nonisolated private static let defaultSession: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 12
configuration.timeoutIntervalForResource = 20
configuration.waitsForConnectivity = false
return URLSession(configuration: configuration)
}()
/// token API
func bindAuthTokenProvider(_ provider: @escaping () -> String?) {
authTokenProvider = provider
}
/// APIRequest
func send<Response: Decodable>(
_ apiRequest: APIRequest<Response>,
tokenOverride: String? = nil
) async throws -> Response {
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
logRequest(request)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch is CancellationError {
logCancelled(for: request, reason: "CancellationError")
throw CancellationError()
} catch let error as URLError {
if error.code == .cancelled {
logCancelled(for: request, reason: "URLError.cancelled")
throw CancellationError()
}
throw APIError.networkFailed(networkErrorMessage(for: error))
} catch {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: data)
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
}
/// URLRequest Headertoken
private func makeURLRequest<Response: Decodable>(
_ apiRequest: APIRequest<Response>,
tokenOverride: String?
) throws -> URLRequest {
let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path
guard var components = URLComponents(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path
) else {
throw APIError.invalidURL
}
if !apiRequest.queryItems.isEmpty {
components.queryItems = apiRequest.queryItems
}
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = apiRequest.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
apiRequest.headers.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
if let body = apiRequest.body {
request.httpBody = try encoder.encode(body)
}
return request
}
/// HTTP 2xx
private func validateHTTPResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard 200 ..< 300 ~= httpResponse.statusCode else {
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
}
}
/// Envelope data
private func decodeEnvelope<Response: Decodable>(_ responseType: Response.Type, from data: Data) throws -> Response {
let envelope: APIEnvelope<Response>
do {
envelope = try decoder.decode(APIEnvelope<Response>.self, from: data)
} catch {
throw APIError.decodeFailed(error.localizedDescription)
}
guard envelope.isSuccess else {
throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败")
}
if responseType == EmptyPayload.self {
return EmptyPayload() as! Response
}
guard let payload = envelope.data else {
throw APIError.emptyData
}
return payload
}
/// HTTP
private func parseHTTPErrorMessage(data: Data) -> String {
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) {
if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty {
return msg
}
if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
return message
}
if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty {
return error
}
}
if let plainText = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!plainText.isEmpty {
return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText
}
return "服务端返回错误"
}
/// URLError
private func networkErrorMessage(for error: URLError) -> String {
switch error.code {
case .timedOut:
"请求超时,请稍后重试"
case .notConnectedToInternet:
"网络不可用,请检查网络连接"
case .networkConnectionLost:
"网络连接中断,请重试"
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
"无法连接服务器,请稍后重试"
default:
error.localizedDescription
}
}
/// Debug
private func logRequest(_ request: URLRequest) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
print("[API][Request] \(method) \(url)")
#endif
}
/// Debug
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
let body = Self.debugResponseBody(from: data)
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
#endif
}
/// Debug
private func logCancelled(for request: URLRequest, reason: String) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
print("[API][Cancelled] \(method) \(url) reason=\(reason)")
#endif
}
#if DEBUG
/// 便
private static func debugResponseBody(from data: Data) -> String {
guard !data.isEmpty else { return "<empty response>" }
if
let object = try? JSONSerialization.jsonObject(with: data),
let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]),
let prettyJSON = String(data: prettyData, encoding: .utf8) {
return prettyJSON
}
return String(data: data, encoding: .utf8) ?? "<non-utf8 response: \(data.count) bytes>"
}
#endif
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,62 @@
//
// APIEnvelope.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// codemsg data
struct APIEnvelope<T: Decodable>: Decodable {
let data: T?
let code: Int
let msg: String?
/// code
var isSuccess: Bool {
code == 100000
}
/// Envelope JSON
enum CodingKeys: String, CodingKey {
case data
case code
case msg
}
/// data EmptyPayload
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
msg = try container.decodeIfPresent(String.self, forKey: .msg)
guard code == 100000 else {
data = nil
return
}
guard container.contains(.data), try !container.decodeNil(forKey: .data) else {
data = nil
return
}
if T.self == EmptyPayload.self {
data = EmptyPayload() as? T
return
}
data = try container.decode(T.self, forKey: .data)
}
}
/// data
struct EmptyPayload: Codable {}
/// HTTP
struct ErrorEnvelope: Decodable {
let code: Int?
let msg: String?
let message: String?
let error: String?
}

View File

@ -0,0 +1,62 @@
//
// APIEnvironment.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// HTTP WebSocket
struct APIEnvironment: Equatable {
let baseURL: URL
let webSocketURL: URL
nonisolated static let production = APIEnvironment(
baseURL: URL(string: "https://api.zhifly.cn")!,
webSocketURL: URL(string: "wss://api.zhifly.cn/wss")!
)
nonisolated static let testing = APIEnvironment(
baseURL: URL(string: "https://api-test.zhifly.cn")!,
webSocketURL: URL(string: "wss://api-test.zhifly.cn/wss")!
)
nonisolated static var current: APIEnvironment {
#if DEBUG
.testing
#else
.production
#endif
}
}
/// App
enum AppClientInfo {
nonisolated static let osType = "iOS"
/// App build
nonisolated static func appVersion(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String {
let version = (infoDictionary?["CFBundleShortVersionString"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "1.0.0"
let build = (infoDictionary?["CFBundleVersion"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let versionParts = version.split(separator: ".", omittingEmptySubsequences: false)
if versionParts.count >= 3 {
return version
}
if versionParts.count == 2, let build {
return "\(version).\(build)"
}
return version
}
}
private extension String {
nonisolated var nonEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,61 @@
//
// APIError.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// URLHTTP
enum APIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpStatus(Int, String)
case serverCode(Int, String)
case emptyData
case decodeFailed(String)
case networkFailed(String)
var errorDescription: String? {
switch self {
case .invalidURL:
"请求地址无效"
case .invalidResponse:
"服务响应异常"
case let .httpStatus(statusCode, message):
"请求失败HTTP \(statusCode)\(message)"
case .serverCode(_, let message):
message
case .emptyData:
"接口返回数据为空"
case .decodeFailed(let message):
"数据解析失败:\(message)"
case .networkFailed(let message):
"网络请求失败:\(message)"
}
}
}
extension APIError {
///
static func isAuthenticationExpired(_ error: Error) -> Bool {
guard let apiError = error as? APIError else { return false }
switch apiError {
case let .httpStatus(statusCode, _):
return statusCode == 401 || statusCode == 403
case let .serverCode(code, message):
let text = message.lowercased()
return code == 200001
|| text.contains("token")
|| text.contains("过期")
|| text.contains("登录失效")
|| text.contains("重新登录")
|| text.contains("unauthorized")
|| text.contains("验证失败")
|| text.contains("驗證失敗")
default:
return false
}
}
}

View File

@ -0,0 +1,55 @@
//
// APIRequest.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// HTTP
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
/// API Header
struct APIRequest<Response: Decodable> {
var method: HTTPMethod
var path: String
var queryItems: [URLQueryItem]
var headers: [String: String]
var body: AnyEncodable?
/// API AnyEncodable
init<Body: Encodable>(
method: HTTPMethod,
path: String,
queryItems: [URLQueryItem] = [],
headers: [String: String] = [:],
body: Body? = Optional<EmptyPayload>.none
) {
self.method = method
self.path = path
self.queryItems = queryItems
self.headers = headers
self.body = body.map(AnyEncodable.init)
}
}
/// Encodable APIRequest
struct AnyEncodable: Encodable {
private let encodeValue: (Encoder) throws -> Void
/// Encodable
nonisolated init<Value: Encodable>(_ value: Value) {
encodeValue = value.encode(to:)
}
/// Encoder
nonisolated func encode(to encoder: Encoder) throws {
try encodeValue(encoder)
}
}

View File

@ -0,0 +1,83 @@
//
// ListPayload.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// total + list
struct ListPayload<T: Decodable>: Decodable {
let total: Int
let list: [T]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int, list: [T]) {
self.total = total
self.list = list
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([T].self, forKey: .list) ?? []
}
}
/// total + data list
struct DataListPayload<T: Decodable>: Decodable {
let total: Int
let data: [T]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case data
case list
}
///
init(total: Int, data: [T]) {
self.total = total
self.data = data
}
/// data/list total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
data = try container.decodeIfPresent([T].self, forKey: .data)
?? container.decodeIfPresent([T].self, forKey: .list)
?? []
}
}
private extension KeyedDecodingContainer {
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,305 @@
# Networking 模块业务逻辑
## 模块职责
Networking 模块是 App 的统一网络请求入口。业务模块不直接使用 `URLSession`,而是通过自己的 API 类创建 `APIRequest`,再交给 `APIClient.send` 发送。
这个模块负责:
- 维护不同环境的服务器地址。
- 描述一次接口请求的方法、路径、query、header、body 和响应类型。
- 把业务请求转换成 `URLRequest`
- 自动注入公共 Header 和登录 token。
- 发送请求并处理网络错误。
- 校验 HTTP 状态码。
- 解包后端统一 Envelope。
- 把错误转换成统一的 `APIError`
## 文件职责
- `APIEnvironment.swift`定义生产环境、测试环境、HTTP baseURL 和 WebSocket 地址。
- `APIRequest.swift`:定义强类型请求模型,业务 API 通过它声明接口。
- `APIClient.swift`:统一网络客户端,负责真正构造、发送、解析请求。
- `APIEnvelope.swift`:定义后端统一响应结构。
- `APIError.swift`:定义网络层错误和登录失效判断。
## 请求调用链路
一次业务请求的大致流程是:
1. 业务 API 创建 `APIRequest<Response>`
2. `APIClient.send` 接收这个请求。
3. `APIClient.makeURLRequest``APIRequest` 转成 `URLRequest`
4. `APIClient` 注入公共 Header、业务 Header、token 和请求体。
5. `URLSessionProtocol.data(for:)` 发送请求。
6. `APIClient.validateHTTPResponse` 校验 HTTP 状态码。
7. `APIClient.decodeEnvelope` 解包后端 Envelope。
8. 成功时返回 `Response` 类型的业务数据。
9. 失败时抛出 `APIError`
## APIRequest 的作用
`APIRequest<Response>` 是业务接口和网络底层之间的桥梁。
它包含:
- `method`HTTP 方法,目前支持 GET、POST、PUT、DELETE。
- `path`:接口路径,例如 `/api/app/v9/login`
- `queryItems`URL query 参数。
- `headers`:接口额外 Header。
- `body`:请求体,内部通过 `AnyEncodable` 做类型擦除。
- `Response`:接口成功后期望返回的数据类型。
示例:
```swift
try await client.send(
APIRequest(
method: .post,
path: "/api/app/v9/login",
body: LoginRequest(username: username, password: password)
)
)
```
业务 API 类应该负责创建 `APIRequest`View 和 ViewModel 不应该直接拼 URL。
## URLRequest 构造规则
`APIClient.makeURLRequest` 会做这些事情:
1. 如果 `path` 没有 `/` 前缀,会自动补上。
2. 使用 `APIEnvironment.baseURL + path` 生成完整 URL。
3. 如果 `queryItems` 非空,则写入 URL query。
4. 设置 HTTP method。
5. 设置公共 Header
- `Content-Type: application/json`
- `Accept: application/json`
- `X-APP-VERSION`
- `X-OS-TYPE`
6. 合并业务 API 传入的额外 Header。
7. 选择并注入 token。
8. 如果有 body则使用 `JSONEncoder` 编码为 JSON。
## token 注入规则
token 有两个来源:
1. `tokenOverride`
2. `authTokenProvider`
优先级是:
```text
tokenOverride > authTokenProvider()
```
正常登录后的接口走 `authTokenProvider``RootView` 启动时会绑定:
```swift
apiClient.bindAuthTokenProvider { appSession.token }
```
登录流程里的 `set-user` 比较特殊,它需要使用登录接口返回的临时 token所以会传入 `tokenOverride`
token 最终会写入请求 Header
```text
token: <token>
```
空 token 不会写入 Header。
## 环境选择
`APIEnvironment.current` 根据编译环境选择接口地址:
- Debug`https://api-test.zhifly.cn`
- Release`https://api.zhifly.cn`
WebSocket 地址也在 `APIEnvironment` 中定义,当前网络客户端主要使用 HTTP baseURL。
## App 版本 Header
`AppClientInfo.appVersion` 会从 `Info.plist` 读取:
- `CFBundleShortVersionString`
- `CFBundleVersion`
如果版本号已经有三段,则直接使用版本号。
如果版本号只有两段,并且有 build 号,则拼成 `版本号.build`
如果读取失败,则兜底为 `1.0.0`
这个值会作为 `X-APP-VERSION` Header 发送给后端。
系统类型固定为:
```text
X-OS-TYPE: iOS
```
## 后端 Envelope 约定
后端统一响应结构由 `APIEnvelope<T>` 表示:
```swift
struct APIEnvelope<T: Decodable>: Decodable {
let data: T?
let code: Int
let msg: String?
}
```
当前成功业务码是:
```text
100000
```
只有 `code == 100000` 时才会继续解析 `data`
如果接口成功但没有业务数据,使用 `EmptyPayload`
```swift
let _: EmptyPayload = try await client.send(...)
```
## 错误处理规则
网络错误分为几层:
### 1. URL 构造错误
URL 拼接失败时抛出:
```swift
APIError.invalidURL
```
### 2. URLSession 错误
`URLError` 会被转换成中文提示:
- 超时:请求超时,请稍后重试
- 无网络:网络不可用,请检查网络连接
- 连接中断:网络连接中断,请重试
- 无法连接服务器:无法连接服务器,请稍后重试
取消请求会继续抛出 `CancellationError`,不会包装成业务错误。
### 3. HTTP 状态码错误
非 2xx 状态码会抛出:
```swift
APIError.httpStatus(statusCode, message)
```
错误文案优先从响应体里解析:
1. `msg`
2. `message`
3. `error`
4. plain text 响应体
5. 兜底文案“服务端返回错误”
### 4. Envelope 解码错误
响应体无法按 `APIEnvelope<Response>` 解码时抛出:
```swift
APIError.decodeFailed(message)
```
### 5. 后端业务码错误
HTTP 成功但 `code != 100000` 时抛出:
```swift
APIError.serverCode(code, msg)
```
### 6. 空数据错误
接口声明需要返回 `Response`,但 Envelope 里没有 `data` 时抛出:
```swift
APIError.emptyData
```
## 登录失效判断
`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态。
会被视为登录失效的情况:
- HTTP 401
- HTTP 403
- 业务码 `200001`
- 错误文案包含:
- `token`
- `过期`
- `登录失效`
- `重新登录`
- `unauthorized`
- `验证失败`
- `驗證失敗`
`SessionBootstrapper` 会用这个方法判断冷启动校验失败时是否要清空 token 和账号快照。
账号上下文相关接口集中在 `AccountContextAPI`
- `rolePermissions()` 读取角色权限。
- `scenicListAll()` 读取景区列表。
- `storeAll()` 读取门店列表。
- `scenicSpotListAll(scenicId:)` 按景区读取景点/打卡点列表。
这些接口仍然只通过 `APIClient.send` 发起请求token 由 `APIClient` 的 token provider 注入。
## Debug 日志
Debug 环境下,`APIClient` 会打印:
- 请求方法和 URL
- 响应状态码
- 格式化后的响应体
- 被取消的请求信息
Release 环境不会打印这些日志。
## 新增接口的推荐写法
新增业务接口时,优先按这个结构写:
```swift
@MainActor
@Observable
final class SomeFeatureAPI {
@ObservationIgnored private let client: APIClient
init(client: APIClient) {
self.client = client
}
func loadData() async throws -> SomeResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/example/path"
)
)
}
}
```
规则:
- API 类只负责接口封装,不处理 UI 状态。
- ViewModel 调用 API 类,不直接调用 `APIClient`
- 请求体单独定义 `Encodable` Model。
- 响应体单独定义 `Decodable` Model。
- 接口成功无 data 时使用 `EmptyPayload`
- 需要临时 token 的接口使用 `tokenOverride`
## 当前注意点
- `APIClient``@MainActor @Observable`,当前用于方便通过 Environment 注入和共享。网络发送本身是 async不会阻塞主线程等待网络返回。
- `URLSessionProtocol` 用于后续单元测试注入假 session。
- `APIEnvelope.isSuccess` 当前只认 `100000`,如果后端未来新增成功码,需要集中改这里。
- `APIEnvironment.current` 在 Debug 下默认测试环境,真机调试时需要注意接口环境。

View File

@ -0,0 +1,30 @@
//
// PushAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
@MainActor
/// API iOS APNs token
final class PushAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// APNs device token沿 Android/JPush
func registerJPushId(_ registrationId: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/user/register-jpush-id",
queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationId)]
)
)
}
}

View File

@ -0,0 +1,151 @@
//
// PushNotificationManager.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
import UserNotifications
@MainActor
/// APNs token
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationManager()
private var api: PushAPI?
private weak var session: AppSession?
private weak var router: AppRouter?
private var latestDeviceToken: String?
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
private let tokenDefaultsKey = "apns_device_token"
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
override init() {
super.init()
}
///
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
self.api = api
self.session = session
self.router = router
UNUserNotificationCenter.current().delegate = self
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
}
/// APNs token
func requestAuthorizationAndRegister() {
Task { @MainActor in
let settings = await UNUserNotificationCenter.current().notificationSettings()
latestAuthorizationStatus = settings.authorizationStatus
switch settings.authorizationStatus {
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
guard granted else { return }
UIApplication.shared.registerForRemoteNotifications()
case .authorized, .provisional, .ephemeral:
UIApplication.shared.registerForRemoteNotifications()
uploadPendingTokenIfPossible()
case .denied:
break
@unknown default:
break
}
}
}
/// APNs token
func handleDeviceToken(_ deviceToken: Data) {
let token = APNsDeviceToken.hexString(from: deviceToken)
latestDeviceToken = token
UserDefaults.standard.set(token, forKey: tokenDefaultsKey)
uploadPendingTokenIfPossible()
}
/// APNs
func handleRegistrationError(_ error: Error) {
#if DEBUG
print("APNs registration failed: \(error.localizedDescription)")
#endif
}
/// token
func uploadPendingTokenIfPossible() {
guard let api, session?.isLoggedIn == true else { return }
guard let token = latestDeviceToken, !token.isEmpty else { return }
let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey)
guard uploaded != token else { return }
Task {
do {
try await api.registerJPushId(token)
UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey)
} catch {
#if DEBUG
print("Push token upload failed: \(error.localizedDescription)")
#endif
}
}
}
/// userInfo
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
route(from: PushPayload(userInfo: userInfo))
}
/// payload
func handleRemoteNotification(_ payload: PushPayload) {
route(from: payload)
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let payload = PushPayload(userInfo: notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
}
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let payload = PushPayload(userInfo: response.notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
completionHandler()
}
}
private func route(from payload: PushPayload) {
guard let router else { return }
switch payload.route {
case .payment:
navigateHomeRoute(.paymentCollection)
case .order:
router.selectOrders(entry: .storeOrders)
case .verificationOrder:
router.selectOrders(entry: .verificationOrders)
case .task:
navigateHomeRoute(.taskManagement)
case .queue:
navigateHomeRoute(.queueManagement)
case .messageCenter:
navigateHomeRoute(.messageCenter)
}
}
private func navigateHomeRoute(_ route: HomeRoute) {
guard let router else { return }
router.select(.home)
router.router(for: .home).navigate(to: .home(route))
UIKitAppNavigation.pushHomeRoute(route, animated: true)
}
}

View File

@ -0,0 +1,99 @@
//
// PushPayload.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// APNs token Data
enum APNsDeviceToken {
static func hexString(from data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
/// payload extras/data
struct PushPayload: Sendable {
enum Route: Sendable, Equatable {
case payment
case order
case verificationOrder
case task
case queue
case messageCenter
}
private let values: [String: String]
nonisolated init(userInfo: [AnyHashable: Any]) {
var result: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String else { continue }
result[key] = Self.stringValue(value)
if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" {
Self.mergeJSON(value, into: &result)
}
}
values = result
}
nonisolated var route: Route {
let typeText = values["type"] ?? ""
let routeText = values["route"] ?? ""
let uriText = values["uri"] ?? ""
let actionText = values["action"] ?? ""
let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased()
if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") {
return .verificationOrder
}
if merged.contains("order") || merged.contains("订单") {
return .order
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
return .messageCenter
}
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
if let dict = value as? [String: Any] {
for (key, value) in dict {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
return
}
guard let text = value as? String,
let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
for (key, value) in object {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
}
nonisolated private static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number.stringValue
case .some(let value):
return "\(value)"
case nil:
return ""
}
}
}

View File

@ -0,0 +1,73 @@
//
// ScenicQueueAnnouncementState.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicQueueAnnouncement: Equatable {
enum Kind: Equatable {
case newTickets(count: Int)
case calledTicket(id: Int64)
}
let kind: Kind
let text: String
}
///
struct ScenicQueueAnnouncementState {
private var knownTicketIds = Set<Int64>()
private var calledTicketIds = Set<Int64>()
///
mutating func nextAnnouncement(
stats: ScenicQueueStatsData,
tickets: [ScenicQueueTicket],
customCallText: String?
) -> ScenicQueueAnnouncement? {
let currentIds = Set(tickets.map(\.id))
let newTickets = tickets.filter { !knownTicketIds.contains($0.id) }
knownTicketIds = currentIds
if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) {
calledTicketIds.insert(ticket.id)
return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText))
}
guard !newTickets.isEmpty else { return nil }
let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。"
return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text)
}
///
mutating func reset() {
knownTicketIds.removeAll()
calledTicketIds.removeAll()
}
///
static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String {
callText(forQueueCode: ticket.queueCode, customText: customText)
}
///
static func callText(forQueueCode queueCode: String, customText: String?) -> String {
let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !custom.isEmpty, custom != "A0001" {
return custom.replacingOccurrences(of: "{number}", with: queueCode)
}
let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode)
return "\(code)到拍摄点拍摄。"
}
///
static func ttsLabel(for queueCode: String) -> String {
let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "当前游客" }
return trimmed.map(String.init).joined(separator: " ")
}
}

View File

@ -0,0 +1,295 @@
//
// ScenicQueueRuntime.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import UIKit
///
enum AppScenePhase {
case active
case inactive
case background
}
@MainActor
///
final class ScenicQueueRuntime {
var onChange: (() -> Void)?
private(set) var isMonitoring = false { didSet { onChange?() } }
private(set) var lastPollText = "--" { didSet { onChange?() } }
private(set) var lastSpokenText = "" { didSet { onChange?() } }
private(set) var lastQueueCount = 0 { didSet { onChange?() } }
private(set) var lastError: String? { didSet { onChange?() } }
private let speaker = ScenicQueueSpeechService()
private weak var api: (any ScenicQueueServing)?
private var userId: String?
private var scenicId: Int?
private var scenePhase: AppScenePhase = .active
private var pollTask: Task<Void, Never>?
private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
private var announcementState = ScenicQueueAnnouncementState()
private var lastScenicId: Int?
private var lastSpotId: Int?
private var suspendedByQueueScreen = false
///
var voiceEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true }
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey)
}
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) }
}
///
var backgroundPollingEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false }
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey)
}
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) }
}
///
func update(
api: any ScenicQueueServing,
userId: String?,
scenicId: Int?,
scenePhase: AppScenePhase
) {
self.api = api
self.userId = userId
self.scenicId = scenicId
self.scenePhase = scenePhase
guard !suspendedByQueueScreen else {
stop()
return
}
guard let scenicId else {
stop()
resetSnapshot()
return
}
let spotId = selectedSpotId()
guard spotId > 0 else {
stop()
lastError = "排队监听未启动:请先在排队设置里选择打卡点"
return
}
if lastScenicId != scenicId || lastSpotId != spotId {
resetSnapshot()
lastScenicId = scenicId
lastSpotId = spotId
}
if scenePhase == .background, !backgroundPollingEnabled {
stop()
return
}
startIfNeeded()
}
///
func setSuspendedByQueueScreen(_ suspended: Bool) {
guard suspendedByQueueScreen != suspended else { return }
suspendedByQueueScreen = suspended
if suspended {
stop()
} else if let api {
update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase)
}
}
///
func stop() {
pollTask?.cancel()
pollTask = nil
isMonitoring = false
speaker.stop()
endBackgroundTask()
}
///
func pollNow() {
guard pollTask != nil else { return }
Task { await pollOnce() }
}
private func startIfNeeded() {
guard pollTask == nil else { return }
isMonitoring = true
if scenePhase == .background {
beginBackgroundTask()
}
pollTask = Task { [weak self] in
await self?.runLoop()
}
}
private func runLoop() async {
while !Task.isCancelled {
await pollOnce()
let seconds = pollIntervalSeconds()
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
}
}
private func pollOnce() async {
guard let api, let scenicId else { return }
let spotId = selectedSpotId()
guard spotId > 0 else { return }
if scenePhase == .background {
beginBackgroundTask()
}
do {
if lastScenicId != scenicId || lastSpotId != spotId {
resetSnapshot()
lastScenicId = scenicId
lastSpotId = spotId
}
async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId)
async let homeData = api.scenicQueueHome(
scenicId: scenicId,
scenicSpotId: spotId,
type: QueueListType.queueing.rawValue,
page: 1,
pageSize: 20
)
let (stats, home) = try await (statsData, homeData)
handle(stats: stats, tickets: home.list?.list ?? [])
lastError = nil
} catch {
lastError = error.localizedDescription
}
}
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
lastPollText = formatter.string(from: Date())
lastQueueCount = stats.queueCount
guard voiceEnabled else { return }
let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId)
if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) {
lastSpokenText = announcement.text
speaker.speak(announcement.text)
}
}
private func pollIntervalSeconds() -> UInt64 {
switch scenePhase {
case .active:
return 12
case .inactive:
return 20
case .background:
return 30
@unknown default:
return 20
}
}
private func selectedSpotId() -> Int {
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
}
private func resetSnapshot() {
announcementState.reset()
lastQueueCount = 0
lastPollText = "--"
lastSpokenText = ""
}
private func beginBackgroundTask() {
guard backgroundTaskId == .invalid else { return }
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
Task { @MainActor in
self?.endBackgroundTask()
self?.stop()
}
}
}
private func endBackgroundTask() {
guard backgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTaskId)
backgroundTaskId = .invalid
}
}
@MainActor
///
final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer()
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
override init() {
super.init()
synthesizer.delegate = self
}
///
func speak(_ text: String) {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
enqueue(normalized, continuation: nil, replacePending: true)
}
/// 使
func speakAndWait(_ text: String) async {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
await withCheckedContinuation { continuation in
enqueue(normalized, continuation: continuation, replacePending: false)
}
}
///
func stop() {
synthesizer.stopSpeaking(at: .immediate)
resumePending()
}
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
if replacePending {
synthesizer.stopSpeaking(at: .immediate)
resumePending()
}
if let continuation {
pendingContinuations.append(continuation)
}
let utterance = AVSpeechUtterance(string: normalized)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = 0.48
synthesizer.speak(utterance)
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() }
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() }
}
private func resumePending() {
let continuations = pendingContinuations
pendingContinuations.removeAll()
continuations.forEach { $0.resume() }
}
}

View File

@ -0,0 +1,166 @@
//
// ScenicQueueSocketClient.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
/// WebSocket
struct ScenicQueueSocketMessage: Equatable {
static let queueUpdatedAction = "scenic_spot_queue_updated"
static let ticketCalledAction = "scenic_queue_ticket_called"
let code: Int
let data: ScenicQueueSocketData?
var isScenicQueueEvent: Bool {
let action = data?.action ?? ""
return action == Self.queueUpdatedAction || action == Self.ticketCalledAction
}
}
/// WebSocket data
struct ScenicQueueSocketData: Equatable {
let action: String
let params: ScenicQueueSocketParams?
}
/// WebSocket
struct ScenicQueueSocketParams: Equatable {
let scenicSpotId: Int64?
let recordId: Int64?
let operatorUid: Int64?
let eventId: String?
}
@MainActor
/// WebSocket
final class ScenicQueueSocketClient {
private var webSocketTask: URLSessionWebSocketTask?
private var receiveTask: Task<Void, Never>?
/// WebSocket
func connect(
socketToken: String,
scenicSpotId: Int,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) {
let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
disconnect(reason: "Reconnect before scenic queue socket connect")
let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL)
webSocketTask = task
task.resume()
receiveTask = Task { [weak self] in
await self?.receiveLoop(task: task, onMessage: onMessage)
}
Task {
for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) {
try? await task.send(.string(payload))
}
}
}
/// WebSocket
func disconnect(reason: String = "Scenic queue page stopped") {
receiveTask?.cancel()
receiveTask = nil
webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8))
webSocketTask = nil
}
private func receiveLoop(
task: URLSessionWebSocketTask,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) async {
while !Task.isCancelled {
do {
let message = try await task.receive()
let text: String?
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8)
@unknown default:
text = nil
}
guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue }
onMessage(parsed)
} catch {
return
}
}
}
/// payloadtype Android
static func subscriptionPayloads(scenicSpotId: Int) -> [String] {
[
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#
]
}
/// WebSocket
static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let code = intValue(root["code"]) ?? 0
var socketData: ScenicQueueSocketData?
if let dataObject = root["data"] as? [String: Any] {
let action = stringValue(dataObject["action"])
var params: ScenicQueueSocketParams?
if let paramsObject = dataObject["params"] as? [String: Any] {
params = ScenicQueueSocketParams(
scenicSpotId: int64Value(paramsObject["scenic_spot_id"]),
recordId: int64Value(paramsObject["record_id"]),
operatorUid: int64Value(paramsObject["operator_uid"]),
eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
)
}
socketData = ScenicQueueSocketData(action: action, params: params)
}
return ScenicQueueSocketMessage(code: code, data: socketData)
}
private static func stringValue(_ value: Any?) -> String {
switch value {
case let value as String:
return value
case let value as NSNumber:
return value.stringValue
case let value?:
return "\(value)"
case nil:
return ""
}
}
private static func intValue(_ value: Any?) -> Int? {
if let value = value as? Int { return value }
if let value = value as? NSNumber { return value.intValue }
if let value = value as? String { return Int(value) }
return nil
}
private static func int64Value(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? NSNumber { return value.int64Value }
if let value = value as? String { return Int64(value) }
return nil
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,105 @@
//
// AccountSnapshotStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
///
struct AccountSnapshot: Codable, Equatable {
var profile: AccountProfile?
var accountType: String?
var businessUserId: Int?
var currentRoleId: Int?
var scenicScopes: [BusinessScope]
var storeScopes: [BusinessScope]
var currentScenicId: Int?
var currentStoreId: Int?
///
init(
profile: AccountProfile? = nil,
accountType: String? = nil,
businessUserId: Int? = nil,
currentRoleId: Int? = nil,
scenicScopes: [BusinessScope] = [],
storeScopes: [BusinessScope] = [],
currentScenicId: Int? = nil,
currentStoreId: Int? = nil
) {
self.profile = profile
self.accountType = accountType
self.businessUserId = businessUserId
self.currentRoleId = currentRoleId
self.scenicScopes = scenicScopes
self.storeScopes = storeScopes
self.currentScenicId = currentScenicId
self.currentStoreId = currentStoreId
}
}
/// 使 UserDefaults
final class AccountSnapshotStore {
private let defaults: UserDefaults
private let key: String
private let encoder: JSONEncoder
private let decoder: JSONDecoder
/// UserDefaults
init(
defaults: UserDefaults = .standard,
key: String = "suixinkan.account.snapshot.v1",
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()
) {
self.defaults = defaults
self.key = key
self.encoder = encoder
self.decoder = decoder
}
///
func save(_ snapshot: AccountSnapshot) {
guard let data = try? encoder.encode(snapshot) else { return }
defaults.set(data, forKey: key)
}
///
func load() -> AccountSnapshot? {
guard let data = defaults.data(forKey: key) else { return nil }
do {
return try decoder.decode(AccountSnapshot.self, from: data)
} catch {
clear()
return nil
}
}
///
func clear() {
defaults.removeObject(forKey: key)
}
///
@MainActor
func saveCurrentSelection(
accountContext: AccountContext,
currentRoleId: Int?
) {
let existing = load()
save(
AccountSnapshot(
profile: accountContext.profile ?? existing?.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: currentRoleId ?? existing?.currentRoleId,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
}

View File

@ -0,0 +1,50 @@
//
// AppPreferencesStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
/// App
final class AppPreferencesStore {
private let defaults: UserDefaults
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
/// UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
///
func saveLastLoginUsername(_ username: String) {
let value = username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
defaults.set(value, forKey: lastLoginUsernameKey)
}
///
func loadLastLoginUsername() -> String? {
let value = defaults.string(forKey: lastLoginUsernameKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
///
func savePrivacyAgreementAccepted(_ accepted: Bool) {
defaults.set(accepted, forKey: privacyAgreementAcceptedKey)
}
///
func loadPrivacyAgreementAccepted() -> Bool {
defaults.bool(forKey: privacyAgreementAcceptedKey)
}
///
func clear() {
defaults.removeObject(forKey: lastLoginUsernameKey)
defaults.removeObject(forKey: privacyAgreementAcceptedKey)
}
}

View File

@ -0,0 +1,100 @@
//
// SessionTokenStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Security
/// token Keychain
enum SessionTokenStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
"登录凭证存储失败(\(status)"
}
}
}
/// token Keychain API
final class SessionTokenStore {
private let service: String
private let account: String
/// token App Bundle Keychain
init(
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
account: String = "session.token"
) {
self.service = service
self.account = account
}
/// token token
func save(_ token: String) throws {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedToken.isEmpty else {
try clear()
return
}
let data = Data(trimmedToken.utf8)
let query = baseQuery()
let updateAttributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(updateStatus)
}
var addQuery = query
updateAttributes.forEach { addQuery[$0.key] = $0.value }
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SessionTokenStoreError.unexpectedStatus(addStatus)
}
}
/// token nil
func load() -> String? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty else {
return nil
}
return token
}
/// token
func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(status)
}
}
/// App 使 Keychain
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}

View File

@ -0,0 +1,62 @@
//
// FeaturePlaceholderViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// UIKit
final class FeaturePlaceholderViewController: UIViewController {
private let pageTitle: String
private let uri: String
init(title: String, uri: String) {
self.pageTitle = title
self.uri = uri
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)
title = pageTitle
let icon = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
icon.tintColor = AppDesignUIKit.primary
icon.contentMode = .scaleAspectFit
let titleLabel = UILabel()
titleLabel.text = pageTitle
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold)
titleLabel.textColor = AppDesignUIKit.textPrimary
titleLabel.textAlignment = .center
let uriLabel = UILabel()
uriLabel.text = uri
uriLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
uriLabel.textColor = AppDesignUIKit.textSecondary
uriLabel.textAlignment = .center
uriLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [icon, titleLabel, uriLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.medium
stack.alignment = .center
view.addSubview(stack)
icon.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
}
}

View File

@ -0,0 +1,107 @@
//
// RemoteImage.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Kingfisher
import UIKit
extension UIImageView {
/// 使 Kingfisher 退
func loadRemoteImage(
urlString: String?,
contentMode: UIView.ContentMode = .scaleAspectFill,
placeholder: UIImage? = nil,
failureImage: UIImage? = nil
) {
let normalized = normalizedURLString(from: urlString)
self.contentMode = contentMode
guard let normalized, let url = URL(string: normalized) else {
kf.cancelDownloadTask()
image = failureImage ?? placeholder
return
}
kf.setImage(
with: url,
placeholder: placeholder,
options: [
.cacheOriginalImage,
.transition(.fade(0.2))
]
) { [weak self] result in
guard let self else { return }
if case .failure = result {
self.image = failureImage ?? placeholder
}
}
}
///
func loadRemoteAvatar(
urlString: String,
systemImageName: String = "person.fill",
iconSize: CGFloat = 44
) {
clipsToBounds = true
layer.cornerRadius = min(bounds.width, bounds.height) / 2
let placeholder = Self.avatarPlaceholder(
systemImageName: systemImageName,
iconSize: iconSize,
size: bounds.size == .zero ? CGSize(width: iconSize, height: iconSize) : bounds.size
)
loadRemoteImage(
urlString: urlString,
contentMode: .scaleAspectFill,
placeholder: placeholder,
failureImage: placeholder
)
}
///
func cancelRemoteImageLoad() {
kf.cancelDownloadTask()
}
private func normalizedURLString(from urlString: String?) -> String? {
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
private static func avatarPlaceholder(
systemImageName: String,
iconSize: CGFloat,
size: CGSize
) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
AppDesign.primarySoft.setFill()
context.fill(CGRect(origin: .zero, size: size))
let configuration = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .semibold)
guard let symbol = UIImage(systemName: systemImageName, withConfiguration: configuration)?
.withTintColor(AppDesign.primary, renderingMode: .alwaysOriginal) else {
return
}
let origin = CGPoint(
x: (size.width - symbol.size.width) / 2,
y: (size.height - symbol.size.height) / 2
)
symbol.draw(at: origin)
}
}
}
extension UIImageView {
///
func refreshRemoteAvatarCornerRadius() {
guard bounds.width > 0, bounds.height > 0 else { return }
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
}

View File

@ -0,0 +1,31 @@
//
// UIColor+AppDesign.swift
// suixinkan
//
import UIKit
extension UIColor {
/// 0xRRGGBB UIColor
convenience init(hex: UInt, alpha: CGFloat = 1.0) {
self.init(
red: CGFloat((hex >> 16) & 0xff) / 255.0,
green: CGFloat((hex >> 8) & 0xff) / 255.0,
blue: CGFloat(hex & 0xff) / 255.0,
alpha: alpha
)
}
}
/// UIKit SwiftUI `AppDesign`
enum AppDesignUIKit {
static let primary = UIColor(hex: 0x0073FF)
static let primarySoft = UIColor(hex: 0xEFF6FF)
static let textPrimary = UIColor(hex: 0x1F2937)
static let textSecondary = UIColor(hex: 0x6B7280)
static let placeholder = UIColor(hex: 0xA8B2C1)
static let success = UIColor(hex: 0x14964A)
static let warning = UIColor(hex: 0xFF7B00)
static let pageBackground = UIColor(hex: 0xF5F5F5)
static let cardBackground = UIColor.white
}

View File

@ -0,0 +1,111 @@
//
// ViewControllerHelpers.swift
// suixinkan
//
import SnapKit
import UIKit
/// ViewController ToastLoading ViewModel
@MainActor
enum ViewControllerHelpers {
static var services: AppServices { AppServices.shared }
/// ViewModel onChange deinit
static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) {
onChange?()
_ = ViewModelBindingToken(owner: owner, handler: handler)
}
}
/// ViewModel onChange
private final class ViewModelBindingToken {
private weak var owner: AnyObject?
private let handler: () -> Void
init(owner: AnyObject, handler: @escaping () -> Void) {
self.owner = owner
self.handler = handler
}
deinit {
handler()
}
}
extension UIViewController {
var appServices: AppServices { AppServices.shared }
func showToast(_ message: String) {
appServices.toastCenter.show(message)
}
func showGlobalLoading(_ message: String = "") {
appServices.globalLoading.show(message: message)
}
func hideGlobalLoading() {
appServices.globalLoading.hide()
}
///
func makeCardView(cornerRadius: CGFloat = 8) -> UIView {
let view = UIView()
view.backgroundColor = AppDesignUIKit.cardBackground
view.layer.cornerRadius = cornerRadius
view.clipsToBounds = true
return view
}
///
func makeEmptyStateView(title: String, message: String, systemImage: String) -> UIView {
let container = UIView()
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = AppMetrics.Spacing.small
let imageView = UIImageView(image: UIImage(systemName: systemImage))
imageView.tintColor = AppDesignUIKit.textSecondary
imageView.contentMode = .scaleAspectFit
imageView.snp.makeConstraints { make in
make.width.height.equalTo(48)
}
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesignUIKit.textPrimary
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = message
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
messageLabel.textColor = AppDesignUIKit.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
stack.addArrangedSubview(imageView)
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(messageLabel)
container.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
return container
}
///
func makePrimaryButton(title: String) -> UIButton {
var config = UIButton.Configuration.filled()
config.title = title
config.baseBackgroundColor = AppDesignUIKit.primary
config.baseForegroundColor = .white
config.cornerStyle = .medium
config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
let button = UIButton(configuration: config)
return button
}
}

View File

@ -0,0 +1,156 @@
//
// ModuleViewControllerSupport.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// Cell
final class TitleSubtitleTableViewCell: UITableViewCell {
static let reuseIdentifier = "TitleSubtitleTableViewCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
selectionStyle = .default
textLabel?.font = .systemFont(ofSize: 16, weight: .medium)
textLabel?.textColor = AppDesign.textPrimary
textLabel?.numberOfLines = 2
detailTextLabel?.font = .systemFont(ofSize: 13)
detailTextLabel?.textColor = AppDesign.textSecondary
detailTextLabel?.numberOfLines = 3
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
textLabel?.text = title
if let subtitle, !subtitle.isEmpty {
detailTextLabel?.text = subtitle
} else {
detailTextLabel?.text = detail
}
}
}
/// UITableView ViewModel onChange
@MainActor
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
let services = AppServices.shared
let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let refreshControl = UIRefreshControl()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var viewModelReloadHandler: (() -> Void)?
var isLoading = false {
didSet {
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.largeTitleDisplayMode = .never
tableView.dataSource = self
tableView.delegate = self
tableView.backgroundColor = .clear
tableView.register(
TitleSubtitleTableViewCell.self,
forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier
)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
activityIndicator.hidesWhenStopped = true
view.addSubview(tableView)
view.addSubview(activityIndicator)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
Task { await reloadContent() }
}
func bindViewModel(onChange: (() -> Void)?) {
viewModelReloadHandler = onChange
}
func reloadTable() {
tableView.reloadData()
}
func tableRowCount() -> Int { 0 }
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
func didSelectTableRow(at indexPath: IndexPath) {}
func reloadContent() async {}
@objc private func handleRefresh() {
Task {
await reloadContent()
refreshControl.endRefreshing()
}
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableRowCount()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as? TitleSubtitleTableViewCell else {
return UITableViewCell()
}
configureCell(cell, at: indexPath)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
didSelectTableRow(at: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
willDisplayTableRow(at: indexPath)
}
func willDisplayTableRow(at indexPath: IndexPath) {}
}
extension ModuleTableViewController {
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
if let bindable = viewModel as? ViewModelBindable {
bindable.onChange = { [weak self] in
reload()
self?.reloadTable()
}
}
reload()
}
}
/// ViewModel
@MainActor
protocol ViewModelBindable: AnyObject {
var onChange: (() -> Void)? { get set }
}

View File

@ -0,0 +1,283 @@
//
// OSSUploadService.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import UniformTypeIdentifiers
#if canImport(AlibabaCloudOSS)
import AlibabaCloudOSS
#endif
/// OSS
@MainActor
protocol OSSUploadServing {
/// 访 OSS URL
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
}
@MainActor
/// OSS STS SDK URL
final class OSSUploadService {
private let configService: any OSSConfigServing
/// OSS STS
init(configService: any OSSConfigServing) {
self.configService = configService
}
/// 访 OSS URL
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress)
}
/// 访 OSS URL
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress)
}
/// 访 OSS URL
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress)
}
/// 访 OSS URL
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress)
}
/// 访 OSS URL
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "alive_album", onProgress: onProgress)
}
/// 访 OSS URL
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress)
}
/// 访 OSS URL
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "project", onProgress: onProgress)
}
/// 访 OSS URL
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress)
}
/// 访 OSS URL
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress)
}
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
}
/// 访 OSS URL
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "pilot_cert", onProgress: onProgress)
}
/// OSS
private func uploadFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
moduleType: String,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(1)
try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName)
let config = try await configService.aliyunOSSBucket(bucket: "vipsky")
let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType)
onProgress(12)
#if canImport(AlibabaCloudOSS)
let credentialsProvider = StaticCredentialsProvider(
accessKeyId: config.credentials.accessKeyId,
accessKeySecret: config.credentials.accessKeySecret,
securityToken: config.credentials.securityToken
)
let clientConfig = Configuration.default()
.withRegion(config.region)
.withCredentialsProvider(credentialsProvider)
if !config.endpoint.isEmpty {
clientConfig.withEndpoint(config.endpoint)
}
onProgress(25)
let client = Client(clientConfig)
_ = try await client.putObject(
PutObjectRequest(
bucket: config.bucket,
key: objectKey,
contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType),
body: .data(data),
progress: ProgressClosure { _, transferred, expected in
guard expected > 0 else { return }
let uploadProgress = Double(transferred) / Double(expected)
let progress = max(26, min(99, 25 + Int(uploadProgress * 74)))
onProgress(progress)
}
)
)
onProgress(100)
return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey)
#else
throw OSSUploadError.sdkUnavailable
#endif
}
}
extension OSSUploadService: OSSUploadServing {}
/// OSS MIME URL
enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
///
static func validate(dataSize: Int, fileName: String) throws {
guard dataSize > 0 else {
throw OSSUploadError.emptyFile
}
guard dataSize <= maxFileSize else {
throw OSSUploadError.fileTooLarge
}
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
guard allowedExtensions.contains(ext) else {
throw OSSUploadError.unsupportedFileType
}
}
/// OSS objectKey
static func objectKey(
fileName: String,
scenicId: Int,
moduleType: String,
date: Date = Date(),
uuid: UUID = UUID(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = timeZone
formatter.dateFormat = "yyyyMMdd"
let currentDate = formatter.string(from: date)
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "")
let safeName = sanitizedFileName(fileName)
switch moduleType {
case "task_upload":
return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "cloud_driver":
return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "album_upload":
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "alive_album":
return "live_albums/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "project":
return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "punch_point":
return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "user_avatar":
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "pilot_cert":
return "pilot_cert/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
}
}
/// MIME
static func contentType(for fileName: String, fileType: Int) -> String {
let ext = URL(fileURLWithPath: fileName).pathExtension
if let type = UTType(filenameExtension: ext)?.preferredMIMEType {
return type
}
return fileType == 1 ? "video/mp4" : "image/jpeg"
}
/// OSS 访 objectKey
static func joinURL(baseURL: String, objectKey: String) -> String {
if baseURL.hasSuffix("/") {
return baseURL + objectKey
}
return baseURL + "/" + objectKey
}
/// objectKey
private static func sanitizedFileName(_ fileName: String) -> String {
let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
var unsafeCharacters = CharacterSet.controlCharacters
unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\"))
let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in
result += unsafeCharacters.contains(scalar) ? "_" : String(scalar)
}
return safeName.isEmpty ? "upload" : safeName
}
}
/// OSS SDK
enum OSSUploadError: LocalizedError, Equatable {
case sdkUnavailable
case emptyFile
case fileTooLarge
case unsupportedFileType
var errorDescription: String? {
switch self {
case .sdkUnavailable:
"阿里云 OSS Swift SDK 尚未链接到当前 iOS target"
case .emptyFile:
"文件内容不能为空"
case .fileTooLarge:
"文件大小不能超过2048MB"
case .unsupportedFileType:
"仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式"
}
}
}

View File

@ -0,0 +1,52 @@
# Upload 模块业务逻辑
## 模块职责
Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。
该模块不负责业务表单提交,只负责:
- 获取阿里云 OSS STS 临时配置。
- 校验待上传文件大小和扩展名。
- 生成按业务模块隔离的 OSS objectKey。
- 调用阿里云 OSS Swift SDK 上传文件。
- 返回最终可访问的文件 URL。
## 核心对象
- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。
- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。
- `OSSUploadPolicy`上传策略管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。
- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。
- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。
- `RemoteImage`Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。
## 上传流程
1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。
2. ViewModel 调用 `OSSUploadService` 的业务上传方法。
3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。
4. `OSSUploadPolicy` 校验文件并生成 objectKey。
5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。
6. 上传成功后返回 `base_url + objectKey`
7. 业务 ViewModel 再把 URL 提交给对应业务接口。
## 路径规则
当前模块路径:
- `user_avatar``avatar/yyyyMMdd/scenicId/uuid_fileName`
- `real_name``real_name/yyyyMMdd/scenicId/uuid_fileName`
- `task_upload``task_upload/yyyyMMdd/scenicId/uuid_fileName`
- `cloud_driver``cloud_driver/yyyyMMdd/scenicId/uuid_fileName`
- `album_upload``album/yyyyMMdd/scenicId/uuid_fileName`
- `alive_album``live_albums/yyyyMMdd/scenicId/uuid_fileName`
- `punch_point``punch_point/yyyyMMdd/scenicId/uuid_fileName`
- `scenic_apply``scenic_apply/yyyyMMdd/scenicId/uuid_fileName`
文件名会清理控制字符、`/``\`,避免生成非法 objectKey。
## 缓存边界
- OSS STS token 不落盘,只在一次上传流程中临时使用。
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
- 图片展示缓存交给 Kingfisher业务代码不自行保存网络图片文件。
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。

View File

@ -0,0 +1,39 @@
//
// UploadAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// OSS STS token 便
@MainActor
protocol OSSConfigServing {
/// bucket OSS
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
}
@MainActor
/// API
final class UploadAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// bucket OSS
func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/config/get-sts-token",
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
)
)
}
}
extension UploadAPI: OSSConfigServing {}

View File

@ -0,0 +1,121 @@
//
// UploadImageProcessors.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import UIKit
import UniformTypeIdentifiers
///
enum AvatarImageProcessor {
static let maxPixelLength: CGFloat = 1024
static let jpegCompressionQuality: CGFloat = 0.82
///
struct ProcessedImage: Equatable {
let data: Data
let fileName: String
let contentType: UTType
}
/// JPEG
static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "avatar",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageProcessor {
static let maxPixelLength: CGFloat = 1800
static let jpegCompressionQuality: CGFloat = 0.88
/// JPEG
static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "real_name_\(side.rawValue)",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageSide: String {
case front
case back
}
///
enum UploadImageProcessingError: LocalizedError, Equatable {
case invalidImage
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidImage:
"请选择有效的图片"
case .encodingFailed:
"图片处理失败,请重新选择"
}
}
}
/// JPEG
private enum UploadImageRenderer {
/// JPEG
static func process(
data: Data,
fileNamePrefix: String,
maxPixelLength: CGFloat,
jpegCompressionQuality: CGFloat,
timestamp: TimeInterval
) throws -> AvatarImageProcessor.ProcessedImage {
guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else {
throw UploadImageProcessingError.invalidImage
}
let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength)
guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else {
throw UploadImageProcessingError.encodingFailed
}
return AvatarImageProcessor.ProcessedImage(
data: jpegData,
fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg",
contentType: .jpeg
)
}
}
private extension UIImage {
///
func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage {
let longestSide = max(size.width, size.height)
let scale = min(1, maxPixelLength / longestSide)
let targetSize = CGSize(
width: max(1, (size.width * scale).rounded()),
height: max(1, (size.height * scale).rounded())
)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: targetSize))
draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}

View File

@ -0,0 +1,100 @@
//
// UploadModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// OSS STS
struct AliyunOSSResponse: Decodable, Equatable {
let baseUrl: String
let endpoint: String
let region: String
let bucket: String
let expireSeconds: Int
let credentials: AliyunOSSCredentials
/// OSS STS JSON
enum CodingKeys: String, CodingKey {
case baseUrl = "base_url"
case endpoint
case region
case bucket
case expireSeconds = "expire_seconds"
case credentials
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
baseUrl = try container.decodeLossyString(forKey: .baseUrl)
endpoint = try container.decodeLossyString(forKey: .endpoint)
region = try container.decodeLossyString(forKey: .region)
bucket = try container.decodeLossyString(forKey: .bucket)
expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0
credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials)
}
}
/// OSS SDK 访 token
struct AliyunOSSCredentials: Decodable, Equatable {
let accessKeyId: String
let accessKeySecret: String
let securityToken: String
/// OSS JSON
enum CodingKeys: String, CodingKey {
case accessKeyId = "access_key_id"
case accessKeySecret = "access_key_secret"
case securityToken = "security_token"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accessKeyId = try container.decodeLossyString(forKey: .accessKeyId)
accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret)
securityToken = try container.decodeLossyString(forKey: .securityToken)
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,74 @@
//
// AppFormValidator.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
enum AppFormValidator {
///
static func normalizedPhoneNumber(_ value: String) -> String {
value.filter { !$0.isWhitespace }
}
/// 11
static func isValidMainlandPhoneNumber(_ value: String) -> Bool {
let phone = normalizedPhoneNumber(value)
guard phone.count == 11, phone.first == "1" else { return false }
return phone.allSatisfy(\.isNumber)
}
///
static func rangedInteger(_ text: String, name: String, range: ClosedRange<Int>) throws -> Int {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let value = Int(trimmed) else {
throw APIError.networkFailed("\(name)格式不正确")
}
guard range.contains(value) else {
throw APIError.networkFailed("\(name)须在 \(range.lowerBound)\(range.upperBound)")
}
return value
}
///
static func validateQueueNoticeThresholds(first: Int, second: Int) throws {
guard second <= first else {
throw APIError.networkFailed("第二次通知阈值不能大于第一次通知阈值")
}
}
///
static func sanitizedMoneyInput(_ input: String, maxDecimalPlaces: Int = 2) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for char in input {
if char.isNumber {
if hasDot {
guard decimalCount < maxDecimalPlaces else { continue }
decimalCount += 1
}
result.append(char)
} else if char == ".", !hasDot {
hasDot = true
result.append(char)
}
}
if result.hasPrefix(".") {
result = "0" + result
}
return result
}
///
static func normalizedMoneyForSubmit(_ input: String) -> String {
let sanitized = sanitizedMoneyInput(input, maxDecimalPlaces: 2)
guard !sanitized.isEmpty, sanitized != "." else { return "" }
return sanitized
}
}