added ios module
This commit is contained in:
133
ios/OralTrainer/Services/HTTPClient.swift
Normal file
133
ios/OralTrainer/Services/HTTPClient.swift
Normal file
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
enum HTTPClientError: LocalizedError {
|
||||
case invalidURL
|
||||
case badStatus(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
return "无效的服务地址"
|
||||
case .badStatus(let code, let body):
|
||||
return "HTTP \(code): \(body)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPResult {
|
||||
let statusCode: Int
|
||||
let body: Data
|
||||
let effectiveBaseURL: String
|
||||
}
|
||||
|
||||
enum HTTPClient {
|
||||
static func getJSON<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
baseURL: String,
|
||||
path: String,
|
||||
timeout: TimeInterval
|
||||
) async throws -> (value: T, effectiveBaseURL: String) {
|
||||
let result = try await execute(
|
||||
baseURL: baseURL,
|
||||
path: path,
|
||||
method: "GET",
|
||||
timeout: timeout,
|
||||
headers: ["Accept": "application/json"]
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let body = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, body)
|
||||
}
|
||||
let value = try JSONDecoder().decode(T.self, from: result.body)
|
||||
return (value, result.effectiveBaseURL)
|
||||
}
|
||||
|
||||
static func execute(
|
||||
baseURL: String,
|
||||
path: String,
|
||||
method: String,
|
||||
timeout: TimeInterval,
|
||||
headers: [String: String] = [:],
|
||||
body: Data? = nil
|
||||
) async throws -> HTTPResult {
|
||||
let trimmedBase = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard let base = URL(string: trimmedBase), base.scheme != nil, base.host != nil else {
|
||||
throw HTTPClientError.invalidURL
|
||||
}
|
||||
let endpoint = URL(string: path, relativeTo: base) ?? base
|
||||
do {
|
||||
return try await perform(
|
||||
url: endpoint,
|
||||
method: method,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
body: body,
|
||||
effectiveBaseURL: trimmedBase
|
||||
)
|
||||
} catch {
|
||||
// 与安卓端一致:HTTPS 因 TLS 问题失败时,降级用同一请求重试 HTTP。
|
||||
guard AppConfig.allowHTTPFallback,
|
||||
endpoint.scheme == "https",
|
||||
isTLSError(error) else {
|
||||
throw error
|
||||
}
|
||||
var baseComponents = URLComponents(string: trimmedBase)
|
||||
baseComponents?.scheme = "http"
|
||||
guard let httpBase = baseComponents?.string,
|
||||
let httpURL = URL(string: path, relativeTo: URL(string: httpBase)) else {
|
||||
throw error
|
||||
}
|
||||
return try await perform(
|
||||
url: httpURL,
|
||||
method: method,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
body: body,
|
||||
effectiveBaseURL: httpBase
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func perform(
|
||||
url: URL,
|
||||
method: String,
|
||||
timeout: TimeInterval,
|
||||
headers: [String: String],
|
||||
body: Data?,
|
||||
effectiveBaseURL: String
|
||||
) async throws -> HTTPResult {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = timeout
|
||||
request.setValue(AppConfig.userAgent, forHTTPHeaderField: "User-Agent")
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
request.httpBody = body
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw HTTPClientError.invalidURL
|
||||
}
|
||||
return HTTPResult(statusCode: http.statusCode, body: data, effectiveBaseURL: effectiveBaseURL)
|
||||
}
|
||||
|
||||
private static func isTLSError(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
let tlsCodes: [Int] = [
|
||||
NSURLErrorSecureConnectionFailed,
|
||||
NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasUnknownRoot,
|
||||
NSURLErrorServerCertificateNotYetValid,
|
||||
NSURLErrorClientCertificateRejected,
|
||||
NSURLErrorClientCertificateRequired,
|
||||
NSURLErrorCannotLoadFromNetwork,
|
||||
]
|
||||
if tlsCodes.contains(nsError.code) {
|
||||
return true
|
||||
}
|
||||
let message = (nsError.localizedDescription + " " + (nsError.localizedFailureReason ?? ""))
|
||||
.lowercased()
|
||||
return message.contains("ssl") || message.contains("tls") || message.contains("certificate")
|
||||
}
|
||||
}
|
||||
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal file
124
ios/OralTrainer/Services/ImitationAssessor.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal file
34
ios/OralTrainer/Services/SentenceBoundaryAPI.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
enum SentenceBoundaryAPIError: LocalizedError {
|
||||
case invalidHash
|
||||
case hashMismatch
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHash:
|
||||
return "video_hash 必须是 64 位 SHA-256 十六进制字符串"
|
||||
case .hashMismatch:
|
||||
return "服务端返回的句边界哈希与请求不一致"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SentenceBoundaryAPI {
|
||||
static func fetch(videoHash: String) async throws -> SentenceBoundaryDocument {
|
||||
let hash = videoHash.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw SentenceBoundaryAPIError.invalidHash
|
||||
}
|
||||
let result = try await HTTPClient.getJSON(
|
||||
SentenceBoundaryDocument.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos/\(hash)/sentence-boundaries",
|
||||
timeout: AppConfig.readTimeout
|
||||
)
|
||||
guard result.value.videoHash.lowercased() == hash else {
|
||||
throw SentenceBoundaryAPIError.hashMismatch
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal file
18
ios/OralTrainer/Services/VideoCatalogAPI.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct VideoCatalogResponse: Decodable {
|
||||
let videos: [TrainingVideoSummary]
|
||||
}
|
||||
|
||||
enum VideoCatalogAPI {
|
||||
static func fetch() async throws -> (videos: [TrainingVideoSummary], baseURL: String) {
|
||||
let result = try await HTTPClient.getJSON(
|
||||
VideoCatalogResponse.self,
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/videos",
|
||||
timeout: AppConfig.readTimeout
|
||||
)
|
||||
let ready = result.value.videos.filter { $0.status == "ready" }
|
||||
return (ready, result.effectiveBaseURL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user