初始提交

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit 0a0d4fbd79
84 changed files with 8899 additions and 0 deletions

View File

@ -0,0 +1,257 @@
//
// APIClient.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
import Observation
/// URLSession
protocol URLSessionProtocol {
/// URLRequest
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
@MainActor
@Observable
/// token Envelope
final class APIClient {
@ObservationIgnored private let session: URLSessionProtocol
@ObservationIgnored private let encoder: JSONEncoder
@ObservationIgnored private let decoder: JSONDecoder
@ObservationIgnored 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,55 @@
//
// 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) ?? []
}
}
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 下默认测试环境,真机调试时需要注意接口环境。