From 9230db3b696e63ba71d26874aa027ffcb292cefc Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Mon, 7 Sep 2026 11:42:53 +0800 Subject: [PATCH] fixed some bugs --- ios/OralTrainer.xcodeproj/project.pbxproj | 8 + .../UserInterfaceState.xcuserstate | Bin 22578 -> 22797 bytes ios/OralTrainer/Config.swift | 3 + .../Services/TrainingHistoryStore.swift | 167 +++++++++++++++ ios/OralTrainer/Services/UserHistory.swift | 177 ++++++++++++++++ ios/OralTrainer/Views/AuthView.swift | 21 +- ios/OralTrainer/Views/RootView.swift | 197 +++++++++++++++++- ios/OralTrainer/Views/TestView.swift | 193 +++++++++++++++-- sentence_api/README.md | 1 + sentence_api/main.py | 24 +++ sentence_api/repository.py | 41 ++++ sentence_api/tests/test_assessment_api.py | 65 ++++++ 12 files changed, 883 insertions(+), 14 deletions(-) create mode 100644 ios/OralTrainer/Services/TrainingHistoryStore.swift create mode 100644 ios/OralTrainer/Services/UserHistory.swift diff --git a/ios/OralTrainer.xcodeproj/project.pbxproj b/ios/OralTrainer.xcodeproj/project.pbxproj index 80ecbd3..d07f68b 100644 --- a/ios/OralTrainer.xcodeproj/project.pbxproj +++ b/ios/OralTrainer.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ AA0000000000000000000090 /* AuthAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000091 /* AuthAPI.swift */; }; + AA00000000000000000000A1 /* UserHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A2 /* UserHistory.swift */; }; + AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A4 /* TrainingHistoryStore.swift */; }; AA0000000000000000000092 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000093 /* AuthView.swift */; }; AA0000000000000000000040 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000020 /* Assets.xcassets */; }; AA0000000000000000000042 /* OralTrainerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000022 /* OralTrainerApp.swift */; }; @@ -36,6 +38,8 @@ /* Begin PBXFileReference section */ AA0000000000000000000091 /* AuthAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAPI.swift; sourceTree = ""; }; + AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = ""; }; + AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = ""; }; AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; AA000000000000000000000A /* OralTrainer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OralTrainer.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA000000000000000000001F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -124,6 +128,8 @@ AA0000000000000000000091 /* AuthAPI.swift */, AA000000000000000000002A /* SentenceBoundaryAPI.swift */, AA000000000000000000002B /* ImitationAssessor.swift */, + AA00000000000000000000A4 /* TrainingHistoryStore.swift */, + AA00000000000000000000A2 /* UserHistory.swift */, AA000000000000000000007E /* DubbingCompositor.swift */, AA0000000000000000000081 /* AudioRecorderSession.swift */, ); @@ -255,6 +261,8 @@ AA0000000000000000000051 /* PlayerSectionView.swift in Sources */, AA0000000000000000000052 /* CatalogView.swift in Sources */, AA0000000000000000000090 /* AuthAPI.swift in Sources */, + AA00000000000000000000A1 /* UserHistory.swift in Sources */, + AA00000000000000000000A3 /* TrainingHistoryStore.swift in Sources */, AA0000000000000000000092 /* AuthView.swift in Sources */, AA0000000000000000000053 /* TestView.swift in Sources */, AA0000000000000000000054 /* FullscreenPlayerView.swift in Sources */, diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 4ba8701dceffb0f8005cf344233d2dead6dc6a09..a8a8d2233ef8610960debb943bfa6f5be3577cf6 100644 GIT binary patch delta 10178 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 delta 10250 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 QG~N8<8k(Oi_x$t!2Py_s?*IS* diff --git a/ios/OralTrainer/Config.swift b/ios/OralTrainer/Config.swift index ee12455..30ba6c6 100644 --- a/ios/OralTrainer/Config.swift +++ b/ios/OralTrainer/Config.swift @@ -20,4 +20,7 @@ enum AppConfig { /// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。 static var authToken: String = "" + + /// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。 + static var currentUserId: String = "" } diff --git a/ios/OralTrainer/Services/TrainingHistoryStore.swift b/ios/OralTrainer/Services/TrainingHistoryStore.swift new file mode 100644 index 0000000..07cf67c --- /dev/null +++ b/ios/OralTrainer/Services/TrainingHistoryStore.swift @@ -0,0 +1,167 @@ +import Foundation + +struct PersistedRecording { + let audioURL: URL + let rawURL: URL +} + +struct TrainingHistorySnapshot { + var audioSegments: [Int: URL] = [:] + var rawSegments: [Int: URL] = [:] + var scores: [Int: ImitationAssessmentResult] = [:] +} + +@MainActor +final class TrainingHistoryManager: ObservableObject { + @Published private(set) var historyVersion = 0 + let store = TrainingHistoryStore() + + func notifyHistoryChanged() { + historyVersion += 1 + } +} + +final class TrainingHistoryStore { + private let root: URL + + init() { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + root = documents.appendingPathComponent("TrainingHistory", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func normalizedVideoHash(_ videoHash: String) -> String { + let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) } + return String(String.UnicodeScalarView(filtered)) + } + + private var userDirectory: URL { + let identifier = normalizedVideoHash(AppConfig.currentUserId) + return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true) + } + + func load(videoHash: String) -> TrainingHistorySnapshot { + let directory = courseDirectory(videoHash) + var snapshot = TrainingHistorySnapshot() + for index in 0..<500 { + let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index)) + let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index)) + if FileManager.default.fileExists(atPath: audio.path) { + snapshot.audioSegments[index] = audio + } + if FileManager.default.fileExists(atPath: raw.path) { + snapshot.rawSegments[index] = raw + } + } + + let scoreFile = directory.appendingPathComponent("scores.json") + if let data = try? Data(contentsOf: scoreFile), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for (key, value) in object { + guard let index = Int(key), index >= 0 else { continue } + if let payload = value as? [String: Any], + let payloadData = try? JSONSerialization.data(withJSONObject: payload), + let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) { + snapshot.scores[index] = score + } + } + } + return snapshot + } + + func saveSegment( + videoHash: String, + sentenceIndex: Int, + audioURL: URL, + rawURL: URL + ) throws -> PersistedRecording { + let directory = courseDirectory(videoHash) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let savedAudio = directory.appendingPathComponent( + String(format: "segment-%04d.m4a", sentenceIndex) + ) + let savedRaw = directory.appendingPathComponent( + String(format: "raw-%04d.caf", sentenceIndex) + ) + try? FileManager.default.removeItem(at: savedAudio) + try? FileManager.default.removeItem(at: savedRaw) + try FileManager.default.moveItem(at: audioURL, to: savedAudio) + try FileManager.default.moveItem(at: rawURL, to: savedRaw) + return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw) + } + + func audioDestination(videoHash: String, sentenceIndex: Int) -> URL { + courseDirectory(videoHash).appendingPathComponent( + String(format: "segment-%04d.m4a", sentenceIndex) + ) + } + + func saveScores( + videoHash: String, + scores: [Int: ImitationAssessmentResult] + ) { + let directory = courseDirectory(videoHash) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var object: [String: Any] = [:] + for (index, score) in scores { + object[String(index)] = Self.payload(score) + } + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try data.write( + to: directory.appendingPathComponent("scores.json"), + options: [.atomic] + ) + } catch { + // History persistence is best-effort; the server remains the source of record. + } + } + + func removeScore(videoHash: String, sentenceIndex: Int) { + var snapshot = load(videoHash: videoHash) + snapshot.scores[sentenceIndex] = nil + saveScores(videoHash: videoHash, scores: snapshot.scores) + } + + func removeAllAudio(videoHash: String) { + let snapshot = load(videoHash: videoHash) + for url in snapshot.audioSegments.values { + try? FileManager.default.removeItem(at: url) + } + for url in snapshot.rawSegments.values { + try? FileManager.default.removeItem(at: url) + } + } + + private func courseDirectory(_ videoHash: String) -> URL { + userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true) + } + + private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] { + var value: [String: Any] = ["overall_score": score.overallScore] + if let item = score.passed { value["passed"] = item } + if let item = score.passScore { value["pass_score"] = item } + if let item = score.contentScore { value["content_score"] = item } + if let item = score.completenessScore { value["completeness_score"] = item } + if let item = score.fluencyScore { value["fluency_score"] = item } + if let item = score.pronunciationScore { value["pronunciation_score"] = item } + if let item = score.prosodyScore { value["prosody_score"] = item } + if let item = score.durationScore { value["duration_score"] = item } + if let item = score.pauseScore { value["pause_score"] = item } + if let item = score.speechRateScore { value["speech_rate_score"] = item } + if let item = score.referenceText { value["reference_text"] = item } + if let item = score.recognizedText { value["recognized_text"] = item } + if let item = score.durationRatio { value["duration_ratio"] = item } + if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens } + if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens } + if !score.substitutions.isEmpty { + value["substitutions"] = score.substitutions.map { + ["expected": $0.expected, "actual": $0.actual] + } + } + if let item = score.feedback { value["feedback"] = item } + if !score.details.isEmpty { value["details"] = score.details } + return value + } +} diff --git a/ios/OralTrainer/Services/UserHistory.swift b/ios/OralTrainer/Services/UserHistory.swift new file mode 100644 index 0000000..39d1c0b --- /dev/null +++ b/ios/OralTrainer/Services/UserHistory.swift @@ -0,0 +1,177 @@ +import Foundation + +struct UserResultSummary: Identifiable, Decodable { + let attemptId: String + let videoHash: String + let courseTitle: String + let sentenceIndex: Int + let sentenceText: String + let overallScore: Float + let passed: Bool + let createdAt: String + + var id: String { attemptId } + + private enum CodingKeys: String, CodingKey { + case overallScore = "overall_score" + case courseTitle = "course_title" + case sentenceIndex = "sentence_index" + case sentenceText = "sentence_text" + case createdAt = "created_at" + case attemptId = "attempt_id" + case videoHash = "video_hash" + case passed + } +} + +struct UserDubShareSummary: Identifiable, Decodable { + let shareId: String + let videoHash: String + let courseTitle: String + let title: String + let segmentCount: Int + let averageScore: Float + let createdAt: String + + var id: String { shareId } + + private enum CodingKeys: String, CodingKey { + case averageScore = "average_score" + case courseTitle = "course_title" + case segmentCount = "segment_count" + case createdAt = "created_at" + case shareId = "share_id" + case videoHash = "video_hash" + case title + } +} + +struct DubShareSegmentDetail: Decodable { + let index: Int + let overallScore: Float + let recognizedText: String + let scoreDetails: [String: Float] + let audioURL: String + + private enum CodingKeys: String, CodingKey { + case overallScore = "overall_score" + case recognizedText = "recognized_text" + case scoreDetails = "score_details" + case audioURL = "audio_url" + case index + } +} + +struct DubShareDetail: Decodable { + let shareId: String + let videoHash: String + let title: String + let segments: [DubShareSegmentDetail] + + private enum CodingKeys: String, CodingKey { + case shareId = "share_id" + case videoHash = "video_hash" + case title + case segments + } +} + +enum UserHistoryError: LocalizedError { + case badStatus(Int, String) + + var errorDescription: String? { + switch self { + case .badStatus(let code, let body): + return "HTTP \(code): \(body.prefix(200))" + } + } +} + +enum UserHistoryAPI { + static func results(token: String) async throws -> [UserResultSummary] { + try await getJSON([UserResultSummary].self, path: "api/v1/me/results", token: token) + } + + static func dubShares(token: String) async throws -> [UserDubShareSummary] { + try await getJSON([UserDubShareSummary].self, path: "api/v1/me/dub-shares", token: token) + } + + static func dubShareDetail(token: String, shareId: String) async throws -> DubShareDetail { + try await getJSON( + DubShareDetail.self, + path: "api/v1/dub-shares/\(shareId)", + token: token + ) + } + + static func deleteResult(token: String, attemptId: String) async throws { + try await delete(path: "api/v1/me/results/\(attemptId)", token: token) + } + + static func deleteDubShare(token: String, shareId: String) async throws { + try await delete(path: "api/v1/me/dub-shares/\(shareId)", token: token) + } + + static func downloadAudio( + token: String, + audioPath: String, + destination: URL + ) async throws { + let result = try await HTTPClient.execute( + baseURL: AppConfig.serverBaseURL, + path: audioPath, + method: "GET", + timeout: AppConfig.readTimeout, + headers: authorizationHeaders(token: token) + ) + guard (200...299).contains(result.statusCode) else { + throw UserHistoryError.badStatus( + result.statusCode, + String(data: result.body, encoding: .utf8) ?? "" + ) + } + try? FileManager.default.removeItem(at: destination) + try result.body.write(to: destination, options: .atomic) + } + + private static func getJSON( + _ type: T.Type, + path: String, + token: String + ) async throws -> T { + let result = try await HTTPClient.execute( + baseURL: AppConfig.serverBaseURL, + path: path, + method: "GET", + timeout: AppConfig.readTimeout, + headers: authorizationHeaders(token: token) + ) + guard (200...299).contains(result.statusCode) else { + throw UserHistoryError.badStatus( + result.statusCode, + String(data: result.body, encoding: .utf8) ?? "" + ) + } + return try JSONDecoder().decode(T.self, from: result.body) + } + + private static func delete(path: String, token: String) async throws { + let result = try await HTTPClient.execute( + baseURL: AppConfig.serverBaseURL, + path: path, + method: "DELETE", + timeout: AppConfig.readTimeout, + headers: authorizationHeaders(token: token) + ) + guard (200...299).contains(result.statusCode) else { + throw UserHistoryError.badStatus( + result.statusCode, + String(data: result.body, encoding: .utf8) ?? "" + ) + } + } + + private static func authorizationHeaders(token: String) -> [String: String] { + token.isEmpty ? [:] : ["Authorization": "Bearer \(token)"] + } +} diff --git a/ios/OralTrainer/Views/AuthView.swift b/ios/OralTrainer/Views/AuthView.swift index 3860c3b..c6d04c1 100644 --- a/ios/OralTrainer/Views/AuthView.swift +++ b/ios/OralTrainer/Views/AuthView.swift @@ -6,15 +6,20 @@ import SwiftUI final class AuthManager: ObservableObject { @Published var token: String @Published var nickname: String + @Published var userID: String @Published var showingRegister = false @Published var showingSSO = false @Published var showingPhoneBinding = false @Published var ssoPendingToken: String? + @Published var ssoPendingUserId = "" init() { let defaults = UserDefaults.standard token = defaults.string(forKey: "oral_trainer_token") ?? "" nickname = defaults.string(forKey: "oral_trainer_nickname") ?? "" + let userID = defaults.string(forKey: "oral_trainer_user_id") ?? "" + AppConfig.currentUserId = userID + self.userID = userID AppConfig.authToken = token } @@ -24,22 +29,29 @@ final class AuthManager: ObservableObject { token = session.token nickname = session.user.nickname AppConfig.authToken = session.token + userID = session.user.id + AppConfig.currentUserId = session.user.id let defaults = UserDefaults.standard defaults.set(session.token, forKey: "oral_trainer_token") defaults.set(session.user.nickname, forKey: "oral_trainer_nickname") + defaults.set(session.user.id, forKey: "oral_trainer_user_id") } func logout() { token = "" nickname = "" AppConfig.authToken = "" + userID = "" + AppConfig.currentUserId = "" showingRegister = false showingSSO = false showingPhoneBinding = false ssoPendingToken = nil + ssoPendingUserId = "" let defaults = UserDefaults.standard defaults.removeObject(forKey: "oral_trainer_token") defaults.removeObject(forKey: "oral_trainer_nickname") + defaults.removeObject(forKey: "oral_trainer_user_id") } } @@ -357,6 +369,7 @@ struct SSOWebView: View { dismiss() if !result.phoneBound { auth.ssoPendingToken = result.token + auth.ssoPendingUserId = result.user.id auth.showingPhoneBinding = true } else { auth.saveSession(AuthSession(token: result.token, user: result.user)) @@ -499,7 +512,13 @@ struct PhoneBindingView: View { try await AuthAPI.ssoBindPhone(token: token, phone: cleaned) auth.showingPhoneBinding = false auth.ssoPendingToken = nil - auth.saveSession(AuthSession(token: token, user: AuthUser(id: "", username: "", nickname: ""))) + let userID = auth.ssoPendingUserId + auth.saveSession( + AuthSession( + token: token, + user: AuthUser(id: userID, username: "", nickname: "") + ) + ) dismiss() onLoginSuccess() } catch { diff --git a/ios/OralTrainer/Views/RootView.swift b/ios/OralTrainer/Views/RootView.swift index 65665b3..69798b3 100644 --- a/ios/OralTrainer/Views/RootView.swift +++ b/ios/OralTrainer/Views/RootView.swift @@ -6,6 +6,7 @@ struct RootView: View { @StateObject private var auth = AuthManager() @StateObject private var controller = PlayerController() @StateObject private var catalogVM = CatalogViewModel() + @StateObject private var historyManager = TrainingHistoryManager() @State private var activeModule: Module = .course @State private var continuousPlayback = false @State private var showImporter = false @@ -14,6 +15,11 @@ struct RootView: View { @State private var currentCourse: Course? @State private var courseStatusText = "正在同步" @State private var showCourseGuardToast = false + @State private var managedResults: [UserResultSummary] = [] + @State private var managedDubShares: [UserDubShareSummary] = [] + @State private var managementStatus = "" + @State private var managementIsLoading = false + @State private var deletingHistoryID: String? enum Module { case course @@ -49,7 +55,11 @@ struct RootView: View { onFullscreen: enterFullscreen ) case .test: - TestView(controller: controller, onFullscreen: enterFullscreen) + TestView( + controller: controller, + historyManager: historyManager, + onFullscreen: enterFullscreen + ) case .mine: minePage } @@ -276,10 +286,195 @@ struct RootView: View { .font(.system(size: 13)) .foregroundColor(Theme.textMuted) .padding(.horizontal, 16) + + HStack(spacing: 10) { + Button { + Task { await loadManagedHistory() } + } label: { + Text(managementIsLoading ? "刷新中..." : "刷新记录") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 14) + .frame(height: 36) + .background(Theme.buttonBlue) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .disabled(managementIsLoading) + + if !managementStatus.isEmpty { + Text(managementStatus) + .font(.system(size: 12)) + .foregroundColor(Theme.textMuted) + } + } + .padding(.horizontal, 16) + + historyGroup(title: "我的测试结果", isEmpty: managedResults.isEmpty) { + ForEach(managedResults) { row in + resultRow(row) + } + } + + historyGroup(title: "我的配音分享", isEmpty: managedDubShares.isEmpty) { + ForEach(managedDubShares) { row in + dubShareRow(row) + } + } + } + .padding(.bottom, 24) + } + .task(id: currentCourse?.videoHash) { + await loadManagedHistory() + } + } + + private func historyGroup( + title: String, + isEmpty: Bool, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 16) + if isEmpty { + Text("该课程暂无记录") + .font(.system(size: 13)) + .foregroundColor(Theme.textMuted) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal, 16) + } else { + LazyVStack(spacing: 8, content: content) + .padding(.horizontal, 16) } } } + private func resultRow(_ row: UserResultSummary) -> some View { + historyRow( + title: "第 \(row.sentenceIndex + 1) 句 · \(String(format: "%.0f", row.overallScore)) 分", + subtitle: "\(row.passed ? "通过" : "未通过") · \(displayDate(row.createdAt))\n\(row.sentenceText)", + isDeleting: deletingHistoryID == row.id, + deleteLabel: "删除" + ) { + await deleteResult(row) + } + } + + private func dubShareRow(_ row: UserDubShareSummary) -> some View { + historyRow( + title: row.title, + subtitle: "\(row.segmentCount) 句 · 平均 \(String(format: "%.0f", row.averageScore)) 分 · \(displayDate(row.createdAt))", + isDeleting: deletingHistoryID == row.id, + deleteLabel: "删除" + ) { + await deleteDubShare(row) + } + } + + private func historyRow( + title: String, + subtitle: String, + isDeleting: Bool, + deleteLabel: String, + action: @escaping () async -> Void + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 15, weight: .bold)) + .foregroundColor(Theme.textDark) + Text(subtitle) + .font(.system(size: 12)) + .foregroundColor(Theme.textSubtle) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button { + Task { await action() } + } label: { + Text(isDeleting ? "删除中..." : deleteLabel) + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .frame(height: 28) + .background(isDeleting ? Theme.border : Theme.danger) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .disabled(isDeleting) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surface) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Theme.lightBorder, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private func loadManagedHistory() async { + guard let course = currentCourse else { + managementStatus = "请先选择课程" + managedResults = [] + managedDubShares = [] + return + } + managementIsLoading = true + managementStatus = "正在加载..." + defer { managementIsLoading = false } + do { + async let resultsTask = UserHistoryAPI.results(token: auth.token) + async let sharesTask = UserHistoryAPI.dubShares(token: auth.token) + let (allResults, allShares) = try await (resultsTask, sharesTask) + managedResults = allResults.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame } + managedDubShares = allShares.filter { $0.videoHash.caseInsensitiveCompare(course.videoHash) == .orderedSame } + managementStatus = "共 \(managedResults.count) 条测试、\(managedDubShares.count) 条配音" + } catch { + managementStatus = "记录加载失败:\(error.localizedDescription)" + } + } + + private func deleteResult(_ row: UserResultSummary) async { + guard let course = currentCourse, deletingHistoryID == nil else { return } + deletingHistoryID = row.id + defer { deletingHistoryID = nil } + do { + try await UserHistoryAPI.deleteResult(token: auth.token, attemptId: row.attemptId) + managedResults.removeAll { $0.id == row.id } + historyManager.store.removeScore(videoHash: course.videoHash, sentenceIndex: row.sentenceIndex) + historyManager.notifyHistoryChanged() + managementStatus = "测试记录已删除" + } catch { + managementStatus = "删除失败:\(error.localizedDescription)" + } + } + + private func deleteDubShare(_ row: UserDubShareSummary) async { + guard let course = currentCourse, deletingHistoryID == nil else { return } + deletingHistoryID = row.id + defer { deletingHistoryID = nil } + do { + try await UserHistoryAPI.deleteDubShare(token: auth.token, shareId: row.shareId) + managedDubShares.removeAll { $0.id == row.id } + historyManager.store.removeAllAudio(videoHash: course.videoHash) + historyManager.notifyHistoryChanged() + managementStatus = "配音分享已删除" + } catch { + managementStatus = "删除失败:\(error.localizedDescription)" + } + } + + private func displayDate(_ value: String) -> String { + value.replacingOccurrences(of: "T", with: " ").prefix(16).description + } + // MARK: - Load Courses private func loadCourses() async { diff --git a/ios/OralTrainer/Views/TestView.swift b/ios/OralTrainer/Views/TestView.swift index cdba569..e0ebc70 100644 --- a/ios/OralTrainer/Views/TestView.swift +++ b/ios/OralTrainer/Views/TestView.swift @@ -3,6 +3,7 @@ import SwiftUI struct TestView: View { @ObservedObject var controller: PlayerController + @ObservedObject var historyManager: TrainingHistoryManager let onFullscreen: () -> Void @State private var recordingSession: AudioRecorderSession? @@ -25,6 +26,8 @@ struct TestView: View { @State private var showShareSheet = false @State private var dubPlaybackObserver: NSObjectProtocol? @State private var testSubtitleVisible = true + @State private var loadedCourseHash: String? + @State private var remoteHistoryGeneration = 0 var body: some View { ScrollView(showsIndicators: false) { @@ -121,7 +124,8 @@ struct TestView: View { .padding(16) } } - .onReceive(controller.$currentItem) { _ in + .onReceive(controller.$currentItem) { item in + synchronizeHistory(for: item?.id) refreshTestUI() } .onReceive(controller.$currentSentence) { _ in @@ -130,6 +134,14 @@ struct TestView: View { .onDisappear { stopDubPlayback(message: nil) } + .onAppear { + synchronizeHistory(for: controller.currentItem?.id) + refreshTestUI() + } + .onChange(of: historyManager.historyVersion) { _ in + synchronizeHistory(for: controller.currentItem?.id, forceLocalReload: true) + refreshTestUI() + } .sheet(isPresented: $showShareSheet) { if let url = shareURL { ActivityShareSheet(items: [url]) @@ -290,14 +302,24 @@ struct TestView: View { try? FileManager.default.removeItem(at: rawURL) return } - if let oldURL = dubSegments[sentence.index] { - try? FileManager.default.removeItem(at: oldURL) + var audioURL = url + var rawPersistedURL = rawURL + if let item = controller.currentItem { + do { + let persisted = try historyManager.store.saveSegment( + videoHash: item.id, + sentenceIndex: sentence.index, + audioURL: url, + rawURL: rawURL + ) + audioURL = persisted.audioURL + rawPersistedURL = persisted.rawURL + } catch { + testStatus = "录音已评测,但本地历史保存失败:\(error.localizedDescription)" + } } - if let oldRawURL = dubRawSegments[sentence.index] { - try? FileManager.default.removeItem(at: oldRawURL) - } - dubSegments[sentence.index] = url - dubRawSegments[sentence.index] = rawURL + dubSegments[sentence.index] = audioURL + dubRawSegments[sentence.index] = rawPersistedURL mergedDubURL = nil submitAssessment(recordingURL: url) } @@ -337,6 +359,8 @@ struct TestView: View { Task { do { let result = try await ImitationAssessor.assess(request: request) + latestScores[sentence.index] = result + historyManager.store.saveScores(videoHash: item.id, scores: latestScores) applyAssessmentResult(result) } catch { testStatus = "评测失败:\(error.localizedDescription)" @@ -391,14 +415,159 @@ struct TestView: View { testStatus = "请先在训练模块选择一个云端课程" return } - let currentRecording = dubSegments[sentence.index] - let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖" - testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)" - if mergedDubURL == nil { + if let score = latestScores[sentence.index] { + applyAssessmentResult(score) + } else { + let currentRecording = dubSegments[sentence.index] + let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖" + testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)" + scoreSummary = "尚未评测" + scoreDetail = "" + } + if mergedDubURL == nil && !dubbingPlayback && !isGeneratingDub { dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音" } } + // MARK: - 历史记录 + + private func synchronizeHistory(for videoHash: String?, forceLocalReload: Bool = false) { + guard let videoHash else { + loadedCourseHash = nil + dubSegments = [:] + dubRawSegments = [:] + latestScores = [:] + return + } + let normalizedHash = historyManager.store.normalizedVideoHash(videoHash) + guard loadedCourseHash != normalizedHash || forceLocalReload else { return } + + loadedCourseHash = normalizedHash + remoteHistoryGeneration += 1 + let generation = remoteHistoryGeneration + let snapshot = historyManager.store.load(videoHash: videoHash) + dubSegments = snapshot.audioSegments + dubRawSegments = snapshot.rawSegments + for (index, audioURL) in snapshot.audioSegments where dubRawSegments[index] == nil { + dubRawSegments[index] = audioURL + } + latestScores = snapshot.scores + mergedDubURL = nil + if snapshot.audioSegments.isEmpty && snapshot.scores.isEmpty { + dubbingStatus = "正在同步历史配音…" + } else { + dubbingStatus = "已加载历史配音 \(snapshot.audioSegments.count) 句、评分 \(snapshot.scores.count) 句" + } + + Task { + await restoreRemoteHistory(videoHash: videoHash, generation: generation) + } + } + + private func restoreRemoteHistory(videoHash: String, generation: Int) async { + let token = AppConfig.authToken + guard !token.isEmpty else { return } + let normalizedHash = historyManager.store.normalizedVideoHash(videoHash) + + do { + let rows = try await UserHistoryAPI.results(token: token) + guard remoteHistoryGeneration == generation, + loadedCourseHash == normalizedHash else { return } + var latest = [Int: UserResultSummary]() + rows.filter { historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash } + .sorted { $0.createdAt < $1.createdAt } + .forEach { latest[$0.sentenceIndex] = $0 } + + var addedScores = 0 + for (index, row) in latest where latestScores[index] == nil { + if let score = decodedScore(payload: [ + "overall_score": row.overallScore, + "passed": row.passed, + "reference_text": row.sentenceText, + "feedback": "来自历史测试记录", + ]) { + latestScores[index] = score + addedScores += 1 + } + } + if addedScores > 0 { + historyManager.store.saveScores(videoHash: videoHash, scores: latestScores) + dubbingStatus = "已同步历史评分 \(latestScores.count) 句" + refreshTestUI() + } + } catch { + if remoteHistoryGeneration == generation, latestScores.isEmpty { + dubbingStatus = "历史评分同步失败:\(error.localizedDescription)" + } + } + + guard dubSegments.isEmpty else { return } + do { + let shares = try await UserHistoryAPI.dubShares(token: token) + guard remoteHistoryGeneration == generation, + loadedCourseHash == normalizedHash else { return } + guard let share = shares + .filter({ historyManager.store.normalizedVideoHash($0.videoHash) == normalizedHash }) + .max(by: { $0.createdAt < $1.createdAt }) else { + if dubSegments.isEmpty && latestScores.isEmpty { + dubbingStatus = "暂无可用的历史配音音频" + } + return + } + + let detail = try await UserHistoryAPI.dubShareDetail(token: token, shareId: share.shareId) + var restored = [Int: URL]() + for segment in detail.segments { + guard remoteHistoryGeneration == generation else { return } + let filename = segment.audioURL.split(separator: "/").last.map(String.init) ?? "" + guard !filename.isEmpty else { continue } + let destination = historyManager.store.audioDestination( + videoHash: videoHash, + sentenceIndex: segment.index + ) + try await UserHistoryAPI.downloadAudio( + token: token, + audioPath: "api/v1/dub-shares/\(share.shareId)/audio/\(filename)", + destination: destination + ) + restored[segment.index] = destination + var payload: [String: Any] = [ + "overall_score": segment.overallScore, + "feedback": "来自历史配音分享", + ] + if !segment.recognizedText.isEmpty { + payload["recognized_text"] = segment.recognizedText + } + segment.scoreDetails.forEach { payload[$0.key] = $0.value } + if let score = decodedScore(payload: payload), latestScores[segment.index] == nil { + latestScores[segment.index] = score + } + } + + guard remoteHistoryGeneration == generation, + loadedCourseHash == normalizedHash else { return } + for (index, audioURL) in restored where dubSegments[index] == nil { + dubSegments[index] = audioURL + dubRawSegments[index] = audioURL + } + historyManager.store.saveScores(videoHash: videoHash, scores: latestScores) + if !restored.isEmpty { + dubbingStatus = "已从云端恢复配音 \(restored.count) 句" + } else if dubSegments.isEmpty && latestScores.isEmpty { + dubbingStatus = "暂无可用的历史配音音频" + } + refreshTestUI() + } catch { + guard remoteHistoryGeneration == generation, dubSegments.isEmpty else { return } + dubbingStatus = "历史配音同步失败:\(error.localizedDescription)" + } + } + + private func decodedScore(payload: [String: Any]) -> ImitationAssessmentResult? { + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil } + return try? JSONDecoder().decode(ImitationAssessmentResult.self, from: data) + } + // MARK: - 配音 private func toggleDubPlayback() { diff --git a/sentence_api/README.md b/sentence_api/README.md index 8abc544..54260c3 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -12,6 +12,7 @@ FastAPI service for the Android oral-training SDK. It provides: - video catalog and HTTP Range playback; - sentence-boundary lookup compatible with the existing Android SDK; - student recording upload and `asr-fluency-v1` assessment; +- signed-in user history for test results and dubbing shares, including deletion; - AI sentence explanation for signed-in mobile users; - the legacy JSON boundary index as a read-only fallback. diff --git a/sentence_api/main.py b/sentence_api/main.py index c42a17c..d9d2a3d 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -346,6 +346,30 @@ def create_app( def my_dub_shares(user: AuthenticatedUser = Depends(current_user)) -> List[UserDubShareSummary]: return [UserDubShareSummary(**row) for row in video_repository.list_user_dub_shares(user.id)] + @application.delete("/api/v1/me/results/{attempt_id}", status_code=204) + def delete_my_result( + attempt_id: str, + user: AuthenticatedUser = Depends(current_user), + ) -> Response: + deleted_result = video_repository.delete_user_result(user.id, attempt_id) + if deleted_result is None: + raise HTTPException(status_code=404, detail="Test result was not found.") + if deleted_result["audio_filename"]: + (service_settings.attempts_dir / Path(deleted_result["audio_filename"]).name).unlink(missing_ok=True) + return Response(status_code=204) + + @application.delete("/api/v1/me/dub-shares/{share_id}", status_code=204) + def delete_my_dub_share( + share_id: str, + user: AuthenticatedUser = Depends(current_user), + ) -> Response: + audio_filenames = video_repository.delete_user_dub_share(user.id, share_id) + if audio_filenames is None: + raise HTTPException(status_code=404, detail="Dubbing share was not found.") + for filename in audio_filenames: + (service_settings.dub_shares_dir / Path(filename).name).unlink(missing_ok=True) + return Response(status_code=204) + @application.get("/admin", include_in_schema=False) def admin_page() -> FileResponse: page = static_dir / "admin.html" diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 3797f3c..a49b656 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -1118,6 +1118,47 @@ class VideoRepository: ).fetchall() return [dict(row) for row in rows] + def delete_user_result(self, user_id: str, attempt_id: str) -> Optional[Dict[str, Any]]: + """Delete one attempt owned by the user and return its stored audio filename.""" + with self._connect() as connection: + row = connection.execute( + """ + SELECT audio_filename FROM attempts + WHERE attempt_id = ? AND user_id = ? + """, + (attempt_id, user_id), + ).fetchone() + if row is None: + return None + connection.execute( + "DELETE FROM attempts WHERE attempt_id = ? AND user_id = ?", + (attempt_id, user_id), + ) + return {"audio_filename": row["audio_filename"]} + + def delete_user_dub_share(self, user_id: str, share_id: str) -> Optional[List[str]]: + """Delete one dub share owned by the user and return stored audio filenames.""" + with self._connect() as connection: + share = connection.execute( + "SELECT share_id FROM dub_shares WHERE share_id = ? AND user_id = ?", + (share_id, user_id), + ).fetchone() + if share is None: + return None + filenames = [ + row["audio_filename"] + for row in connection.execute( + "SELECT audio_filename FROM dub_share_segments WHERE share_id = ?", + (share_id,), + ).fetchall() + ] + # Segments are removed by ON DELETE CASCADE. + connection.execute( + "DELETE FROM dub_shares WHERE share_id = ? AND user_id = ?", + (share_id, user_id), + ) + return filenames + def delete_video(self, video_hash: str) -> Optional[Dict[str, Any]]: video = self.get_video(video_hash) if video is None: diff --git a/sentence_api/tests/test_assessment_api.py b/sentence_api/tests/test_assessment_api.py index 014ebf5..727813f 100644 --- a/sentence_api/tests/test_assessment_api.py +++ b/sentence_api/tests/test_assessment_api.py @@ -239,3 +239,68 @@ def test_user_enrollment_results_and_dub_shares_are_scoped(tmp_path): assert len(shares) == 1 assert shares[0]["segment_count"] == 1 assert shares[0]["average_score"] == 88 + + +def test_user_can_delete_owned_result_and_dub_share(tmp_path): + client = make_client(tmp_path) + assessed = client.post( + f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments", + files={"audio": ("reading.wav", make_wav(), "audio/wav")}, + ) + attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"] + shared = client.post( + "/api/v1/dub-shares", + data={ + "video_hash": VIDEO_HASH, + "segments": json.dumps([{"sentence_index": 0}]), + }, + files={"files": ("dub.wav", make_wav(), "audio/wav")}, + ) + share_id = shared.json()["share_id"] + + deleted_result = client.delete(f"/api/v1/me/results/{attempt_id}") + deleted_share = client.delete(f"/api/v1/me/dub-shares/{share_id}") + + assert assessed.status_code == 200 + assert shared.status_code == 201 + assert deleted_result.status_code == 204 + assert deleted_share.status_code == 204 + assert client.get("/api/v1/me/results").json() == [] + assert client.get("/api/v1/me/dub-shares").json() == [] + assert client.get(f"/api/v1/dub-shares/{share_id}").status_code == 404 + + +def test_user_cannot_delete_another_users_history(tmp_path): + client = make_client(tmp_path) + client.post( + f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments", + files={"audio": ("reading.wav", make_wav(), "audio/wav")}, + ) + attempt_id = client.get("/api/v1/me/results").json()[0]["attempt_id"] + shared = client.post( + "/api/v1/dub-shares", + data={ + "video_hash": VIDEO_HASH, + "segments": json.dumps([{"sentence_index": 0}]), + }, + files={"files": ("dub.wav", make_wav(), "audio/wav")}, + ) + share_id = shared.json()["share_id"] + + other = TestClient(create_app( + BoundaryStore(tmp_path / "legacy.json"), + settings=replace( + Settings.from_env(), + data_dir=tmp_path / "data", + legacy_boundaries_file=tmp_path / "legacy.json", + ), + )) + registered = other.post( + "/api/v1/auth/register", + json={"username": "other", "password": "password-123"}, + ) + other.headers.update({"Authorization": f"Bearer {registered.json()['token']}"}) + + assert registered.status_code == 201 + assert other.delete(f"/api/v1/me/results/{attempt_id}").status_code == 404 + assert other.delete(f"/api/v1/me/dub-shares/{share_id}").status_code == 404