fixed some bugs

This commit is contained in:
2026-09-07 11:42:53 +08:00
parent aca36c78c1
commit 9230db3b69
12 changed files with 883 additions and 14 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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:

View File

@@ -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