APIClient.swift 15 KB

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