- AuthManager: 移除 isAuthenticated/isRestoring,仅保留 AppSession 枚举 - App 入口: switch session 分发行路由,支持 disabled/deleted 状态页 - 新增 AccountStatusView(禁用/注销提示) - ContentView: TabBar 5→4(学习/知识库/分析/我的),去掉 AI 首页 - StudyHomeView: 融合每日思考题,去掉 AI 输入栏和本周活跃 - AnalysisHomeView: 新增本周学习活跃柱状图 - AppleAuthRequest 新增 authorizationCode,LoginPage 提取传递 - APIError 支持 errorCode,APIClient 解析服务端 errorCode - AuthManager.applyErrorCode() 根据错误码切换状态 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
173 lines
5.7 KiB
Swift
173 lines
5.7 KiB
Swift
//
|
|
// APIClient.swift - 通用 HTTP 客户端
|
|
//
|
|
|
|
import Foundation
|
|
|
|
actor APIClient {
|
|
static let shared = APIClient()
|
|
|
|
private let session: URLSession
|
|
private var token: String?
|
|
|
|
private init() {
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = APIConfig.timeout
|
|
config.timeoutIntervalForResource = 60
|
|
session = URLSession(configuration: config)
|
|
}
|
|
|
|
func setToken(_ token: String?) {
|
|
self.token = token
|
|
}
|
|
|
|
// MARK: - Generic request
|
|
|
|
func request<T: Decodable>(
|
|
_ path: String,
|
|
method: String = "GET",
|
|
body: Encodable? = nil,
|
|
queryItems: [URLQueryItem]? = nil
|
|
) async throws -> T {
|
|
try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: false)
|
|
}
|
|
|
|
private func performRequest<T: Decodable>(
|
|
_ path: String,
|
|
method: String,
|
|
body: Encodable?,
|
|
queryItems: [URLQueryItem]?,
|
|
isRetry: Bool
|
|
) async throws -> T {
|
|
var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)!
|
|
if let queryItems { components.queryItems = queryItems }
|
|
|
|
var request = URLRequest(url: components.url!)
|
|
request.httpMethod = method
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
if let token {
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
if let body {
|
|
request.httpBody = try JSONEncoder().encode(AnyEncodable(body))
|
|
}
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw APIError.networkError(NSError(domain: "", code: -1))
|
|
}
|
|
|
|
switch httpResponse.statusCode {
|
|
case 200, 201:
|
|
return try decodeResponse(data)
|
|
case 401 where !isRetry:
|
|
if let newToken = await refreshAccessToken() {
|
|
self.token = newToken
|
|
return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true)
|
|
}
|
|
await notifyTokenExpired()
|
|
throw decodeServerError(data, fallback: APIError.unauthorized)
|
|
case 401:
|
|
throw decodeServerError(data, fallback: APIError.unauthorized)
|
|
case 400..<500:
|
|
let msg = String(data: data, encoding: .utf8) ?? ""
|
|
throw APIError.serverError(msg)
|
|
default:
|
|
throw APIError.requestFailed(httpResponse.statusCode)
|
|
}
|
|
}
|
|
|
|
private func refreshAccessToken() async -> String? {
|
|
guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil }
|
|
do {
|
|
let body = RefreshRequest(refreshToken: refreshToken)
|
|
let resp: AuthResponse = try await performRequest(
|
|
"/auth/refresh", method: "POST", body: body, queryItems: nil, isRetry: true
|
|
)
|
|
KeychainHelper.save(
|
|
accessToken: resp.accessToken,
|
|
refreshToken: resp.refreshToken,
|
|
userId: resp.user?.id ?? ""
|
|
)
|
|
return resp.accessToken
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func notifyTokenExpired() async {
|
|
await MainActor.run {
|
|
NotificationCenter.default.post(name: .tokenExpired, object: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Server error decoding
|
|
|
|
/// 从服务端错误响应中提取 message 和 errorCode
|
|
func extractErrorInfo(_ data: Data) -> (message: String?, errorCode: String?) {
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return (nil, nil)
|
|
}
|
|
return (
|
|
message: json["message"] as? String,
|
|
errorCode: json["errorCode"] as? String
|
|
)
|
|
}
|
|
|
|
private func decodeServerError(_ data: Data, fallback: APIError) -> APIError {
|
|
let info = extractErrorInfo(data)
|
|
if let serverMsg = info.message {
|
|
return APIError.serverError(serverMsg, code: info.errorCode)
|
|
}
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
// MARK: - Server error info (public for AuthManager)
|
|
|
|
struct ServerErrorInfo {
|
|
let message: String
|
|
let errorCode: String?
|
|
}
|
|
|
|
extension APIClient {
|
|
nonisolated func parseErrorInfo(from data: Data) -> ServerErrorInfo? {
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let msg = json["message"] as? String else {
|
|
return nil
|
|
}
|
|
return ServerErrorInfo(message: msg, errorCode: json["errorCode"] as? String)
|
|
}
|
|
|
|
// MARK: - Decoding
|
|
|
|
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
|
|
let decoder = JSONDecoder()
|
|
// Try unwrapped response first (no envelope), then wrapped
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
json["data"] == nil && json["success"] == nil {
|
|
return try decoder.decode(T.self, from: data)
|
|
}
|
|
// Has envelope wrapper
|
|
do {
|
|
let envelope = try decoder.decode(APIEnvelope<T>.self, from: data)
|
|
return envelope.data
|
|
} catch {
|
|
// Fallback: try decoding T directly (e.g. server returns unwrapped on some endpoints)
|
|
return try decoder.decode(T.self, from: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper for encoding arbitrary Encodable
|
|
|
|
struct AnyEncodable: Encodable {
|
|
let value: Encodable
|
|
init(_ value: Encodable) { self.value = value }
|
|
func encode(to encoder: Encoder) throws { try value.encode(to: encoder) }
|
|
}
|