diff --git a/.gitignore b/.gitignore index 7486a3a..7806dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ miniprogram_npm/ dist/ coverage/ target/ +.test-dist/ *.tsbuildinfo # Local configuration and secrets diff --git a/docs/incidents/2026-07-22-miniprogram-voice-retry.md b/docs/incidents/2026-07-22-miniprogram-voice-retry.md index 2980d3c..a19b3c1 100644 --- a/docs/incidents/2026-07-22-miniprogram-voice-retry.md +++ b/docs/incidents/2026-07-22-miniprogram-voice-retry.md @@ -3,7 +3,7 @@ ## 状态 - 记录日期:2026-07-22 -- 当前状态:待处理 +- 当前状态:已修复,待部署验证 - 影响范围:小程序反馈生成页的语音转录重试流程 - 相关会话:`7dc4f0dd-bd32-4d4b-b90a-c36ae83c2ca7` @@ -73,6 +73,10 @@ POST /api/v1/feedback-lessons/{lesson_id}/finish 页面没有独立、明确的“重试转录”主操作,也没有显示将要重试的失败片段数量。 +### 原生弹窗确认文案超限 + +语音记录弹窗把 `confirmText` 设置为“重试失败项”,共 5 个字符。项目使用的微信小程序 API 类型定义明确限制 `showModal.confirmText` 最多 4 个字符,因此该弹窗可能直接调用失败;原实现也没有设置 `fail` 回调,界面上不会留下可观察错误。 + ### 生成失败后没有刷新会话 `generateFeedback` 捕获 API 错误后只显示短暂 Toast,没有重新调用 `loadActiveSession`,也没有把错误信息和 `X-Request-Id` 保存到页面的持久错误区域。 @@ -83,7 +87,7 @@ POST /api/v1/feedback-lessons/{lesson_id}/finish API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,没有前端操作日志;请求到达服务端后,也缺少 `transcription_retry_requested` 等业务事件日志。 -## 待实现方案 +## 实现方案 1. 当 `failedSegmentCount > 0` 时,主操作优先显示“重试转录”,并直接调用失败片段重试流程。 2. 禁止在存在失败片段时调用生成接口。 @@ -92,6 +96,16 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时, 5. API 为重试受理、生成拒绝和转录失败增加包含会话 ID、课堂 ID、片段 ID、尝试次数和错误分类的结构化业务日志。 6. 对“未识别到清晰语音”提供更准确的用户提示,说明可能是录音过短、静音或环境噪声,并允许放弃该失败片段或重新录制。 +## 实施结果 + +- 主操作按 `failed -> processing -> generate -> save` 排序;存在失败片段时显示“重试转录(数量)”,不会调用生成接口。 +- 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。 +- 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。 +- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。 +- 页面增加失败原因说明和“放弃并重新录制”;后端删除失败片段时会重算课堂状态,空课堂会一并删除。 +- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。 +- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。 + ## 验收标准 - 存在失败片段时,主按钮不再显示“生成反馈”。 diff --git a/package.json b/package.json index dea0308..16ef0d9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "tsc --noEmit false --outDir .test-dist && node --test tests/voice-state.test.js" }, "devDependencies": { "miniprogram-api-typings": "^4.0.0", diff --git a/pages/feedback/index.ts b/pages/feedback/index.ts index 3e9cdf7..2e04930 100644 --- a/pages/feedback/index.ts +++ b/pages/feedback/index.ts @@ -2,6 +2,7 @@ import { copyApiRequestId, createFeedbackRecord, createVoiceLesson, + discardVoiceSegment, ensureFeedbackSession, finishVoiceLesson, generateFeedbackFromVoice, @@ -20,6 +21,13 @@ import type { StudentProfile, VoiceLessonFinished } from '../../utils/types' +import { runWithSessionRefresh } from './voice-actions' +import { + getFailedVoiceSegments, + getVoiceFailureHint, + getVoicePrimaryAction, + getVoicePrimaryActionText +} from './voice-state' type PickerEvent = { detail: { value: string | number } } type InputEvent = { detail: { value: string } } @@ -98,6 +106,10 @@ function wait(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function logVoiceOperation(event: string, fields: Record): void { + console.info('voice_operation', { event, ...fields }) +} + Page({ data: { profiles: [] as StudentProfile[], @@ -114,6 +126,7 @@ Page({ recordingPaused: false, recordingTime: '00:00', voiceStatus: '语音录入', + voiceFailureHint: '', voiceActionable: false, primaryActionText: '保存反馈', errorMessage: '', @@ -241,23 +254,35 @@ Page({ return } if (!session || session.lessonCount === 0) { - this.setData({ voiceStatus: '语音录入', voiceActionable: false, primaryActionText: '保存反馈' }) + this.setData({ + voiceStatus: '语音录入', + voiceFailureHint: '', + voiceActionable: false, + primaryActionText: '保存反馈' + }) return } const prefix = `${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}` let suffix = '已就绪' - if (session.failedSegmentCount > 0) suffix = '转录失败,点击重试' + if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败` else if (session.processingSegmentCount > 0) suffix = '正在处理' - let primaryActionText = session.needsGeneration ? '生成反馈' : '保存反馈' - if (session.processingSegmentCount > 0) primaryActionText = '转录中' + const primaryAction = getVoicePrimaryAction(session) this.setData({ voiceStatus: `${prefix} · ${suffix}`, + voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '', voiceActionable: true, - primaryActionText + primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount) }) }, + showApiError(error: unknown, showToast = true) { + const errorMessage = getApiErrorMessage(error) + const errorRequestId = getApiRequestId(error) + this.setData({ errorMessage, errorRequestId }) + if (showToast) wx.showToast({ title: errorMessage, icon: 'none' }) + }, + scheduleVoicePolling(session: FeedbackSession | null) { this.clearVoicePollTimer() if (!pageVisible || !session || session.processingSegmentCount === 0) return @@ -302,6 +327,7 @@ Page({ if (readyShortLessons.length > 0) await this.loadActiveSession(profileId) } catch (error) { console.warn('Failed to refresh voice transcription status', error) + this.showApiError(error, false) } finally { voicePollBusy = false this.scheduleVoicePolling(this.data.session) @@ -374,6 +400,7 @@ Page({ this.syncVoicePresentation(updated) } catch (error) { console.warn('Failed to auto-save feedback session', error) + this.showApiError(error, false) } }, @@ -443,7 +470,7 @@ Page({ } catch (error) { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) - wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + this.showApiError(error) } }, @@ -496,6 +523,7 @@ Page({ await this.uploadWithRetry(segment) } catch (error) { console.warn('Voice segment upload failed', error) + this.showApiError(error, false) const saved = await this.persistRecordingFile(segment) failedLocalUploads.push(saved) wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads) @@ -536,11 +564,13 @@ Page({ if (failedLocalUploads.length === 0) return this.setData({ voiceBusy: true, voiceStatus: '正在重试上传' }) const remaining: PendingVoiceUpload[] = [] + let lastError: unknown for (const segment of failedLocalUploads) { try { await this.uploadWithRetry(segment) this.removeSavedFile(segment.filePath) - } catch { + } catch (error) { + lastError = error remaining.push(segment) } } @@ -548,7 +578,7 @@ Page({ wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads) if (remaining.length > 0) { this.setData({ voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true }) - wx.showToast({ title: '录音上传失败,请检查网络', icon: 'none' }) + this.showApiError(lastError || new Error('录音上传失败,请检查网络')) return } await this.finalizeCurrentLesson() @@ -584,7 +614,7 @@ Page({ finishRequested = false await this.loadActiveSession() } catch (error) { - wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + this.showApiError(error) } finally { this.clearRecordingTimer() this.setData({ recording: false, recordingPaused: false, voiceBusy: false, recordingTime: '00:00' }) @@ -647,7 +677,7 @@ Page({ content: lines.join('\n'), showCancel: hasFailures, cancelText: '关闭', - confirmText: hasFailures ? '重试失败项' : '知道了', + confirmText: hasFailures ? '重试转录' : '知道了', success: (result) => { if (hasFailures && result.confirm) void this.retryFailedSegments() } @@ -657,32 +687,100 @@ Page({ async retryFailedSegments() { const session = this.data.session if (!session) return - const failedSegments = session.lessons.flatMap((lesson) => - lesson.segments.filter((segment) => segment.status === 'failed') - ) + const failedSegments = getFailedVoiceSegments(session) if (failedSegments.length === 0) return - this.setData({ voiceBusy: true, voiceStatus: '正在重试转录' }) + logVoiceOperation('transcription_retry_clicked', { + feedbackSessionId: session.id, + failedSegmentCount: failedSegments.length + }) + this.setData({ + voiceBusy: true, + voiceStatus: `正在重试${failedSegments.length}段转录`, + primaryActionText: '正在重试转录', + errorMessage: '', + errorRequestId: '' + }) try { - for (const segment of failedSegments) await retryVoiceSegment(segment.id) - const failedLessonIds = session.lessons - .filter((lesson) => lesson.segments.some((segment) => segment.status === 'failed')) - .map((lesson) => lesson.id) - for (const lessonId of failedLessonIds) { - const finished = await finishVoiceLesson(lessonId) - await this.applyFinishedLesson(finished) - } - await this.loadActiveSession() + await runWithSessionRefresh( + async () => { + for (const segment of failedSegments) await retryVoiceSegment(segment.id) + }, + async () => this.loadActiveSession() + ) + logVoiceOperation('transcription_retry_submitted', { + feedbackSessionId: session.id, + segmentIds: failedSegments.map((segment) => segment.id) + }) + wx.showToast({ title: '已重新提交,正在转录', icon: 'none' }) } catch (error) { - wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + logVoiceOperation('transcription_retry_failed', { + feedbackSessionId: session.id, + requestId: getApiRequestId(error) + }) + this.showApiError(error) + } finally { + this.setData({ voiceBusy: false }) + this.syncVoicePresentation(this.data.session) + this.scheduleVoicePolling(this.data.session) + } + }, + + confirmDiscardFailedSegments(): Promise { + return new Promise((resolve) => { + wx.showModal({ + title: '放弃失败录音', + content: '失败片段将被删除,并立即开始新的录音。此操作无法撤销。', + confirmText: '放弃重录', + confirmColor: '#d13f3a', + success: (result) => resolve(result.confirm), + fail: () => resolve(false) + }) + }) + }, + + async discardFailedAndRecord() { + if (this.data.recording || this.data.voiceBusy) return + const session = this.data.session + if (!session) return + const failedSegments = getFailedVoiceSegments(session) + if (failedSegments.length === 0 || !(await this.confirmDiscardFailedSegments())) return + + let discarded = false + this.setData({ + voiceBusy: true, + voiceStatus: `正在放弃${failedSegments.length}段失败录音`, + primaryActionText: '正在处理', + errorMessage: '', + errorRequestId: '' + }) + try { + await runWithSessionRefresh( + async () => { + for (const segment of failedSegments) await discardVoiceSegment(segment.id) + }, + async () => this.loadActiveSession() + ) + discarded = true + logVoiceOperation('failed_transcription_discarded', { + feedbackSessionId: session.id, + segmentIds: failedSegments.map((segment) => segment.id) + }) + } catch (error) { + this.showApiError(error) } finally { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) } + if (discarded) await this.toggleRecording() }, async saveFeedback() { this.clearDraftSaveTimer() const session = this.data.session + if (session && session.failedSegmentCount > 0) { + await this.retryFailedSegments() + return + } if (session && session.processingSegmentCount > 0) { wx.showToast({ title: '录音仍在转写,请稍候', icon: 'none' }) this.scheduleVoicePolling(session) @@ -718,7 +816,7 @@ Page({ wx.showToast({ title: '反馈已保存', icon: 'success' }) setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450) } catch (error) { - wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + this.showApiError(error) } finally { this.setData({ saving: false }) } @@ -727,17 +825,25 @@ Page({ async generateFeedback() { const session = this.data.session if (!session) return - this.setData({ generating: true, voiceBusy: true, voiceStatus: '正在生成反馈' }) + this.setData({ + generating: true, + voiceBusy: true, + voiceStatus: '正在生成反馈', + errorMessage: '', + errorRequestId: '' + }) try { - const result = await generateFeedbackFromVoice(session.id, this.data.draft.content) + const result = await runWithSessionRefresh( + async () => generateFeedbackFromVoice(session.id, this.data.draft.content), + async () => this.loadActiveSession() + ) this.setData({ 'draft.content': result.content, contentLength: result.content.length }) - await this.loadActiveSession() wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' }) } catch (error) { - wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) + this.showApiError(error) } finally { this.setData({ generating: false, voiceBusy: false }) this.syncVoicePresentation(this.data.session) diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml index 50b2c0d..83907b3 100644 --- a/pages/feedback/index.wxml +++ b/pages/feedback/index.wxml @@ -56,10 +56,18 @@ {{recordingTime}} + + {{voiceFailureHint}} + + - + diff --git a/pages/feedback/index.wxss b/pages/feedback/index.wxss index df9147e..4a64a06 100644 --- a/pages/feedback/index.wxss +++ b/pages/feedback/index.wxss @@ -165,6 +165,45 @@ font-variant-numeric: tabular-nums; } +.voice-failure-recovery { + width: 100%; + padding: 16rpx; + border-left: 6rpx solid #d13f3a; + border-radius: 8rpx; + background: #fff7f6; +} + +.voice-failure-hint { + display: block; + color: #6e3f3c; + font-size: 24rpx; + line-height: 1.55; +} + +.voice-discard-button { + width: auto; + min-width: 0; + height: 56rpx; + margin: 12rpx 0 0; + padding: 0; + border: 0; + background: transparent; + color: #b8322d; + font-size: 24rpx; + font-weight: 600; + line-height: 56rpx; + text-align: left; +} + +.voice-discard-button::after { + border: 0; +} + +.voice-discard-button[disabled] { + color: #aeaeb2; + opacity: 1; +} + .save-feedback { width: calc(100% - 48rpx); min-width: calc(100% - 48rpx); diff --git a/pages/feedback/voice-actions.ts b/pages/feedback/voice-actions.ts new file mode 100644 index 0000000..06d0275 --- /dev/null +++ b/pages/feedback/voice-actions.ts @@ -0,0 +1,18 @@ +export async function runWithSessionRefresh( + action: () => Promise, + refreshSession: () => Promise +): Promise { + let result: T + try { + result = await action() + } catch (error) { + try { + await refreshSession() + } catch { + // Preserve the actionable API error and its request ID. + } + throw error + } + await refreshSession() + return result +} diff --git a/pages/feedback/voice-state.ts b/pages/feedback/voice-state.ts new file mode 100644 index 0000000..642e447 --- /dev/null +++ b/pages/feedback/voice-state.ts @@ -0,0 +1,39 @@ +import type { FeedbackSession, VoiceAudioSegment } from '../../utils/types' + +export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate' + +export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction { + if (!session || session.lessonCount === 0) return 'save' + if (session.failedSegmentCount > 0) return 'retry' + if (session.processingSegmentCount > 0) return 'processing' + return session.needsGeneration ? 'generate' : 'save' +} + +export function getVoicePrimaryActionText( + action: VoicePrimaryAction, + failedSegmentCount = 0 +): string { + if (action === 'retry') return `重试转录(${failedSegmentCount})` + if (action === 'processing') return '转录中' + if (action === 'generate') return '生成反馈' + return '保存反馈' +} + +export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegment[] { + return session.lessons.flatMap((lesson) => + lesson.segments.filter((segment) => segment.status === 'failed') + ) +} + +export function getVoiceFailureHint(session: FeedbackSession): string { + const failedSegments = getFailedVoiceSegments(session) + const hasNoClearSpeech = failedSegments.some((segment) => { + const message = segment.errorMessage?.toLowerCase() || '' + return message.includes('no clear speech') || message.includes('未识别到清晰语音') + }) + const count = session.failedSegmentCount + if (hasNoClearSpeech) { + return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃后重新录制。` + } + return `${count}段录音转录失败,可重试,或放弃后重新录制。` +} diff --git a/server/API.md b/server/API.md index 467f167..8503ffe 100644 --- a/server/API.md +++ b/server/API.md @@ -102,6 +102,7 @@ Authorization: Bearer | `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 | | `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记短录音文字已直接加入反馈。 | | `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 | +| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 | | `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 | ### 创建学生档案 diff --git a/server/API_GUIDE.md b/server/API_GUIDE.md index a3bc637..37ec798 100644 --- a/server/API_GUIDE.md +++ b/server/API_GUIDE.md @@ -126,6 +126,8 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新 录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR,并将片段更新为 `ready` 或 `failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文。 +失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。 + ## 6. OpenAPI 维护方式 `/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。 diff --git a/server/src/recording_routes.rs b/server/src/recording_routes.rs index db795a8..8e2cd30 100644 --- a/server/src/recording_routes.rs +++ b/server/src/recording_routes.rs @@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc}; use axum::{ Json, extract::{Multipart, Path, Query, State}, - http::HeaderMap, + http::{HeaderMap, StatusCode}, }; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::{ AppState, error::{ApiError, CommonApiErrors, ErrorBody}, + transcription_worker::transcription_error_class, }; type SharedState = Arc; @@ -34,6 +35,7 @@ pub fn router() -> OpenApiRouter { .routes(routes!(finish_lesson_session)) .routes(routes!(mark_lesson_applied)) .routes(routes!(retry_audio_segment)) + .routes(routes!(discard_audio_segment)) .routes(routes!(generate_feedback_content)) } @@ -94,6 +96,26 @@ struct SegmentRow { error_message: Option, } +#[derive(Debug, FromRow)] +struct RetryableSegmentRow { + sequence: i32, + duration_ms: i32, + audio_path: String, + lesson_id: Uuid, + feedback_session_id: Uuid, + transcription_attempts: i32, + error_message: Option, +} + +#[derive(Debug, FromRow)] +struct GenerationBlockingSegmentRow { + id: Uuid, + lesson_id: Uuid, + status: String, + transcription_attempts: i32, + error_message: Option, +} + #[derive(Debug, Serialize, ToSchema)] struct FeedbackSessionResponse { id: Uuid, @@ -665,19 +687,23 @@ async fn retry_audio_segment( .authenticate(state.pool.as_ref(), &headers) .await? .user_id; - let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>( - "SELECT s.sequence, s.duration_ms, s.audio_path, l.id \ + let mut transaction = pool(&state)?.begin().await?; + let segment = sqlx::query_as::<_, RetryableSegmentRow>( + "SELECT s.sequence, s.duration_ms, s.audio_path, l.id AS lesson_id, \ + f.id AS feedback_session_id, s.transcription_attempts, s.error_message \ FROM audio_segments s \ JOIN lesson_sessions l ON l.id = s.lesson_session_id \ JOIN feedback_sessions f ON f.id = l.feedback_session_id \ - WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active'", + WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active' \ + AND s.status = 'failed' \ + FOR UPDATE OF s", ) .bind(segment_id) .bind(owner_id) - .fetch_optional(pool(&state)?) + .fetch_optional(&mut *transaction) .await? .ok_or(ApiError::NotFound)?; - tokio::fs::metadata(&segment.2) + tokio::fs::metadata(&segment.audio_path) .await .map_err(internal_io_error)?; sqlx::query( @@ -685,30 +711,144 @@ async fn retry_audio_segment( processing_started_at = NULL, updated_at = now() WHERE id = $1", ) .bind(segment_id) - .execute(pool(&state)?) + .execute(&mut *transaction) .await?; sqlx::query( "UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1", ) - .bind(segment.3) - .execute(pool(&state)?) + .bind(segment.lesson_id) + .execute(&mut *transaction) .await?; - let session_id = sqlx::query_scalar::<_, Uuid>( - "SELECT feedback_session_id FROM lesson_sessions WHERE id = $1", - ) - .bind(segment.3) - .fetch_one(pool(&state)?) - .await?; - touch_session(pool(&state)?, session_id).await?; + sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1") + .bind(segment.feedback_session_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + tracing::info!( + event = "transcription_retry_requested", + feedback_session_id = %segment.feedback_session_id, + lesson_id = %segment.lesson_id, + segment_id = %segment_id, + attempt = segment.transcription_attempts + 1, + previous_attempt = segment.transcription_attempts, + error_class = transcription_error_class(segment.error_message.as_deref().unwrap_or("")), + status = "processing", + "failed audio segment returned to transcription queue" + ); Ok(Json(AudioSegmentResponse { id: segment_id, - sequence: segment.0, - duration_ms: segment.1, + sequence: segment.sequence, + duration_ms: segment.duration_ms, status: "processing".to_owned(), error_message: None, })) } +#[utoipa::path( + delete, + path = "/api/v1/audio-segments/{segment_id}", + tag = "语音反馈", + summary = "放弃失败的录音片段", + description = "删除无法使用的失败录音。若课堂不再包含片段,则同时删除空课堂。", + params( + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), + ("segment_id" = Uuid, Path, description = "失败录音片段 ID") + ), + responses( + (status = 204, description = "失败片段已放弃"), + (status = 404, description = "失败片段不存在或不属于当前用户", body = ErrorBody), + CommonApiErrors + ) +)] +async fn discard_audio_segment( + State(state): State, + headers: HeaderMap, + Path(segment_id): Path, +) -> Result { + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; + let mut transaction = pool(&state)?.begin().await?; + let segment = sqlx::query_as::<_, RetryableSegmentRow>( + "SELECT s.sequence, s.duration_ms, s.audio_path, l.id AS lesson_id, \ + f.id AS feedback_session_id, s.transcription_attempts, s.error_message \ + FROM audio_segments s \ + JOIN lesson_sessions l ON l.id = s.lesson_session_id \ + JOIN feedback_sessions f ON f.id = l.feedback_session_id \ + WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active' \ + AND s.status = 'failed' \ + FOR UPDATE OF s", + ) + .bind(segment_id) + .bind(owner_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(ApiError::NotFound)?; + + sqlx::query("DELETE FROM audio_segments WHERE id = $1 AND status = 'failed'") + .bind(segment_id) + .execute(&mut *transaction) + .await?; + let remaining_segment_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM audio_segments WHERE lesson_session_id = $1", + ) + .bind(segment.lesson_id) + .fetch_one(&mut *transaction) + .await?; + if remaining_segment_count == 0 { + sqlx::query("DELETE FROM lesson_sessions WHERE id = $1") + .bind(segment.lesson_id) + .execute(&mut *transaction) + .await?; + } else { + sqlx::query( + "UPDATE lesson_sessions SET \ + status = CASE \ + WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'failed') THEN 'failed' \ + WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'processing') THEN 'processing' \ + ELSE 'ready' \ + END, \ + duration_ms = (SELECT COALESCE(SUM(duration_ms), 0) FROM audio_segments WHERE lesson_session_id = $1), \ + updated_at = now() \ + WHERE id = $1", + ) + .bind(segment.lesson_id) + .execute(&mut *transaction) + .await?; + } + sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1") + .bind(segment.feedback_session_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + if let Err(error) = tokio::fs::remove_file(&segment.audio_path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + event = "audio_storage_delete_failed", + feedback_session_id = %segment.feedback_session_id, + lesson_id = %segment.lesson_id, + segment_id = %segment_id, + error_class = "audio_storage_delete_failed", + "discarded audio file could not be removed from storage" + ); + } + tracing::info!( + event = "failed_transcription_discarded", + feedback_session_id = %segment.feedback_session_id, + lesson_id = %segment.lesson_id, + segment_id = %segment_id, + attempt = segment.transcription_attempts, + error_class = transcription_error_class(segment.error_message.as_deref().unwrap_or("")), + status = "discarded", + "failed audio segment discarded" + ); + Ok(StatusCode::NO_CONTENT) +} + #[utoipa::path( post, path = "/api/v1/feedback-sessions/{session_id}/generate", @@ -739,22 +879,30 @@ async fn generate_feedback_content( .user_id; validate_draft_content(&input.existing_content)?; assert_session_owner(pool(&state)?, owner_id, session_id).await?; - let pending_status = sqlx::query_as::<_, (bool, bool)>( - "SELECT \ - EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \ - WHERE l.feedback_session_id = $1 AND s.status = 'processing'), \ - EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \ - WHERE l.feedback_session_id = $1 AND s.status = 'failed')", + let blocking_segments = sqlx::query_as::<_, GenerationBlockingSegmentRow>( + "SELECT s.id, l.id AS lesson_id, s.status, s.transcription_attempts, s.error_message \ + FROM audio_segments s \ + JOIN lesson_sessions l ON l.id = s.lesson_session_id \ + WHERE l.feedback_session_id = $1 AND s.status IN ('processing', 'failed') \ + ORDER BY l.sequence, s.sequence", ) .bind(session_id) - .fetch_one(pool(&state)?) + .fetch_all(pool(&state)?) .await?; - if pending_status.0 { + if blocking_segments + .iter() + .any(|segment| segment.status == "processing") + { + log_generation_rejection(session_id, &blocking_segments, "processing"); return Err(ApiError::BadRequest( "录音仍在后台转录中,请稍后再生成反馈".to_owned(), )); } - if pending_status.1 { + if blocking_segments + .iter() + .any(|segment| segment.status == "failed") + { + log_generation_rejection(session_id, &blocking_segments, "failed"); return Err(ApiError::BadRequest( "部分录音转录失败,请重试后再生成反馈".to_owned(), )); @@ -979,6 +1127,34 @@ fn internal_io_error(_: std::io::Error) -> ApiError { ApiError::Internal } +fn log_generation_rejection( + session_id: Uuid, + blocking_segments: &[GenerationBlockingSegmentRow], + blocking_status: &str, +) { + for segment in blocking_segments + .iter() + .filter(|segment| segment.status == blocking_status) + { + let error_class = if segment.status == "processing" { + "transcription_processing" + } else { + transcription_error_class(segment.error_message.as_deref().unwrap_or("")) + }; + tracing::warn!( + event = "feedback_generation_rejected", + feedback_session_id = %session_id, + lesson_id = %segment.lesson_id, + segment_id = %segment.id, + attempt = segment.transcription_attempts, + status = %segment.status, + error_class, + rejection_reason = %blocking_status, + "feedback generation blocked by audio transcription state" + ); + } +} + fn validate_draft_content(content: &str) -> Result<(), ApiError> { if content.chars().count() > 2000 { return Err(ApiError::BadRequest( diff --git a/server/src/routes.rs b/server/src/routes.rs index ce08059..b9814b3 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -924,7 +924,7 @@ mod tests { .filter(|operation| operation.get("responses").is_some()) .collect::>(); - assert_eq!(operations.len(), 23); + assert_eq!(operations.len(), 24); for operation in &operations { assert_non_empty(operation, "summary"); assert_non_empty(operation, "description"); diff --git a/server/src/transcription_worker.rs b/server/src/transcription_worker.rs index f065fe6..fa988af 100644 --- a/server/src/transcription_worker.rs +++ b/server/src/transcription_worker.rs @@ -335,10 +335,13 @@ async fn fail_job( Ok(()) } -fn transcription_error_class(message: &str) -> &'static str { - if message.contains("连接失败") || message.contains("unavailable") { +pub(crate) fn transcription_error_class(message: &str) -> &'static str { + let normalized = message.to_lowercase(); + if normalized.contains("no clear speech") || message.contains("未识别到清晰语音") { + "no_clear_speech" + } else if message.contains("连接失败") || normalized.contains("unavailable") { "asr_unavailable" - } else if message.contains("HTTP") { + } else if normalized.contains("http") { "asr_http_error" } else if message.contains("读取录音") { "audio_read_failed" @@ -379,3 +382,24 @@ async fn refresh_lesson(pool: &PgPool, lesson_id: Uuid) -> Result<(), sqlx::Erro .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::transcription_error_class; + + #[test] + fn no_clear_speech_is_classified_before_http_errors() { + assert_eq!( + transcription_error_class("FunASR 返回 HTTP 422: no clear speech was recognized"), + "no_clear_speech" + ); + } + + #[test] + fn generic_http_error_keeps_provider_error_class() { + assert_eq!( + transcription_error_class("FunASR 返回 HTTP 500"), + "asr_http_error" + ); + } +} diff --git a/tests/voice-state.test.js b/tests/voice-state.test.js new file mode 100644 index 0000000..ea7e754 --- /dev/null +++ b/tests/voice-state.test.js @@ -0,0 +1,103 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { + getVoiceFailureHint, + getVoicePrimaryAction, + getVoicePrimaryActionText +} = require('../.test-dist/pages/feedback/voice-state.js') +const { runWithSessionRefresh } = require('../.test-dist/pages/feedback/voice-actions.js') + +function session(overrides = {}) { + return { + id: 'session-1', + profileId: null, + feedbackDate: '2026-07-22', + content: '', + status: 'active', + lessonCount: 1, + totalDurationMs: 2800, + processingSegmentCount: 0, + failedSegmentCount: 0, + needsGeneration: false, + lessons: [], + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +test('failed segments take priority over processing and generation', () => { + const value = session({ + failedSegmentCount: 2, + processingSegmentCount: 1, + needsGeneration: true + }) + + const action = getVoicePrimaryAction(value) + assert.equal(action, 'retry') + assert.equal(getVoicePrimaryActionText(action, value.failedSegmentCount), '重试转录(2)') +}) + +test('voice primary action follows retry, processing, then generation states', () => { + assert.equal(getVoicePrimaryAction(session({ failedSegmentCount: 1 })), 'retry') + assert.equal(getVoicePrimaryAction(session({ processingSegmentCount: 1 })), 'processing') + assert.equal(getVoicePrimaryAction(session({ needsGeneration: true })), 'generate') +}) + +test('no clear speech failures provide an actionable explanation', () => { + const value = session({ + failedSegmentCount: 1, + lessons: [ + { + segments: [ + { + id: 'segment-1', + sequence: 1, + durationMs: 2800, + status: 'failed', + errorMessage: 'FunASR HTTP 422: no clear speech was recognized' + } + ] + } + ] + }) + + const hint = getVoiceFailureHint(value) + assert.match(hint, /录音过短、静音或环境噪声/) + assert.match(hint, /放弃后重新录制/) +}) + +test('session refresh runs after a failed API action', async () => { + const expected = new Error('generation rejected') + let refreshCount = 0 + + await assert.rejects( + runWithSessionRefresh( + async () => { + throw expected + }, + async () => { + refreshCount += 1 + } + ), + expected + ) + assert.equal(refreshCount, 1) +}) + +test('refresh failure does not replace the original API error', async () => { + const expected = new Error('generation rejected with request id') + + await assert.rejects( + runWithSessionRefresh( + async () => { + throw expected + }, + async () => { + throw new Error('refresh failed') + } + ), + expected + ) +}) diff --git a/utils/api.ts b/utils/api.ts index f7f2b91..46dc288 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -586,6 +586,13 @@ export async function retryVoiceSegment(segmentId: string): Promise { + await request( + `/api/v1/audio-segments/${encodeURIComponent(segmentId)}`, + 'DELETE' + ) +} + export async function generateFeedbackFromVoice( sessionId: string, existingContent: string