Initial commit
This commit is contained in:
65
suixinkan/Core/Core.md
Normal file
65
suixinkan/Core/Core.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Core 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存储和通用设计常量。业务页面不应直接重复实现这些能力。
|
||||
|
||||
主要子模块:
|
||||
- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。
|
||||
- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。
|
||||
- `Design`:统一颜色、字号、间距、控件尺寸和圆角。
|
||||
|
||||
## 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、一次性扫码结果和错误提示不落盘。
|
||||
- 头像图片缓存交给 Kingfisher,Core 不保存图片 Data。
|
||||
|
||||
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
|
||||
- `AccountProfile`
|
||||
- 账号类型和业务账号 ID
|
||||
- 当前角色 ID
|
||||
- 景区作用域和门店作用域
|
||||
- 当前景区 ID 和当前门店 ID
|
||||
|
||||
## Design
|
||||
|
||||
`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。
|
||||
|
||||
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
||||
29
suixinkan/Core/Design/AppDesign.swift
Normal file
29
suixinkan/Core/Design/AppDesign.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// AppDesign.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 应用通用颜色定义,集中管理跨页面复用的设计色值。
|
||||
enum AppDesign {
|
||||
static let primary = Color(hex: 0x0073FF)
|
||||
static let primarySoft = Color(hex: 0xEFF6FF)
|
||||
static let textPrimary = Color(hex: 0x1F2937)
|
||||
static let textSecondary = Color(hex: 0x6B7280)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// 通过 0xRRGGBB 和透明度创建 SwiftUI Color。
|
||||
init(hex: UInt, alpha: Double = 1.0) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xff) / 255.0,
|
||||
green: Double((hex >> 8) & 0xff) / 255.0,
|
||||
blue: Double(hex & 0xff) / 255.0,
|
||||
opacity: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
67
suixinkan/Core/Design/AppMetrics.swift
Normal file
67
suixinkan/Core/Design/AppMetrics.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
257
suixinkan/Core/Networking/APIClient.swift
Normal file
257
suixinkan/Core/Networking/APIClient.swift
Normal 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,并注入公共 Header、token 和请求体。
|
||||
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
|
||||
}
|
||||
}
|
||||
62
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
62
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// APIEnvelope.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 后端统一响应包裹实体,承载业务 code、msg 和真实 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?
|
||||
}
|
||||
62
suixinkan/Core/Networking/APIEnvironment.swift
Normal file
62
suixinkan/Core/Networking/APIEnvironment.swift
Normal 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
|
||||
}
|
||||
}
|
||||
61
suixinkan/Core/Networking/APIError.swift
Normal file
61
suixinkan/Core/Networking/APIError.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// APIError.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 网络层错误实体,统一转换 URL、HTTP、业务码、解析和网络异常。
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
55
suixinkan/Core/Networking/APIRequest.swift
Normal file
55
suixinkan/Core/Networking/APIRequest.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
55
suixinkan/Core/Networking/ListPayload.swift
Normal file
55
suixinkan/Core/Networking/ListPayload.swift
Normal 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 {
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
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
|
||||
}
|
||||
}
|
||||
305
suixinkan/Core/Networking/Networking.md
Normal file
305
suixinkan/Core/Networking/Networking.md
Normal 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 下默认测试环境,真机调试时需要注意接口环境。
|
||||
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
@ -0,0 +1,86 @@
|
||||
//
|
||||
// AccountSnapshotStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
final class AccountSnapshotStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let key: String
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored 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)
|
||||
}
|
||||
}
|
||||
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AppPreferencesStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。
|
||||
final class AppPreferencesStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
@ObservationIgnored 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)
|
||||
}
|
||||
}
|
||||
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// SessionTokenStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Security
|
||||
|
||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
||||
enum SessionTokenStoreError: LocalizedError {
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unexpectedStatus(status):
|
||||
"登录凭证存储失败(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
||||
final class SessionTokenStore {
|
||||
@ObservationIgnored private let service: String
|
||||
@ObservationIgnored 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
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user