APIClient.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  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. configuration.timeoutIntervalForResource = 60
  127. self.session = URLSession(configuration: configuration)
  128. }
  129. self.tokenStore = tokenStore
  130. }
  131. func hasStoredSession() -> Bool {
  132. tokenStore.load() != nil
  133. }
  134. func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws {
  135. try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt))
  136. }
  137. func clearSession() {
  138. tokenStore.clear()
  139. }
  140. func request<T: Decodable>(
  141. _ path: String,
  142. method: HTTPMethod = .get,
  143. body: Data? = nil,
  144. authenticated: Bool = false
  145. ) async throws -> T {
  146. let envelope: APIEnvelope<T> = try await execute(
  147. path,
  148. method: method,
  149. body: body,
  150. authenticated: authenticated,
  151. canRefresh: true
  152. )
  153. guard let data = envelope.data else { throw APIError.missingData }
  154. return data
  155. }
  156. func requestVoid(
  157. _ path: String,
  158. method: HTTPMethod,
  159. body: Data? = nil,
  160. authenticated: Bool = false
  161. ) async throws {
  162. let _: APIEnvelope<EmptyAPIData> = try await execute(
  163. path,
  164. method: method,
  165. body: body,
  166. authenticated: authenticated,
  167. canRefresh: true
  168. )
  169. }
  170. func upload<T: Decodable>(
  171. _ path: String,
  172. fileURL: URL,
  173. fileName: String,
  174. mimeType: String,
  175. fields: [String: String],
  176. progress: @escaping @Sendable (Double) -> Void
  177. ) async throws -> T {
  178. do {
  179. return try await performUpload(
  180. path,
  181. fileURL: fileURL,
  182. fileName: fileName,
  183. mimeType: mimeType,
  184. fields: fields,
  185. progress: progress
  186. )
  187. } catch APIError.unauthorized {
  188. try await refreshTokens()
  189. return try await performUpload(
  190. path,
  191. fileURL: fileURL,
  192. fileName: fileName,
  193. mimeType: mimeType,
  194. fields: fields,
  195. progress: progress
  196. )
  197. }
  198. }
  199. func download(_ path: String, to destinationURL: URL) async throws {
  200. do {
  201. try await performDownload(path, to: destinationURL)
  202. } catch APIError.unauthorized {
  203. try await refreshTokens()
  204. try await performDownload(path, to: destinationURL)
  205. }
  206. }
  207. private func execute<T: Decodable>(
  208. _ path: String,
  209. method: HTTPMethod,
  210. body: Data?,
  211. authenticated: Bool,
  212. canRefresh: Bool
  213. ) async throws -> APIEnvelope<T> {
  214. var request = try makeRequest(path: path, method: method, body: body)
  215. if authenticated {
  216. guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  217. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  218. }
  219. let data: Data
  220. let response: URLResponse
  221. do {
  222. (data, response) = try await session.data(for: request)
  223. } catch let error as URLError where Self.isSecureConnectionError(error.code) {
  224. throw APIError.secureConnection
  225. } catch {
  226. throw APIError.transport(error.localizedDescription)
  227. }
  228. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  229. if http.statusCode == 401, authenticated, canRefresh {
  230. try await refreshTokens()
  231. return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
  232. }
  233. guard (200..<300).contains(http.statusCode) else {
  234. throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
  235. }
  236. let envelope: APIEnvelope<T>
  237. do {
  238. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  239. } catch {
  240. throw APIError.decoding(error.localizedDescription)
  241. }
  242. guard envelope.code == 0 else {
  243. throw APIError.server(code: envelope.code, message: envelope.message)
  244. }
  245. return envelope
  246. }
  247. private func refreshTokens() async throws {
  248. guard let current = tokenStore.load() else { throw APIError.unauthorized }
  249. let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken])
  250. let envelope: APIEnvelope<RefreshPayload> = try await execute(
  251. "auth/refresh",
  252. method: .post,
  253. body: body,
  254. authenticated: false,
  255. canRefresh: false
  256. )
  257. guard let payload = envelope.data else { throw APIError.unauthorized }
  258. try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt))
  259. }
  260. private func performUpload<T: Decodable>(
  261. _ path: String,
  262. fileURL: URL,
  263. fileName: String,
  264. mimeType: String,
  265. fields: [String: String],
  266. progress: @escaping @Sendable (Double) -> Void
  267. ) async throws -> T {
  268. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  269. let boundary = "CelestiaBoundary-\(UUID().uuidString)"
  270. let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload")
  271. guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil),
  272. let output = try? FileHandle(forWritingTo: temporaryURL) else {
  273. throw APIError.transport("无法准备上传文件")
  274. }
  275. defer {
  276. try? output.close()
  277. try? FileManager.default.removeItem(at: temporaryURL)
  278. }
  279. for (key, value) in fields.sorted(by: { $0.key < $1.key }) {
  280. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8))
  281. }
  282. try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8))
  283. let input = try FileHandle(forReadingFrom: fileURL)
  284. while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
  285. try output.write(contentsOf: chunk)
  286. }
  287. try input.close()
  288. try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
  289. try output.synchronize()
  290. var request = try makeRequest(path: path, method: .post, body: nil)
  291. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  292. request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
  293. let data: Data
  294. let response: URLResponse
  295. do {
  296. let progressDelegate = UploadProgressDelegate(progress: progress)
  297. (data, response) = try await session.upload(
  298. for: request,
  299. fromFile: temporaryURL,
  300. delegate: progressDelegate
  301. )
  302. } catch {
  303. throw APIError.transport(error.localizedDescription)
  304. }
  305. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  306. if http.statusCode == 401 { throw APIError.unauthorized }
  307. guard (200..<300).contains(http.statusCode) else {
  308. throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
  309. }
  310. let envelope: APIEnvelope<T>
  311. do {
  312. envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
  313. } catch {
  314. throw APIError.decoding(error.localizedDescription)
  315. }
  316. guard envelope.code == 0 else {
  317. throw APIError.server(code: envelope.code, message: envelope.message)
  318. }
  319. guard let result = envelope.data else { throw APIError.missingData }
  320. return result
  321. }
  322. private func performDownload(_ path: String, to destinationURL: URL) async throws {
  323. guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
  324. var request = try makeRequest(path: path, method: .get, body: nil)
  325. request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
  326. let temporaryURL: URL
  327. let response: URLResponse
  328. do {
  329. (temporaryURL, response) = try await session.download(for: request)
  330. } catch {
  331. throw APIError.transport(error.localizedDescription)
  332. }
  333. guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
  334. if http.statusCode == 401 { throw APIError.unauthorized }
  335. guard (200..<300).contains(http.statusCode) else {
  336. throw APIError.server(code: http.statusCode, message: "云端文件下载失败")
  337. }
  338. let fileManager = FileManager.default
  339. try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
  340. if fileManager.fileExists(atPath: destinationURL.path) {
  341. return
  342. }
  343. do {
  344. try fileManager.moveItem(at: temporaryURL, to: destinationURL)
  345. } catch {
  346. throw APIError.transport("无法保存云端文件:\(error.localizedDescription)")
  347. }
  348. }
  349. private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest {
  350. let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
  351. let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  352. let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in
  353. partial.appendingPathComponent(String(component))
  354. }
  355. var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false)
  356. if pathAndQuery.count == 2 {
  357. components?.percentEncodedQuery = String(pathAndQuery[1])
  358. }
  359. guard let url = components?.url else { throw APIError.invalidConfiguration }
  360. var request = URLRequest(url: url)
  361. request.httpMethod = method.rawValue
  362. request.setValue("application/json", forHTTPHeaderField: "Accept")
  363. if let body {
  364. request.httpBody = body
  365. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  366. }
  367. return request
  368. }
  369. static let decoder: JSONDecoder = {
  370. let decoder = JSONDecoder()
  371. decoder.dateDecodingStrategy = .custom { decoder in
  372. let container = try decoder.singleValueContainer()
  373. let value = try container.decode(String.self)
  374. let formatter = ISO8601DateFormatter()
  375. formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  376. if let date = formatter.date(from: value) { return date }
  377. formatter.formatOptions = [.withInternetDateTime]
  378. if let date = formatter.date(from: value) { return date }
  379. throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date")
  380. }
  381. return decoder
  382. }()
  383. private static func isSecureConnectionError(_ code: URLError.Code) -> Bool {
  384. switch code {
  385. case .secureConnectionFailed,
  386. .serverCertificateHasBadDate,
  387. .serverCertificateUntrusted,
  388. .serverCertificateHasUnknownRoot,
  389. .serverCertificateNotYetValid,
  390. .clientCertificateRejected,
  391. .clientCertificateRequired,
  392. .appTransportSecurityRequiresSecureConnection:
  393. return true
  394. default:
  395. return false
  396. }
  397. }
  398. private static func serverError(from data: Data, fallbackStatusCode: Int) -> APIError {
  399. if fallbackStatusCode == 401 {
  400. return .unauthorized
  401. }
  402. if let envelope = try? decoder.decode(APIErrorEnvelope.self, from: data) {
  403. return .server(code: envelope.code, message: envelope.message)
  404. }
  405. return .server(code: fallbackStatusCode, message: "服务器请求失败(\(fallbackStatusCode))")
  406. }
  407. }
  408. private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
  409. private let progress: @Sendable (Double) -> Void
  410. init(progress: @escaping @Sendable (Double) -> Void) {
  411. self.progress = progress
  412. }
  413. func urlSession(
  414. _ session: URLSession,
  415. task: URLSessionTask,
  416. didSendBodyData bytesSent: Int64,
  417. totalBytesSent: Int64,
  418. totalBytesExpectedToSend: Int64
  419. ) {
  420. guard totalBytesExpectedToSend > 0 else { return }
  421. progress(min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1))
  422. }
  423. }