Files
suixinkan_uikit/suixinkan/Core/Networking/APIClient.swift
T
lujiuyinandCursor 7deeedec68 修复素材上传 OSS 路径并完善调试与表单交互。
对齐 Android material/{scenicId}/0/ 目录结构以修复视频元数据写入失败,补充 API 请求体调试日志、素材拍摄麦克风权限与上传控件点击穿透修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:19:58 +08:00

432 lines
17 KiB
Swift

//
// APIClient.swift
// suixinkan
//
import Foundation
/// URLSession 抽象协议,用于让网络客户端支持测试替身。
protocol URLSessionProtocol {
/// 发起 URLRequest 并返回原始数据和响应。
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
@MainActor
/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。
final class APIClient {
private let session: URLSessionProtocol
private let encoder: JSONEncoder
private let decoder: JSONDecoder
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)
do {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
throw error
}
}
/// 发送原始 JSON 字符串请求体(对齐 Android `TextContent` 裸 JSON POST)。
func sendRawJSON<Response: Decodable>(
method: HTTPMethod,
path: String,
jsonBody: String,
tokenOverride: String? = nil
) async throws -> Response {
let apiPath = path.hasPrefix("/") ? path : "/" + path
guard let url = URL(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath
) else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = 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")
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
request.httpBody = jsonBody.data(using: .utf8)
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)
do {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
throw error
}
}
/// 发送单文件 multipart/form-data 请求,并返回解包后的业务数据。
func sendMultipart<Response: Decodable>(
path: String,
fieldName: String = "file",
fileName: String,
mimeType: String,
data fileData: Data,
timeoutInterval: TimeInterval = 60,
tokenOverride: String? = nil
) async throws -> Response {
let apiPath = path.hasPrefix("/") ? path : "/" + path
guard let url = URL(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath
) else {
throw APIError.invalidURL
}
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n"
.data(using: .utf8)!
)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.timeoutInterval = timeoutInterval
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
request.setValue(String(body.count), forHTTPHeaderField: "Content-Length")
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
request.httpBody = body
logRequest(request)
let responseData: Data
let response: URLResponse
do {
(responseData, 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: responseData)
do {
try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
throw error
}
}
/// 将业务请求模型转换为 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
}
/// Token 失效时广播 sessionDidExpire,触发全局登出。
private func notifySessionExpiredIfNeeded(for error: APIError) {
guard APIError.isAuthenticationExpired(error) else { return }
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
}
/// 从 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 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
private func logRequest(_ request: URLRequest) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
var lines = ["[API][Request] \(method) \(url)"]
if let queryItems = URLComponents(url: request.url ?? URL(fileURLWithPath: "/"), resolvingAgainstBaseURL: false)?.queryItems,
!queryItems.isEmpty {
let params = queryItems
.map { item in
let value = item.value ?? ""
return "\(item.name)=\(value)"
}
.joined(separator: "&")
lines.append("params: \(params)")
}
let contentType = request.value(forHTTPHeaderField: "Content-Type")
if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
lines.append("body:\n\(body)")
}
print(lines.joined(separator: "\n"))
#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 debugRequestBody(from data: Data?, contentType: String?) -> String? {
guard let data, !data.isEmpty else { return nil }
if let contentType, contentType.lowercased().contains("multipart/form-data") {
return "<multipart/form-data: \(data.count) bytes>"
}
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
}
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
return text.count > 2_000 ? String(text.prefix(2_000)) + "..." : text
}
return "<non-utf8 body: \(data.count) bytes>"
}
/// 将响应体格式化为便于调试阅读的字符串。
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
}
}