98b7600797
对标安卓正式版、对接 app-server(8770)的 SwiftUI 客户端。iOS 无障碍受限、无比价核心,做自成一体:美团 CPS 导购 + 福利(签到/任务/金币)+ 查看(比价记录/省钱战绩)。 功能完整 M0-M5:脚手架、短信登录(JWT+Keychain+401 自动刷新)、首页券 feed 导购、比价记录+省钱战绩、福利、我的(资料/反馈/设置/注销)。 单 target、0 三方依赖、XcodeGen 生成工程;Debug/Release 双 plist 分流 ATS。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
8.1 KiB
Swift
193 lines
8.1 KiB
Swift
import Foundation
|
|
|
|
extension Notification.Name {
|
|
/// 刷新失败/登录彻底过期。SessionStore 监听后清登录态、UI 跳登录。
|
|
static let authDidExpire = Notification.Name("authDidExpire")
|
|
}
|
|
|
|
/// multipart 上传的单个文件部件。
|
|
struct MultipartFile {
|
|
let name: String // 表单字段名(头像="file"、反馈="images")
|
|
let filename: String
|
|
let mimeType: String
|
|
let data: Data
|
|
}
|
|
|
|
/// REST 客户端。自动注入 Bearer、401 自动刷新重放一次(对应安卓 AuthInterceptor + RefreshAuthenticator)。
|
|
/// 全局 snake_case ↔ camelCase 编解码,所以 DTO 用 camelCase 属性、无需逐个 CodingKeys。
|
|
final class APIClient: @unchecked Sendable {
|
|
static let shared = APIClient()
|
|
|
|
private let baseURL: URL
|
|
private let session: URLSession
|
|
let decoder: JSONDecoder
|
|
let encoder: JSONEncoder
|
|
|
|
init(baseURL: URL = AppConfig.apiBaseURL, session: URLSession = .shared) {
|
|
self.baseURL = baseURL
|
|
self.session = session
|
|
let d = JSONDecoder()
|
|
d.keyDecodingStrategy = .convertFromSnakeCase
|
|
self.decoder = d
|
|
let e = JSONEncoder()
|
|
e.keyEncodingStrategy = .convertToSnakeCase
|
|
self.encoder = e
|
|
}
|
|
|
|
// MARK: 便捷方法
|
|
|
|
func get<R: Decodable>(_ path: String, query: [URLQueryItem]? = nil, auth: Bool = true) async throws -> R {
|
|
try await send(path: path, method: "GET", query: query, body: EmptyBody?.none, auth: auth)
|
|
}
|
|
|
|
func post<B: Encodable, R: Decodable>(_ path: String, body: B, auth: Bool = true) async throws -> R {
|
|
try await send(path: path, method: "POST", query: nil, body: body, auth: auth)
|
|
}
|
|
|
|
func post<R: Decodable>(_ path: String, auth: Bool = true) async throws -> R {
|
|
try await send(path: path, method: "POST", query: nil, body: EmptyBody?.none, auth: auth)
|
|
}
|
|
|
|
func patch<B: Encodable, R: Decodable>(_ path: String, body: B, auth: Bool = true) async throws -> R {
|
|
try await send(path: path, method: "PATCH", query: nil, body: body, auth: auth)
|
|
}
|
|
|
|
func delete<R: Decodable>(_ path: String, auth: Bool = true) async throws -> R {
|
|
try await send(path: path, method: "DELETE", query: nil, body: EmptyBody?.none, auth: auth)
|
|
}
|
|
|
|
/// multipart/form-data 上传(头像、反馈截图)。401 同样自动刷新重放一次。
|
|
func postMultipart<R: Decodable>(
|
|
_ path: String,
|
|
fields: [String: String] = [:],
|
|
files: [MultipartFile] = [],
|
|
auth: Bool = true,
|
|
isRetry: Bool = false
|
|
) async throws -> R {
|
|
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
|
var req = URLRequest(url: baseURL.appending(path: cleanPath))
|
|
req.httpMethod = "POST"
|
|
req.timeoutInterval = 30
|
|
let boundary = "----shagua-\(UUID().uuidString)"
|
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = Self.buildMultipart(boundary: boundary, fields: fields, files: files)
|
|
if auth, let token = await AuthTokens.shared.accessToken() {
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
let (data, resp) = try await performData(req)
|
|
guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse }
|
|
if http.statusCode == 401, auth {
|
|
if !isRetry, await AuthTokens.shared.refresh() {
|
|
return try await postMultipart(path, fields: fields, files: files, auth: auth, isRetry: true)
|
|
}
|
|
NotificationCenter.default.post(name: .authDidExpire, object: nil)
|
|
throw APIError.unauthorized
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data))
|
|
}
|
|
do { return try decoder.decode(R.self, from: data) }
|
|
catch { throw APIError.decoding(error) }
|
|
}
|
|
|
|
// MARK: 核心
|
|
|
|
private struct EmptyBody: Encodable {}
|
|
|
|
private func send<B: Encodable, R: Decodable>(
|
|
path: String,
|
|
method: String,
|
|
query: [URLQueryItem]?,
|
|
body: B?,
|
|
auth: Bool,
|
|
isRetry: Bool = false
|
|
) async throws -> R {
|
|
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
|
guard var comps = URLComponents(
|
|
url: baseURL.appending(path: cleanPath),
|
|
resolvingAgainstBaseURL: false
|
|
) else { throw APIError.invalidResponse }
|
|
if let query, !query.isEmpty { comps.queryItems = query }
|
|
guard let url = comps.url else { throw APIError.invalidResponse }
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = method
|
|
req.timeoutInterval = 30
|
|
if let body {
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
do { req.httpBody = try encoder.encode(body) }
|
|
catch { throw APIError.decoding(error) }
|
|
}
|
|
if auth, let token = await AuthTokens.shared.accessToken() {
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
let (data, resp) = try await performData(req)
|
|
guard let http = resp as? HTTPURLResponse else { throw APIError.invalidResponse }
|
|
|
|
if http.statusCode == 401, auth {
|
|
if !isRetry, await AuthTokens.shared.refresh() {
|
|
return try await send(
|
|
path: path, method: method, query: query,
|
|
body: body, auth: auth, isRetry: true
|
|
)
|
|
}
|
|
NotificationCenter.default.post(name: .authDidExpire, object: nil)
|
|
throw APIError.unauthorized
|
|
}
|
|
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw APIError.http(status: http.statusCode, detail: Self.parseDetail(data))
|
|
}
|
|
do { return try decoder.decode(R.self, from: data) }
|
|
catch { throw APIError.decoding(error) }
|
|
}
|
|
|
|
/// 裸刷新,供 AuthTokens 调用(不走 send 的鉴权/重试,避免递归)。
|
|
func rawRefresh(refreshToken: String) async throws -> TokenPairDTO {
|
|
var req = URLRequest(url: baseURL.appending(path: "api/v1/auth/refresh"))
|
|
req.httpMethod = "POST"
|
|
req.timeoutInterval = 20
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try encoder.encode(RefreshRequest(refreshToken: refreshToken))
|
|
let (data, resp) = try await performData(req)
|
|
guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
throw APIError.http(status: (resp as? HTTPURLResponse)?.statusCode ?? -1, detail: nil)
|
|
}
|
|
return try decoder.decode(TokenPairDTO.self, from: data)
|
|
}
|
|
|
|
// MARK: helpers
|
|
|
|
private func performData(_ req: URLRequest) async throws -> (Data, URLResponse) {
|
|
do { return try await session.data(for: req) }
|
|
catch { throw APIError.network(error) }
|
|
}
|
|
|
|
private static func buildMultipart(boundary: String, fields: [String: String], files: [MultipartFile]) -> Data {
|
|
var body = Data()
|
|
let crlf = "\r\n"
|
|
for (key, value) in fields {
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
body.append("Content-Disposition: form-data; name=\"\(key)\"\(crlf)\(crlf)".data(using: .utf8)!)
|
|
body.append("\(value)\(crlf)".data(using: .utf8)!)
|
|
}
|
|
for file in files {
|
|
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
|
|
body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\(crlf)".data(using: .utf8)!)
|
|
body.append("Content-Type: \(file.mimeType)\(crlf)\(crlf)".data(using: .utf8)!)
|
|
body.append(file.data)
|
|
body.append(crlf.data(using: .utf8)!)
|
|
}
|
|
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
|
|
return body
|
|
}
|
|
|
|
/// FastAPI 错误体 {"detail": "..."};422 的 detail 是数组,解析失败返 nil(不崩)。
|
|
private static func parseDetail(_ data: Data) -> String? {
|
|
struct E: Decodable { let detail: String? }
|
|
return (try? JSONDecoder().decode(E.self, from: data))?.detail
|
|
}
|
|
}
|