← API | 列表 | swift_网络层_APIClient
提示信息
# 同伴 App — Swift 网络层 & APIClient 完整实现

> 对应 Flutter 的 Dio + 三拦截器体系。Swift 版采用 `URLSession` + `async/await` + Actor 并发队列,完整还原所有拦截逻辑。

---

## 一、签名算法(X-Sign)

与 Flutter 端完全一致,**MD5**(非 HMAC):

```
原文 = appKey + timestamp + nonce + sortedParams + appSecret

sortedParams:
  - 将 query params + body params 合并
  - 按 key 字母升序排列
  - 拼成 key1=value1&key2=value2
  - Map/Array 类型的 value 用 JSON 序列化
  - 空参数不参与签名

X-Sign = MD5(原文).lowercased()
```

---

## 二、目录结构

```
Core/Network/
├── APIClient.swift            # 核心请求入口(actor)
├── RequestSigner.swift        # 签名 + Header 注入
├── TokenRefresher.swift       # Token 刷新队列(actor)
├── ResponseHandler.swift      # 响应解包 + 错误码路由
├── UploadClient.swift         # 文件上传 + 批量签名 URL
├── AppAPIError.swift          # 统一错误类型
└── Interceptor/
    └── NetworkMonitor.swift   # 网络状态监听
```

---

## 三、AppAPIError

```swift
// AppAPIError.swift
struct AppAPIError: Error, Sendable {
    let code: Int
    let message: String
    var data: (any Sendable)?  // extraData,部分错误码携带

    var isTokenExpired: Bool  { code == 1002 }
    var isNetworkError: Bool  { code == -1 }
    var isAccountInvalid: Bool { code == 1001 }

    // 用于 UI 层直接 toast
    var userMessage: String { message }
}
```

---

## 四、RequestSigner(签名 + Header 注入)

```swift
// RequestSigner.swift
import CryptoKit
import CommonCrypto

struct RequestSigner {

    static func sign(request: inout URLRequest, body: Data?) {
        let timestamp  = String(Int(Date().timeIntervalSince1970))
        let nonce      = UUID().uuidString.replacingOccurrences(of: "-", with: "")
        let appKey     = AppConfig.appKey

        // 注入公共 Header
        request.setValue(appKey,                               forHTTPHeaderField: "X-App-Key")
        request.setValue(timestamp,                            forHTTPHeaderField: "X-Timestamp")
        request.setValue(nonce,                                forHTTPHeaderField: "X-Nonce")
        request.setValue(deviceId,                             forHTTPHeaderField: "X-Device-Id")
        request.setValue("ios",                                forHTTPHeaderField: "X-OS")
        request.setValue(UIDevice.current.systemVersion,       forHTTPHeaderField: "X-OS-Version")
        request.setValue(appVersion,                           forHTTPHeaderField: "X-App-Version")
        request.setValue("zh",                                 forHTTPHeaderField: "X-Language")

        // 注入 Token(已登录时)
        if let token = AppStorage.getString(StorageKeys.accessToken), !token.isEmpty {
            request.setValue("Bearer \(token)",                forHTTPHeaderField: "Authorization")
        }

        // 风控标志
        let riskFlag = AppStorage.getString(StorageKeys.deviceRiskFlag) ?? "[\"unverified\"]"
        request.setValue(riskFlag,                             forHTTPHeaderField: "X-Device-Risk-Flag")

        // 计算签名
        let sortedParams = extractSortedParams(request: request, body: body)
        let raw          = "\(appKey)\(timestamp)\(nonce)\(sortedParams)\(AppConfig.appSecret)"
        request.setValue(md5(raw),                             forHTTPHeaderField: "X-Sign")
    }

    // ── 私有 ─────────────────────────────────────────────────────────────

    private static var deviceId: String {
        KeychainHelper.read(StorageKeys.deviceId) ?? "unknown"
    }

    private static var appVersion: String {
        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
    }

    /// 从 URL query + body JSON 中提取参数,按 key 升序拼接
    private static func extractSortedParams(request: URLRequest, body: Data?) -> String {
        var params: [String: Any] = [:]

        // Query params
        if let url = request.url, let comps = URLComponents(url: url, resolvingAgainstBaseURL: false) {
            comps.queryItems?.forEach { params[$0.name] = $0.value ?? "" }
        }

        // Body JSON(仅 application/json)
        if let data = body,
           let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
            json.forEach { params[$0.key] = $0.value }
        }

        if params.isEmpty { return "" }

        return params.keys.sorted().compactMap { key -> String? in
            guard let value = params[key] else { return nil }
            let strValue: String
            if let nested = value as? [String: Any] ?? (value as? [Any]).map({ $0 as Any }),
               let jsonData = try? JSONSerialization.data(withJSONObject: value),
               let jsonStr = String(data: jsonData, encoding: .utf8) {
                strValue = jsonStr
            } else {
                strValue = "\(value)"
            }
            return "\(key)=\(strValue)"
        }.joined(separator: "&")
    }

    private static func md5(_ string: String) -> String {
        let data = Data(string.utf8)
        var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
        data.withUnsafeBytes { CC_MD5($0.baseAddress, CC_LONG(data.count), &digest) }
        return digest.map { String(format: "%02x", $0) }.joined()
    }
}
```

---

## 五、TokenRefresher(并发刷新队列)

```swift
// TokenRefresher.swift
// actor 保证同一时刻只有一个刷新任务在执行,其余并发请求排队等待
actor TokenRefresher {
    static let shared = TokenRefresher()

    private var isRefreshing = false
    private var waiters: [CheckedContinuation<Bool, Never>] = []

    /// 返回 true = 刷新成功(调用方可重试原请求),false = 刷新失败(需强制登出)
    func refreshIfNeeded() async -> Bool {
        if isRefreshing {
            // 已在刷新,挂起等待
            return await withCheckedContinuation { continuation in
                waiters.append(continuation)
            }
        }

        isRefreshing = true
        let result = await doRefresh()
        isRefreshing = false

        // 通知所有等待者
        let localWaiters = waiters
        waiters.removeAll()
        for waiter in localWaiters {
            waiter.resume(returning: result)
        }

        return result
    }

    private func doRefresh() async -> Bool {
        guard let refreshToken = AppStorage.getString(StorageKeys.refreshToken),
              !refreshToken.isEmpty else {
            return false
        }

        do {
            // 使用独立 URLSession(不走主 APIClient,避免死循环)
            var request = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/auth/token/refresh")!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            let body = try JSONSerialization.data(withJSONObject: ["refresh_token": refreshToken])
            request.httpBody = body
            RequestSigner.sign(request: &request, body: body)   // ⚠️ 刷新请求也需要签名

            let (data, _) = try await URLSession.shared.data(for: request)
            let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
            guard json?["code"] as? Int == 0,
                  let payload = json?["data"] as? [String: Any],
                  let newAccess  = payload["access_token"]  as? String,
                  let newRefresh = payload["refresh_token"] as? String else {
                return false
            }

            AppStorage.setString(StorageKeys.accessToken,  newAccess)
            AppStorage.setString(StorageKeys.refreshToken, newRefresh)
            return true

        } catch {
            return false
        }
    }
}
```

---

## 六、ResponseHandler(响应解包 + 全局错误码路由)

```swift
// ResponseHandler.swift
@MainActor
final class ResponseHandler {

    static func handle(data: Data, response: URLResponse) throws -> Any? {
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw AppAPIError(code: -1, message: "响应格式异常")
        }

        let code    = json["code"] as? Int    ?? -1
        let msg     = json["msg"]  as? String ?? "未知错误"
        let payload = json["data"]

        if code == 0 { return payload }

        // 路由到全局处理器
        switch code {
        case 1001: handleAccountInvalid(msg)
        case 1002: break    // 由 APIClient 捕获后交给 TokenRefresher
        case 1010: AppCoordinator.shared.goBanned(banInfo: payload)
        case 1011: AppCoordinator.shared.goBannedFraud(banInfo: payload)
        case 1012: AppCoordinator.shared.goBannedDevice()
        case 1032: handleUpdate(payload: payload as? [String: Any])
        case 1033: break    // 由 APIClient 捕获后弹隐私协议弹窗重试
        case 1038: AppCoordinator.shared.goMaintenance()
        case 1039: AppCoordinator.shared.goServerError()
        case 2006: showRealNameSheet()
        case 3008: showDailyLimitDialog()
        default: break
        }

        throw AppAPIError(code: code, message: msg, data: payload as? (any Sendable))
    }

    // ── 具体处理逻辑 ──────────────────────────────────────────────────

    private static func handleAccountInvalid(_ msg: String) {
        [StorageKeys.accessToken, StorageKeys.refreshToken,
         StorageKeys.userId, StorageKeys.imToken, StorageKeys.userProfile]
            .forEach { AppStorage.remove($0) }

        Task { @MainActor in
            // 用系统 Alert 显示(无 context 依赖)
            let alert = UIAlertController(title: "登录已失效", message: msg, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "重新登录", style: .default) { _ in
                AppCoordinator.shared.goLogin()
            })
            UIApplication.topViewController?.present(alert, animated: true)
        }
    }

    private static func handleUpdate(payload: [String: Any]?) {
        guard let payload else { return }
        let isForced = payload["force_update"] as? Bool ?? false
        let version  = payload["version"]      as? String ?? "新版本"
        let desc     = payload["description"]  as? String ?? "发现新版本,请更新。"
        let storeURL = payload["store_url"]    as? String ?? ""

        Task { @MainActor in
            let alert = UIAlertController(
                title: "发现新版本 \(version)",
                message: desc,
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "立即更新", style: .default) { _ in
                if let url = URL(string: storeURL) { UIApplication.shared.open(url) }
            })
            if !isForced {
                alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel))
            }
            UIApplication.topViewController?.present(alert, animated: true)
        }
    }

    private static func showRealNameSheet() {
        Task { @MainActor in
            AppCoordinator.shared.presentVerification()
        }
    }

    private static func showDailyLimitDialog() {
        Task { @MainActor in
            let alert = UIAlertController(
                title: "今日收取已达上限",
                message: "你今天已经翻了好多信号啦\n休息一下,明日 0:00 限制将自动重置",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "去聊天", style: .default) { _ in
                AppCoordinator.shared.goMessages()
            })
            alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel))
            UIApplication.topViewController?.present(alert, animated: true)
        }
    }
}
```

---

## 七、APIClient(核心请求 Actor)

```swift
// APIClient.swift
actor APIClient {
    static let shared = APIClient()

    private let session: URLSession
    private let decoder: JSONDecoder

    private init() {
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest  = 15
        config.timeoutIntervalForResource = 15
        session = URLSession(configuration: config)

        decoder = JSONDecoder()
        decoder.keyDecodingStrategy  = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .secondsSince1970
    }

    // ── 公开接口 ──────────────────────────────────────────────────────

    func get<T: Decodable>(_ path: String,
                           params: [String: Any]? = nil,
                           as type: T.Type) async throws -> T {
        let req = try buildRequest(method: "GET", path: path, params: params, body: nil)
        return try await execute(req, as: type)
    }

    func post<T: Decodable>(_ path: String,
                            body: (any Encodable)? = nil,
                            as type: T.Type) async throws -> T {
        let bodyData = try body.map { try JSONEncoder().encode($0) }
        let req      = try buildRequest(method: "POST", path: path, params: nil, body: bodyData)
        return try await execute(req, as: type)
    }

    @discardableResult
    func post(_ path: String, body: (any Encodable)? = nil) async throws -> Any? {
        let bodyData = try body.map { try JSONEncoder().encode($0) }
        let req      = try buildRequest(method: "POST", path: path, params: nil, body: bodyData)
        return try await executeRaw(req)
    }

    // ── 执行层(含 Token 刷新重试) ────────────────────────────────────

    private func execute<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
        let raw = try await executeRaw(request)
        // 将 Any? 重新序列化后解码为目标类型
        let data = try JSONSerialization.data(withJSONObject: raw ?? NSNull())
        return try decoder.decode(T.self, from: data)
    }

    private func executeRaw(_ request: URLRequest) async throws -> Any? {
        do {
            let (data, response) = try await session.data(for: request)
            return try await ResponseHandler.handle(data: data, response: response)
        } catch let err as AppAPIError where err.isTokenExpired {
            // Token 过期 → 刷新后重试一次
            let refreshed = await TokenRefresher.shared.refreshIfNeeded()
            guard refreshed else {
                await AuthService.shared.forceLogout()
                throw err
            }
            // 用新 token 重新签名后重试
            var retryReq = request
            RequestSigner.sign(request: &retryReq, body: request.httpBody)
            let (data, response) = try await session.data(for: retryReq)
            return try await ResponseHandler.handle(data: data, response: response)
        } catch let urlErr as URLError {
            // 网络层错误统一封装
            throw AppAPIError(code: -1, message: urlErrorMessage(urlErr))
        }
    }

    // ── 构建请求 ───────────────────────────────────────────────────────

    private func buildRequest(method: String,
                              path: String,
                              params: [String: Any]?,
                              body: Data?) throws -> URLRequest {
        var urlStr = "\(AppConfig.apiBaseURL)/\(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))"

        if method == "GET", let params, !params.isEmpty {
            var comps = URLComponents(string: urlStr)!
            comps.queryItems = params.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
            urlStr = comps.url!.absoluteString
        }

        var request = URLRequest(url: URL(string: urlStr)!)
        request.httpMethod = method
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = body

        RequestSigner.sign(request: &request, body: body)
        return request
    }

    private func urlErrorMessage(_ err: URLError) -> String {
        switch err.code {
        case .timedOut:            return "网络请求超时"
        case .notConnectedToInternet,
             .networkConnectionLost: return "网络连接失败,请检查网络设置"
        default:                   return "未知网络错误"
        }
    }
}
```

---

## 八、UploadClient(文件上传 + 批量签名 URL)

```swift
// UploadClient.swift
struct UploadResult: Decodable, Sendable {
    let objectKey: String   // OSS key,私有文件需批量签名后才能展示
    let url: String?        // 公开文件直接可用的 URL(头像等)
}

actor UploadClient {
    static let shared = UploadClient()

    private let session = URLSession.shared

    // ── POST /upload/file ──────────────────────────────────────────────
    /// type: "avatar" | "signal_image" | "signal_audio" | "chat_image" | "chat_audio" | "chat_multi"
    func uploadFile(
        at fileURL: URL,
        type: String,
        onProgress: ((Double) -> Void)? = nil
    ) async throws -> UploadResult {
        let boundary = "TB-\(UUID().uuidString)"
        var request  = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/upload/file")!)
        request.httpMethod = "POST"
        request.setValue("multipart/form-data; boundary=\(boundary)",
                         forHTTPHeaderField: "Content-Type")

        // 构建 multipart body
        var body = Data()
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"type\"\r\n\r\n".data(using: .utf8)!)
        body.append("\(type)\r\n".data(using: .utf8)!)

        let fileData = try Data(contentsOf: fileURL)
        let mime     = mimeType(for: fileURL)
        let filename = fileURL.lastPathComponent
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
        body.append("Content-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
        body.append(fileData)
        body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)

        request.httpBody = body
        RequestSigner.sign(request: &request, body: nil) // multipart 不参与签名 body

        let (data, _) = try await session.data(for: request)
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
        guard json?["code"] as? Int == 0,
              let payload = json?["data"] as? [String: Any],
              let objectKey = payload["object_key"] as? String else {
            let msg = json?["msg"] as? String ?? "上传失败"
            throw AppAPIError(code: json?["code"] as? Int ?? -1, message: msg)
        }
        return UploadResult(objectKey: objectKey, url: payload["url"] as? String)
    }

    // ── POST /upload/oss/sign ──────────────────────────────────────────
    /// 批量获取私有文件签名 URL,返回 [objectKey: signedURL]
    func batchSignURLs(_ objectKeys: [String]) async throws -> [String: String] {
        guard !objectKeys.isEmpty else { return [:] }

        let boundary = "TB-\(UUID().uuidString)"
        var request  = URLRequest(url: URL(string: "\(AppConfig.apiBaseURL)/upload/oss/sign")!)
        request.httpMethod = "POST"
        request.setValue("multipart/form-data; boundary=\(boundary)",
                         forHTTPHeaderField: "Content-Type")

        var body = Data()
        for key in objectKeys {
            body.append("--\(boundary)\r\n".data(using: .utf8)!)
            body.append("Content-Disposition: form-data; name=\"objects[]\"\r\n\r\n".data(using: .utf8)!)
            body.append("\(key)\r\n".data(using: .utf8)!)
        }
        body.append("--\(boundary)--\r\n".data(using: .utf8)!)
        request.httpBody = body
        RequestSigner.sign(request: &request, body: nil)

        let (data, _) = try await session.data(for: request)
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
        guard json?["code"] as? Int == 0,
              let payload  = json?["data"] as? [String: Any],
              let urlsDict = payload["urls"] as? [String: String?] else {
            return [:]
        }
        // 过滤空值(已删除文件)
        return urlsDict.compactMapValues { $0.flatMap { $0.isEmpty ? nil : $0 } }
    }

    private func mimeType(for url: URL) -> String {
        switch url.pathExtension.lowercased() {
        case "jpg", "jpeg": return "image/jpeg"
        case "png":         return "image/png"
        case "webp":        return "image/webp"
        case "m4a":         return "audio/mp4"
        case "mp3":         return "audio/mpeg"
        default:            return "application/octet-stream"
        }
    }
}
```

---

## 九、媒体管线(PHPicker → Crop → Compress → Upload)

```swift
// MediaPipeline.swift
// 图片完整链路:选图 → 裁剪 → 压缩 → 上传 → 返回 objectKey
struct MediaPipeline {

    // ── 头像上传(1:1 强制裁剪)──────────────────────────────────────
    @MainActor
    static func pickAndUploadAvatar(
        from viewController: UIViewController
    ) async throws -> UploadResult {
        let image   = try await pickImage(from: viewController, ratio: 1.0 / 1.0)
        let cropped = try await cropImage(image, ratio: 1.0 / 1.0, from: viewController)
        let fileURL = try compress(cropped, maxSize: 1024 * 1024, quality: 0.85) // 1MB
        return try await UploadClient.shared.uploadFile(at: fileURL, type: "avatar")
    }

    // ── 信号图片(不强制裁剪)───────────────────────────────────────
    @MainActor
    static func pickAndUploadSignalImage(
        from viewController: UIViewController
    ) async throws -> UploadResult {
        let image   = try await pickImage(from: viewController, ratio: nil)
        let fileURL = try compress(image, maxSize: 2 * 1024 * 1024, quality: 0.8)
        return try await UploadClient.shared.uploadFile(at: fileURL, type: "signal_image")
    }

    // ── 语音上传(录制完成后直接传文件路径)─────────────────────────
    static func uploadAudio(at fileURL: URL, type: String = "chat_audio") async throws -> UploadResult {
        return try await UploadClient.shared.uploadFile(at: fileURL, type: type)
    }

    // ── 内部工具 ─────────────────────────────────────────────────────

    @MainActor
    private static func pickImage(
        from vc: UIViewController,
        ratio: CGFloat?
    ) async throws -> UIImage {
        return try await withCheckedThrowingContinuation { continuation in
            var config = PHPickerConfiguration(photoLibrary: .shared())
            config.selectionLimit = 1
            config.filter         = .images
            let picker = PHPickerViewController(configuration: config)
            // 通过 Delegate 桥接回调
            let delegate = PHPickerDelegate { result in
                Task {
                    if let result {
                        continuation.resume(returning: result)
                    } else {
                        continuation.resume(throwing: AppAPIError(code: -2, message: "未选择图片"))
                    }
                }
            }
            picker.delegate = delegate
            vc.present(picker, animated: true)
        }
    }

    @MainActor
    private static func cropImage(
        _ image: UIImage,
        ratio: CGFloat,
        from vc: UIViewController
    ) async throws -> UIImage {
        return try await withCheckedThrowingContinuation { continuation in
            let cropVC = TOCropViewController(croppingStyle: .default, image: image)
            cropVC.aspectRatioPreset    = .presetSquare
            cropVC.aspectRatioLockEnabled = true
            cropVC.didCropToRect = { croppedImage, _, _ in
                continuation.resume(returning: croppedImage)
            }
            cropVC.didFinishCancelled = { _ in
                continuation.resume(throwing: AppAPIError(code: -2, message: "取消裁剪"))
            }
            vc.present(cropVC, animated: true)
        }
    }

    private static func compress(
        _ image: UIImage,
        maxSize: Int,
        quality: CGFloat
    ) throws -> URL {
        var data   = image.jpegData(compressionQuality: quality) ?? Data()
        var q      = quality
        while data.count > maxSize && q > 0.1 {
            q   -= 0.1
            data = image.jpegData(compressionQuality: q) ?? data
        }
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("\(UUID().uuidString).jpg")
        try data.write(to: url)
        return url
    }
}
```

---

## 十、使用示例

```swift
// ViewModel 中的典型用法
@MainActor
@Observable
final class SignalPublishViewModel {
    var uploadProgress: Double = 0
    var isUploading = false

    func publishSignal(category: Int, content: String, image: URL?) async throws {
        isUploading = true
        defer { isUploading = false }

        var attachment: SignalAttachment? = nil
        if let imageURL = image {
            let result = try await UploadClient.shared.uploadFile(
                at: imageURL, type: "signal_image"
            )
            attachment = SignalAttachment(objectKey: result.objectKey, mime: "image/jpeg")
        }

        let req = SignalPublishRequest(category: category, content: content, attachment: attachment)
        try await APIClient.shared.post("/signal/publish", body: req)
    }
}
```

---

## 十一、UIApplication 辅助扩展

```swift
// 用于 ResponseHandler 弹窗
extension UIApplication {
    static var topViewController: UIViewController? {
        let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
        return scenes.first?.windows.first { $0.isKeyWindow }?.rootViewController?.topmost
    }
}

extension UIViewController {
    var topmost: UIViewController {
        if let presented = presentedViewController { return presented.topmost }
        if let nav = self as? UINavigationController { return nav.visibleViewController?.topmost ?? self }
        if let tab = self as? UITabBarController { return tab.selectedViewController?.topmost ?? self }
        return self
    }
}
```