225 lines
8.3 KiB
Swift
225 lines
8.3 KiB
Swift
//
|
||
// UploadService.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import Foundation
|
||
import RxSwift
|
||
import SwiftyJSON
|
||
import CommonCrypto
|
||
|
||
enum UploadFileKind {
|
||
case jpeg
|
||
case mp4
|
||
case wav
|
||
|
||
var fileExtension: String {
|
||
switch self {
|
||
case .jpeg: return "jpg"
|
||
case .mp4: return "mp4"
|
||
case .wav: return "wav"
|
||
}
|
||
}
|
||
|
||
var mimeType: String {
|
||
switch self {
|
||
case .jpeg: return "image/jpeg"
|
||
case .mp4: return "video/mp4"
|
||
case .wav: return "audio/wav"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct UploadService {
|
||
|
||
private struct UploadedFile {
|
||
let id: String
|
||
let url: String
|
||
}
|
||
|
||
private static let boundary = "YLQH"
|
||
private static let jiaMiKey = "857d69d374694c1de46486d3bdaaac43"
|
||
|
||
static func upload(_ data: Data, kind: UploadFileKind, scene: String? = nil) -> Observable<String> {
|
||
uploadFile(data, kind: kind, scene: scene)
|
||
.map(\.id)
|
||
}
|
||
|
||
/// 上传后返回服务端提供的可访问地址,供需要提交 URL 的业务使用。
|
||
static func uploadURL(_ data: Data, kind: UploadFileKind, scene: String? = nil) -> Observable<String> {
|
||
uploadFile(data, kind: kind, scene: scene)
|
||
.map { file in
|
||
guard !file.url.isEmpty else {
|
||
throw makeError("上传成功但未返回文件地址")
|
||
}
|
||
return file.url
|
||
}
|
||
}
|
||
|
||
private static func uploadFile(
|
||
_ data: Data,
|
||
kind: UploadFileKind,
|
||
scene: String?
|
||
) -> Observable<UploadedFile> {
|
||
Observable.create { observer in
|
||
do {
|
||
let request = try makeRequest(fileData: data, kind: kind, scene: scene)
|
||
let session = URLSession(configuration: .default)
|
||
let task = session.uploadTask(with: request.request, from: request.body) { data, response, error in
|
||
if let error {
|
||
DispatchQueue.main.async {
|
||
observer.onError(error)
|
||
}
|
||
return
|
||
}
|
||
guard let data else {
|
||
DispatchQueue.main.async {
|
||
observer.onError(makeError("上传失败"))
|
||
}
|
||
return
|
||
}
|
||
let decrypted = ApiManager.decryptIfNeeded(data) ?? data
|
||
let json = JSON(decrypted)
|
||
let code = json["code"].intValue
|
||
guard code == 0 || json["success"].boolValue else {
|
||
let message = json["message"].string ?? "上传失败"
|
||
DispatchQueue.main.async {
|
||
observer.onError(makeError(message))
|
||
}
|
||
return
|
||
}
|
||
let nestedId = json["data"]["id"].stringValue
|
||
let nestedFileId = json["data"]["file_id"].stringValue
|
||
let nestedURL = json["data"]["url"].stringValue
|
||
let nestedFileURL = json["data"]["file_url"].stringValue
|
||
let dataString = json["data"].stringValue
|
||
let fileId: String? = {
|
||
if !nestedId.isEmpty { return nestedId }
|
||
if !nestedFileId.isEmpty { return nestedFileId }
|
||
if !dataString.isEmpty, json["data"].type != .dictionary { return dataString }
|
||
if !nestedURL.isEmpty { return nestedURL }
|
||
if !nestedFileURL.isEmpty { return nestedFileURL }
|
||
return nil
|
||
}()
|
||
guard let fileId, !fileId.isEmpty else {
|
||
DispatchQueue.main.async {
|
||
observer.onError(makeError("上传成功但未返回文件 id"))
|
||
}
|
||
return
|
||
}
|
||
let fileURL: String = {
|
||
if !nestedURL.isEmpty { return nestedURL }
|
||
if !nestedFileURL.isEmpty { return nestedFileURL }
|
||
if dataString.hasPrefix("http://") || dataString.hasPrefix("https://") {
|
||
return dataString
|
||
}
|
||
if fileId.hasPrefix("http://") || fileId.hasPrefix("https://") {
|
||
return fileId
|
||
}
|
||
return ""
|
||
}()
|
||
DispatchQueue.main.async {
|
||
observer.onNext(UploadedFile(id: fileId, url: fileURL))
|
||
observer.onCompleted()
|
||
}
|
||
}
|
||
task.resume()
|
||
return Disposables.create {
|
||
task.cancel()
|
||
}
|
||
} catch {
|
||
observer.onError(error)
|
||
return Disposables.create()
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func makeRequest(fileData: Data, kind: UploadFileKind, scene: String?) throws -> (request: URLRequest, body: Data) {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyyMMddHHmmss"
|
||
let fileName = "\(formatter.string(from: Date())).\(kind.fileExtension)"
|
||
let body = multipartBody(fileData: fileData, fileName: fileName, mimeType: kind.mimeType)
|
||
|
||
var query: [String: String] = [
|
||
"timestamp": String(format: "%.0lf", Date().timeIntervalSince1970),
|
||
"nonce": randomString(length: 32)
|
||
]
|
||
if let scene, !scene.isEmpty {
|
||
query["scene"] = scene
|
||
}
|
||
let queryString = sortedQueryString(query)
|
||
let signature = sign(queryString: queryString, body: body)
|
||
let signedQuery = queryString + "&signature=" + signature
|
||
let encodedQuery = signedQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? signedQuery
|
||
|
||
let base = AppNetworkConfig.shared.baseURL
|
||
guard let url = URL(string: base + "api/user/upload?" + encodedQuery) else {
|
||
throw makeError("上传地址无效")
|
||
}
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.timeoutInterval = 180
|
||
var headers = ApiManager.shared.httpHeader()
|
||
headers["Content-Type"] = "multipart/form-data; boundary=\(boundary)"
|
||
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
||
return (request, body)
|
||
}
|
||
|
||
private static func multipartBody(fileData: Data, fileName: String, mimeType: String) -> Data {
|
||
var data = Data()
|
||
func append(_ string: String) {
|
||
if let part = string.data(using: .utf8) {
|
||
data.append(part)
|
||
}
|
||
}
|
||
append("--\(boundary)\r\n")
|
||
append("Content-Disposition:form-data; name=\"file\"; filename=\"\(fileName)\"\r\n")
|
||
append("Content-Type:\(mimeType)\r\n")
|
||
append("\r\n")
|
||
data.append(fileData)
|
||
append("\r\n")
|
||
append("--\(boundary)--\r\n")
|
||
return data
|
||
}
|
||
|
||
private static func sign(queryString: String, body: Data) -> String {
|
||
var combined = Data()
|
||
combined.append(Data((queryString + "&").utf8))
|
||
combined.append(body)
|
||
combined.append(Data(("&" + md5(jiaMiKey)).utf8))
|
||
return md5(combined)
|
||
}
|
||
|
||
private static func sortedQueryString(_ dict: [String: String]) -> String {
|
||
dict.sorted { $0.key < $1.key }
|
||
.map { "\($0.key)=\($0.value)" }
|
||
.joined(separator: "&")
|
||
}
|
||
|
||
private static func randomString(length: Int) -> String {
|
||
let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||
return String((0..<length).map { _ in chars.randomElement()! })
|
||
}
|
||
|
||
private static func md5(_ string: String) -> String {
|
||
md5(Data(string.utf8))
|
||
}
|
||
|
||
private static func md5(_ data: Data) -> String {
|
||
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||
data.withUnsafeBytes { buffer in
|
||
_ = CC_MD5(buffer.baseAddress, CC_LONG(data.count), &digest)
|
||
}
|
||
return digest.map { String(format: "%02x", $0) }.joined()
|
||
}
|
||
|
||
private static func makeError(_ message: String) -> NSError {
|
||
NSError(
|
||
domain: "UploadService",
|
||
code: -1,
|
||
userInfo: [NSLocalizedDescriptionKey: message]
|
||
)
|
||
}
|
||
}
|