added ios module

This commit is contained in:
2026-08-17 20:13:28 +08:00
parent a8031e2e3b
commit 7c81976b2b
27 changed files with 2767 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import CryptoKit
import Foundation
enum ImitationAssessorError: LocalizedError {
case invalidHash
case cannotReadRecording
var errorDescription: String? {
switch self {
case .invalidHash:
return "视频 id 必须是 64 位 SHA-256 十六进制字符串"
case .cannotReadRecording:
return "无法读取录音文件"
}
}
}
enum ImitationAssessor {
/// SHA-256
static func sha256Hex(of fileURL: URL) throws -> String {
let handle: FileHandle
do {
handle = try FileHandle(forReadingFrom: fileURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? handle.close() }
var hasher = SHA256()
while true {
guard let data = try? handle.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// SHA-256
static func copyAndHash(from source: URL, to destination: URL) throws -> String {
let input: FileHandle
do {
input = try FileHandle(forReadingFrom: source)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
defer { try? input.close() }
try? FileManager.default.removeItem(at: destination)
FileManager.default.createFile(atPath: destination.path, contents: nil)
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
var hasher = SHA256()
while true {
guard let data = try input.read(upToCount: 1 << 20), !data.isEmpty else {
break
}
hasher.update(data: data)
try output.write(contentsOf: data)
}
try output.synchronize()
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
/// MOSS multipart/form-data
static func assess(request: ImitationAssessmentRequest) async throws -> ImitationAssessmentResult {
let hash = request.mediaId.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw ImitationAssessorError.invalidHash
}
let path = "api/v1/videos/\(hash)/sentences/\(request.sentence.index)/assessments"
let boundary = "----OralTrainer-\(UUID().uuidString)"
var body = Data()
appendField(&body, boundary: boundary, name: "language", value: request.locale ?? request.sentence.language ?? "")
let recordingData: Data
do {
recordingData = try Data(contentsOf: request.recordingURL)
} catch {
throw ImitationAssessorError.cannotReadRecording
}
let filename = request.recordingURL.lastPathComponent
let mimeType = request.recordingURL.pathExtension.lowercased() == "wav" ? "audio/wav" : "audio/m4a"
appendFile(&body, boundary: boundary, name: "audio", filename: filename, mimeType: mimeType, data: recordingData)
body.append(Data("--\(boundary)--\r\n".utf8))
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "POST",
timeout: AppConfig.assessmentTimeout,
headers: [
"Accept": "application/json",
"Content-Type": "multipart/form-data; boundary=\(boundary)",
"X-Client-Key": AppConfig.assessmentAPIKey,
],
body: body
)
guard (200...299).contains(result.statusCode) else {
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, responseBody)
}
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
}
private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
body.append(Data(value.utf8))
body.append(Data("\r\n".utf8))
}
private static func appendFile(
_ body: inout Data,
boundary: String,
name: String,
filename: String,
mimeType: String,
data: Data
) {
body.append(Data("--\(boundary)\r\n".utf8))
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".utf8))
body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8))
body.append(data)
body.append(Data("\r\n".utf8))
}
}