wangdl 539b9a7d2b feat(ios): AppSession 状态枚举接入路由 + TabBar 精简 + authorizationCode
- 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>
2026-05-27 21:08:11 +08:00

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) }
}