APIClient.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import Foundation
  2. import Security
  3. // Remote contract: Docs/RemoteAPI.openapi.yaml
  4. // Keep the spec and these request/response models in sync.
  5. enum HTTPMethod: String {
  6. case get = "GET"
  7. case post = "POST"
  8. case put = "PUT"
  9. case delete = "DELETE"
  10. }
  11. struct APIEnvelope<T: Decodable>: Decodable {
  12. let code: Int
  13. let message: String
  14. let data: T?
  15. }
  16. private struct APIErrorEnvelope: Decodable {
  17. let code: Int
  18. let message: String
  19. }
  20. struct EmptyAPIData: Decodable { }
  21. enum APIError: LocalizedError {
  22. case invalidConfiguration
  23. case invalidResponse
  24. case secureConnection
  25. case unauthorized
  26. case server(code: Int, message: String)
  27. case transport(String)
  28. case decoding(String)
  29. case missingData
  30. case storageQuotaExceeded(requiredBytes: Int64, remainingBytes: Int64)
  31. var errorDescription: String? {
  32. switch self {
  33. case .invalidConfiguration: return "服务地址配置无效"
  34. case .invalidResponse: return "服务器返回了无效响应"
  35. case .secureConnection: return "无法安全连接登录服务器,请检查服务器 HTTPS 证书配置"
  36. case .unauthorized: return "登录状态已失效,请重新登录"
  37. case .server(_, let message): return message
  38. case .transport(let message): return "网络连接失败:\(message)"
  39. case .decoding(let message): return "服务器数据解析失败:\(message)"
  40. case .missingData: return "服务器响应缺少必要数据"
  41. case .storageQuotaExceeded(let requiredBytes, let remainingBytes):
  42. let formatter = ByteCountFormatter()
  43. formatter.countStyle = .file
  44. return "云端空间不足:需要 \(formatter.string(fromByteCount: requiredBytes)),剩余 \(formatter.string(fromByteCount: remainingBytes))"
  45. }
  46. }
  47. }
  48. struct AuthTokens: Codable, Sendable {
  49. let accessToken: String
  50. let refreshToken: String
  51. let expiresAt: Date
  52. }
  53. final class TokenStore: @unchecked Sendable {
  54. static let shared = TokenStore()
  55. private let service = "com.celestia.trace.authentication"
  56. private let account = "current-session"
  57. func load() -> AuthTokens? {
  58. let query: [String: Any] = [
  59. kSecClass as String: kSecClassGenericPassword,
  60. kSecAttrService as String: service,
  61. kSecAttrAccount as String: account,
  62. kSecReturnData as String: true,
  63. kSecMatchLimit as String: kSecMatchLimitOne
  64. ]
  65. var result: CFTypeRef?
  66. guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
  67. let data = result as? Data else { return nil }
  68. return try? JSONDecoder().decode(AuthTokens.self, from: data)
  69. }
  70. func save(_ tokens: AuthTokens) throws {
  71. let data = try JSONEncoder().encode(tokens)
  72. let baseQuery: [String: Any] = [
  73. kSecClass as String: kSecClassGenericPassword,
  74. kSecAttrService as String: service,
  75. kSecAttrAccount as String: account
  76. ]
  77. let attributes: [String: Any] = [
  78. kSecValueData as String: data,
  79. kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  80. ]
  81. let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary)
  82. if status == errSecItemNotFound {
  83. var insert = baseQuery
  84. attributes.forEach { insert[$0.key] = $0.value }
  85. guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else {
  86. throw APIError.transport("无法安全保存登录凭据")
  87. }
  88. } else if status != errSecSuccess {
  89. throw APIError.transport("无法更新登录凭据")
  90. }
  91. }
  92. func clear() {
  93. let query: [String: Any] = [
  94. kSecClass as String: kSecClassGenericPassword,
  95. kSecAttrService as String: service,
  96. kSecAttrAccount as String: account
  97. ]
  98. SecItemDelete(query as CFDictionary)
  99. }
  100. }
  101. private struct RefreshPayload: Decodable {
  102. let token: String
  103. let refreshToken: String
  104. let expiresAt: Date
  105. }
  106. actor APIClient {
  107. static let shared = APIClient()
  108. private let baseURL: URL
  109. private let session: URLSession
  110. private let tokenStore: TokenStore
  111. init(
  112. baseURL: URL? = nil,
  113. session: URLSession? = nil,
  114. tokenStore: TokenStore = .shared
  115. ) {
  116. let configured = baseURL ?? (Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String).flatMap(URL.init(string:))
  117. self.baseURL = configured ?? URL(string: "https://api.ccdw.life/celestia-trace/v1")!
  118. if let session {
  119. self.session = session
  120. } else {
  121. let configuration = URLSessionConfiguration.default
  122. // Authentication is user-initiated. Fail promptly when the device is
  123. // offline or TLS negotiation fails instead of leaving the UI waiting.
  124. configuration.waitsForConnectivity = false
  125. configuration.timeoutIntervalForRequest = 15
  126. // A resource transfer may legitimately take much longer than a normal
  127. // API request, especially for recordings on a slow mobile connection.
  128. // Keep the short inactivity timeout above, but do not impose a
  129. // one-minute absolute deadline on the complete upload/download.
  130. configuration.timeoutIntervalForResource = 24 * 60 * 60
  131. self.session = URLSession(configuration: configuration)
  132. }
  133. self.tokenStore = tokenStore
  134. }
  135. func hasStoredSession() -> Bool {
  136. tokenStore.load() != nil
  137. }
  138. func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws {
  139. try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt))
  140. }
  141. func clearSession() {
  142. tokenStore.clear()
  143. }
  144. func request<T: Decodable>(
  145. _ path: String,
  146. method: HTTPMethod = .get,
  147. body: Data? = nil,
  148. authenticated: Bool = false
  149. ) async throws -> T {
  150. let envelope: APIEnvelope<T> = try await execute(
  151. path,
  152. method: method,
  153. body: body,
  154. authenticated: authenticated,
  155. canRefresh: true
  156. )
  157. guard let data = envelope.data else { throw APIError.missingData }
  158. return data
  159. }
  160. func requestVoid(
  161. _ path: String,
  162. method: HTTPMethod,
  163. body: Data? = nil,
  164. authenticated: Bool = false
  165. ) async throws {
  166. let _: APIEnvelope<EmptyAPIData> = try await execute(
  167. path,
  168. method: method,
  169. body: body,
  170. authenticated: authenticated,
  171. canRefresh: true
  172. )
  173. }
  174. func upload<T: Decodable>(
  175. _ path: String,
  176. fileURL: URL,
  177. fileName: String,
  178. mimeType: String,
  179. fields: [String: String],
  180. progress: @escaping @Sendable (Double) -> Void
  181. ) async throws -> T {
  182. do {
  183. return try await performUpload(
  184. path,
  185. fileURL: fileURL,
  186. fileName: fileName,
  187. mimeType: mimeType,
  188. fields: fields,
  189. progress: progress
  190. )
  191. } catch APIError.unauthorized {
  192. try await refreshTokens()
  193. return try await performUpload(
  194. path,
  195. fileURL: fileURL,
  196. fileName: fileName,
  197. mimeType: mimeType,
  198. fields: fields,
  199. progress: progress
  200. )
  201. }
  202. }
  203. func download(_ path: String, to destinationURL: URL) async throws {
  204. do {
  205. try await performDownload(path, to: destinationURL)
  206. } catch APIError.unauthorized {
  207. try await refreshTokens()
  208. try await performDownload(path, to: destinationURL)
  209. }
  210. }
  211. private func execute<T: Decodable>(
  212. _ path: String,
  213. method: HTTPMethod,
  214. body: Data?,
  215. authenticated: Bool,
  216. canRefresh: Bool
  217. ) async throws -> APIEnvelope<T> {
  218. var request = try makeRequest(path: path, method: method, body: body)
  219. if authenticated {
  220. guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  221. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  222. }
  223. let data: Data
  224. let response: URLResponse
  225. do {
  226. (data, response) = try await session.data(for: request)
  227. } catch let error as URLError where Self.isSecureConnectionError(error.code) {
  228. throw APIError.secureConnection
  229. } catch {
  230. throw APIError.transport(error.localizedDescription)
  231. }
  232. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  233. if http.statusCode == 401, authenticated, canRefresh {
  234. try await refreshTokens()
  235. return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
  236. }
  237. guard (200..<300).contains(http.statusCode) else {
  238. throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
  239. }
  240. let envelope: APIEnvelope<T>
  241. do {
  242. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  243. } catch {
  244. throw APIError.decoding(error.localizedDescription)
  245. }
  246. guard envelope.code == 0 else {
  247. throw APIError.server(code: envelope.code, message: envelope.message)
  248. }
  249. return envelope
  250. }
  251. private func refreshTokens() async throws {
  252. guard let current = tokenStore.load() else { throw APIError.unauthorized }
  253. let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken])
  254. let envelope: APIEnvelope<RefreshPayload> = try await execute(
  255. "auth/refresh",
  256. method: .post,
  257. body: body,
  258. authenticated: false,
  259. canRefresh: false
  260. )
  261. guard let payload = envelope.data else { throw APIError.unauthorized }
  262. try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt))
  263. }
  264. private func performUpload<T: Decodable>(
  265. _ path: String,
  266. fileURL: URL,
  267. fileName: String,
  268. mimeType: String,
  269. fields: [String: String],
  270. progress: @escaping @Sendable (Double) -> Void
  271. ) async throws -> T {
  272. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  273. let boundary = "CelestiaBoundary-\(UUID().uuidString)"
  274. let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload")
  275. guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil),
  276. let output = try? FileHandle(forWritingTo: temporaryURL) else {
  277. throw APIError.transport("无法准备上传文件")
  278. }
  279. defer {
  280. try? output.close()
  281. try? FileManager.default.removeItem(at: temporaryURL)
  282. }
  283. for (key, value) in fields.sorted(by: { $0.key < $1.key }) {
  284. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8))
  285. }
  286. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8))
  287. let input = try FileHandle(forReadingFrom: fileURL)
  288. while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
  289. try output.write(contentsOf: chunk)
  290. }
  291. try input.close()
  292. try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
  293. try output.synchronize()
  294. var request = try makeRequest(path: path, method: .post, body: nil)
  295. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  296. request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
  297. let data: Data
  298. let response: URLResponse
  299. do {
  300. let progressDelegate = UploadProgressDelegate(progress: progress)
  301. (data, response) = try await session.upload(
  302. for: request,
  303. fromFile: temporaryURL,
  304. delegate: progressDelegate
  305. )
  306. } catch {
  307. throw APIError.transport(error.localizedDescription)
  308. }
  309. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  310. if http.statusCode == 401 { throw APIError.unauthorized }
  311. guard (200..<300).contains(http.statusCode) else {
  312. throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
  313. }
  314. let envelope: APIEnvelope<T>
  315. do {
  316. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  317. } catch {
  318. throw APIError.decoding(error.localizedDescription)
  319. }
  320. guard envelope.code == 0 else {
  321. throw APIError.server(code: envelope.code, message: envelope.message)
  322. }
  323. guard let result = envelope.data else { throw APIError.missingData }
  324. return result
  325. }
  326. private func performDownload(_ path: String, to destinationURL: URL) async throws {
  327. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  328. var request = try makeRequest(path: path, method: .get, body: nil)
  329. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  330. let temporaryURL: URL
  331. let response: URLResponse
  332. do {
  333. (temporaryURL, response) = try await session.download(for: request)
  334. } catch let error as URLError where error.code == .timedOut {
  335. throw APIError.transport("云端文件下载超时,请检查网络稳定性后重试")
  336. } catch {
  337. throw APIError.transport(error.localizedDescription)
  338. }
  339. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  340. if http.statusCode == 401 { throw APIError.unauthorized }
  341. guard (200..<300).contains(http.statusCode) else {
  342. throw APIError.server(code: http.statusCode, message: "云端文件下载失败")
  343. }
  344. let fileManager = FileManager.default
  345. try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
  346. if fileManager.fileExists(atPath: destinationURL.path) {
  347. return
  348. }
  349. do {
  350. try fileManager.moveItem(at: temporaryURL, to: destinationURL)
  351. } catch {
  352. throw APIError.transport("无法保存云端文件:\(error.localizedDescription)")
  353. }
  354. }
  355. private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest {
  356. let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
  357. let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  358. let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in
  359. partial.appendingPathComponent(String(component))
  360. }
  361. var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false)
  362. if pathAndQuery.count == 2 {
  363. components?.percentEncodedQuery = String(pathAndQuery[1])
  364. }
  365. guard let url = components?.url else { throw APIError.invalidConfiguration }
  366. var request = URLRequest(url: url)
  367. request.httpMethod = method.rawValue
  368. request.setValue("application/json", forHTTPHeaderField: "Accept")
  369. if let body {
  370. request.httpBody = body
  371. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  372. }
  373. return request
  374. }
  375. static let decoder: JSONDecoder = {
  376. let decoder = JSONDecoder()
  377. decoder.dateDecodingStrategy = .custom { decoder in
  378. let container = try decoder.singleValueContainer()
  379. let value = try container.decode(String.self)
  380. let formatter = ISO8601DateFormatter()
  381. formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  382. if let date = formatter.date(from: value) { return date }
  383. formatter.formatOptions = [.withInternetDateTime]
  384. if let date = formatter.date(from: value) { return date }
  385. throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date")
  386. }
  387. return decoder
  388. }()
  389. private static func isSecureConnectionError(_ code: URLError.Code) -> Bool {
  390. switch code {
  391. case .secureConnectionFailed,
  392. .serverCertificateHasBadDate,
  393. .serverCertificateUntrusted,
  394. .serverCertificateHasUnknownRoot,
  395. .serverCertificateNotYetValid,
  396. .clientCertificateRejected,
  397. .clientCertificateRequired,
  398. .appTransportSecurityRequiresSecureConnection:
  399. return true
  400. default:
  401. return false
  402. }
  403. }
  404. private static func serverError(from data: Data, fallbackStatusCode: Int) -> APIError {
  405. if fallbackStatusCode == 401 {
  406. return .unauthorized
  407. }
  408. if let envelope = try? decoder.decode(APIErrorEnvelope.self, from: data) {
  409. return .server(code: envelope.code, message: envelope.message)
  410. }
  411. return .server(code: fallbackStatusCode, message: "服务器请求失败(\(fallbackStatusCode))")
  412. }
  413. }
  414. private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
  415. private let progress: @Sendable (Double) -> Void
  416. init(progress: @escaping @Sendable (Double) -> Void) {
  417. self.progress = progress
  418. }
  419. func urlSession(
  420. _ session: URLSession,
  421. task: URLSessionTask,
  422. didSendBodyData bytesSent: Int64,
  423. totalBytesSent: Int64,
  424. totalBytesExpectedToSend: Int64
  425. ) {
  426. guard totalBytesExpectedToSend > 0 else { return }
  427. progress(min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1))
  428. }
  429. }