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(_ 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(_ 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(_ path: String, auth: Bool = true) async throws -> R { try await send(path: path, method: "POST", query: nil, body: EmptyBody?.none, auth: auth) } func patch(_ 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(_ 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( _ 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( 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 } }