fixed some bugs
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
AA00000000000000000000A2 /* UserHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserHistory.swift; sourceTree = "<group>"; };
|
||||
AA00000000000000000000A4 /* TrainingHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainingHistoryStore.swift; sourceTree = "<group>"; };
|
||||
AA0000000000000000000093 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
Binary file not shown.
@@ -20,4 +20,7 @@ enum AppConfig {
|
||||
|
||||
/// 当前登录用户的认证 token,由 AuthManager 在登录/注销时设置。
|
||||
static var authToken: String = ""
|
||||
|
||||
/// 当前登录用户 ID,用于隔离本机缓存的历史录音和评分。
|
||||
static var currentUserId: String = ""
|
||||
}
|
||||
|
||||
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
167
ios/OralTrainer/Services/TrainingHistoryStore.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
177
ios/OralTrainer/Services/UserHistory.swift
Normal file
@@ -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<T: Decodable>(
|
||||
_ 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)"]
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Content: View>(
|
||||
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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user