Files
shaguabijia-app-ios/App/Networking/APIClient.swift
T
marco 98b7600797 初始化傻瓜比价正式版 iOS 客户端
对标安卓正式版、对接 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>
2026-06-05 00:54:11 +08:00

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 Bearer401 ( 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
}
}