模块化 AppStore 并完善素材管理与个人空间设置。

将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 11:01:08 +08:00
parent ceca780ab3
commit 005349f8e6
128 changed files with 2953 additions and 1123 deletions

View File

@ -146,6 +146,77 @@ final class APIClient {
}
}
/// multipart/form-data
func sendMultipart<Response: Decodable>(
path: String,
fieldName: String = "file",
fileName: String,
mimeType: String,
data fileData: Data,
timeoutInterval: TimeInterval = 60,
tokenOverride: String? = nil
) async throws -> Response {
let apiPath = path.hasPrefix("/") ? path : "/" + path
guard let url = URL(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath
) else {
throw APIError.invalidURL
}
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append(
"Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n"
.data(using: .utf8)!
)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var request = URLRequest(url: url)
request.httpMethod = HTTPMethod.post.rawValue
request.timeoutInterval = timeoutInterval
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
request.setValue(String(body.count), forHTTPHeaderField: "Content-Length")
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
request.httpBody = body
logRequest(request)
let responseData: Data
let response: URLResponse
do {
(responseData, response) = try await session.data(for: request)
} catch is CancellationError {
logCancelled(for: request, reason: "CancellationError")
throw CancellationError()
} catch let error as URLError {
if error.code == .cancelled {
logCancelled(for: request, reason: "URLError.cancelled")
throw CancellationError()
}
throw APIError.networkFailed(networkErrorMessage(for: error))
} catch {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: responseData)
do {
try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
throw error
}
}
/// URLRequest Headertoken
private func makeURLRequest<Response: Decodable>(
_ apiRequest: APIRequest<Response>,

View File

@ -316,7 +316,7 @@ enum OSSUploadPolicy {
case "material":
return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "punch_point":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId
return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
case "scenic_apply":
@ -326,7 +326,7 @@ enum OSSUploadPolicy {
case "alive_album":
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "cloud_driver":
let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let ownerId = userId.isEmpty ? String(scenicId) : userId
let month = String(currentDate.dropFirst(4).prefix(2))
#if DEBUG