diff --git a/docs/incidents/2026-07-22-miniprogram-voice-retry.md b/docs/incidents/2026-07-22-miniprogram-voice-retry.md index 9025c23..5496430 100644 --- a/docs/incidents/2026-07-22-miniprogram-voice-retry.md +++ b/docs/incidents/2026-07-22-miniprogram-voice-retry.md @@ -103,6 +103,7 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时, - 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。 - 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。 - 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。 +- 失败课节在列表内直接提供恢复操作:保留失败音频时显示“重试转录 / 删除录音”;未保留任何音频的空失败课节显示“重新录制 / 删除记录”,避免提供无法执行的转录重试。 - 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。 - 语音记录进一步调整为可复用素材列表:每节可查看完整转写、展开或收起、按需加入或重新加入输入框;短录音转写完成后不再自动修改正文。 - 反馈输入框支持确认后清空,语音记录仍保留;单节内容加入后若超过 2000 字会整体拒绝并提示,不再截断后写入。 @@ -119,6 +120,7 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时, - 点击“重试转录”后,API 日志中能查询到对应 `/retry` 请求和业务事件。 - 页面在重试期间显示明确的处理中状态,且不会重复提交生成请求。 - 重试成功后,该节录音自动进入可选总结集合;再次失败时显示可理解的失败原因。 +- 未保留音频的失败课节显示“重新录制”和“删除记录”,不会显示无法生效的转录重试。 - 所有已就绪录音默认被选中,可逐节取消或一键全选;总结完成后必须先显示预览,不能自动覆盖输入框。 - 两万字级原始转录不写入输入框;配置 AI 后由分层总结压缩为不超过 2000 字的一份正文。 - 所有 API 错误都能在页面上获得可复制的请求编号。 diff --git a/pages/feedback/index.ts b/pages/feedback/index.ts index 15ac961..f362f38 100644 --- a/pages/feedback/index.ts +++ b/pages/feedback/index.ts @@ -4,6 +4,7 @@ import { createFeedbackRecord, createFeedbackSummaryRun, createVoiceLesson, + discardEmptyVoiceLesson, discardVoiceSegment, ensureFeedbackSession, finishVoiceLesson, @@ -27,9 +28,11 @@ import type { VoiceLessonFinished } from '../../utils/types' import { runWithSessionRefresh } from './voice-actions' +import type { VoiceLessonRecoveryAction } from './voice-state' import { getFailedVoiceSegments, getVoiceFailureHint, + getVoiceLessonRecoveryAction, getVoiceLessonStatusText, getVoicePrimaryAction, getVoicePrimaryActionText @@ -60,6 +63,9 @@ interface VoiceLessonView { summaryEligible: boolean summarySelected: boolean failedSegmentCount: number + failureRecoveryAction: VoiceLessonRecoveryAction + failureRecoveryText: string + failureDeleteText: string addActionText: string emptyText: string } @@ -127,7 +133,7 @@ function formatRecordingTime(milliseconds: number): string { function formatVoiceDuration(milliseconds: number): string { const totalMinutes = Math.floor(milliseconds / 60000) - if (totalMinutes < 1) return `${Math.max(1, Math.round(milliseconds / 1000))}秒` + if (totalMinutes < 1) return `${Math.max(0, Math.round(milliseconds / 1000))}秒` return `${totalMinutes}分钟` } @@ -147,6 +153,7 @@ function buildVoiceLessonViews( const isInDraft = Boolean(transcript) && content.includes(transcript) const summaryEligible = lesson.status === 'ready' && Boolean(transcript) const failedSegmentCount = lesson.segments.filter((segment) => segment.status === 'failed').length + const failureRecoveryAction = getVoiceLessonRecoveryAction(lesson) let emptyText = '转录完成后可查看内容' if (lesson.status === 'failed') { emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage @@ -166,6 +173,9 @@ function buildVoiceLessonViews( summaryEligible, summarySelected: summaryEligible && !excluded.has(lesson.id), failedSegmentCount, + failureRecoveryAction, + failureRecoveryText: failureRecoveryAction === 'retry' ? '重试转录' : '重新录制', + failureDeleteText: failureRecoveryAction === 'retry' ? '删除录音' : '删除记录', addActionText: isInDraft ? '已在输入框' : lesson.appliedDirectly @@ -387,8 +397,12 @@ Page({ } const prefix = `语音记录 · ${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}` + const emptyFailedLessonCount = session.lessons.filter((lesson) => + lesson.status === 'failed' && lesson.segments.length === 0 + ).length let suffix = '已就绪' if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败` + else if (emptyFailedLessonCount > 0) suffix = `${emptyFailedLessonCount}节未保留录音` else if (session.processingSegmentCount > 0) suffix = '正在处理' else if (session.needsGeneration) suffix = '可生成总结' const primaryAction = getVoicePrimaryAction(session) @@ -683,8 +697,13 @@ Page({ return } + await this.startVoiceRecording() + }, + + async startVoiceRecording(permissionGranted = false) { this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' }) - const authorized = await this.ensureRecordPermission() + let authorized = permissionGranted + if (!authorized) authorized = await this.ensureRecordPermission() if (!authorized) { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) @@ -1063,13 +1082,57 @@ Page({ await this.retryVoiceSegments(failedSegments) }, - async retryVoiceLesson(event: LessonActionEvent) { + async recoverVoiceLesson(event: LessonActionEvent) { const session = this.data.session const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId) if (!lesson) return const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed') - if (failedSegments.length === 0) return - await this.retryVoiceSegments(failedSegments, lesson.sequence) + if (failedSegments.length > 0) { + await this.retryVoiceSegments(failedSegments, lesson.sequence) + return + } + if (lesson.status === 'failed') await this.rerecordVoiceLesson(lesson) + }, + + confirmRerecordVoiceLesson(lessonSequence: number): Promise { + return new Promise((resolve) => { + wx.showModal({ + title: `重新录制第${lessonSequence}节`, + content: '该节没有保留可重试的音频。重新录制将删除这条失败记录并开启麦克风。', + confirmText: '重新录制', + success: (result) => resolve(result.confirm), + fail: () => resolve(false) + }) + }) + }, + + async rerecordVoiceLesson(lesson: VoiceLesson) { + if ( + this.data.recording + || this.data.voiceBusy + || !(await this.confirmRerecordVoiceLesson(lesson.sequence)) + ) return + this.setData({ voiceBusy: true, voiceStatus: `正在准备重新录制第${lesson.sequence}节` }) + const authorized = await this.ensureRecordPermission() + if (!authorized) { + this.setData({ voiceBusy: false }) + this.syncVoicePresentation(this.data.session) + return + } + try { + await discardEmptyVoiceLesson(lesson.id) + logVoiceOperation('empty_failed_lesson_rerecord_started', { + feedbackSessionId: this.data.session?.id || null, + lessonId: lesson.id, + lessonSequence: lesson.sequence + }) + await this.startVoiceRecording(true) + } catch (error) { + this.setData({ voiceBusy: false }) + await this.loadActiveSession() + this.syncVoicePresentation(this.data.session) + this.showApiError(error) + } }, async retryVoiceSegments( @@ -1141,6 +1204,19 @@ Page({ }) }, + confirmDiscardEmptyVoiceLesson(lessonSequence: number): Promise { + return new Promise((resolve) => { + wx.showModal({ + title: `删除第${lessonSequence}节记录`, + content: '该节没有保留可用录音,删除后不会影响其他语音记录。此操作无法撤销。', + confirmText: '确认删除', + confirmColor: '#d13f3a', + success: (result) => resolve(result.confirm), + fail: () => resolve(false) + }) + }) + }, + async discardFailedSegments() { if (this.data.recording || this.data.voiceBusy) return const session = this.data.session @@ -1159,13 +1235,50 @@ Page({ const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId) if (!session || !lesson) return const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed') + if (failedSegments.length === 0) { + if ( + lesson.status === 'failed' + && await this.confirmDiscardEmptyVoiceLesson(lesson.sequence) + ) await this.discardEmptyFailedVoiceLesson(lesson) + return + } if ( - failedSegments.length === 0 - || !(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence)) + !(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence)) ) return await this.discardVoiceSegments(failedSegments, lesson.sequence) }, + async discardEmptyFailedVoiceLesson(lesson: VoiceLesson) { + const session = this.data.session + if (!session) return + this.setData({ + voiceBusy: true, + voiceStatus: `正在删除第${lesson.sequence}节失败记录`, + primaryActionText: '正在处理', + errorMessage: '', + errorRequestId: '' + }) + try { + await runWithSessionRefresh( + async () => discardEmptyVoiceLesson(lesson.id), + async () => { + await this.loadActiveSession() + } + ) + logVoiceOperation('empty_failed_lesson_discarded', { + feedbackSessionId: session.id, + lessonId: lesson.id, + lessonSequence: lesson.sequence + }) + wx.showToast({ title: `第${lesson.sequence}节失败记录已删除`, icon: 'none' }) + } catch (error) { + this.showApiError(error) + } finally { + this.setData({ voiceBusy: false }) + this.syncVoicePresentation(this.data.session) + } + }, + async discardVoiceSegments( failedSegments: VoiceAudioSegment[], lessonSequence?: number diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml index 4cdb5a3..160a2d8 100644 --- a/pages/feedback/index.wxml +++ b/pages/feedback/index.wxml @@ -101,7 +101,7 @@ {{lesson.durationText}} · {{lesson.transcriptLength}}字 - {{lesson.statusText}} + {{lesson.statusText}} {{lesson.addActionText}} - + + catchtap="recoverVoiceLesson" + >{{lesson.failureRecoveryText}} + >{{lesson.failureDeleteText}} diff --git a/pages/feedback/voice-state.ts b/pages/feedback/voice-state.ts index 2440f2a..148c9c5 100644 --- a/pages/feedback/voice-state.ts +++ b/pages/feedback/voice-state.ts @@ -1,6 +1,7 @@ import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types' export type VoicePrimaryAction = 'save' | 'retry' | 'processing' +export type VoiceLessonRecoveryAction = 'retry' | 'rerecord' | null export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction { if (!session || session.lessonCount === 0) return 'save' @@ -24,6 +25,15 @@ export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegm ) } +export function getVoiceLessonRecoveryAction( + lesson: VoiceLesson +): VoiceLessonRecoveryAction { + if (lesson.status !== 'failed') return null + return lesson.segments.some((segment) => segment.status === 'failed') + ? 'retry' + : 'rerecord' +} + export function getVoiceLessonStatusText(lesson: VoiceLesson, isInDraft = false): string { if (lesson.status === 'failed') return '转录失败' if (lesson.status === 'recording') return '录音中' diff --git a/server/src/recording_routes.rs b/server/src/recording_routes.rs index 73b4b0b..6b5906c 100644 --- a/server/src/recording_routes.rs +++ b/server/src/recording_routes.rs @@ -38,6 +38,7 @@ pub fn router() -> OpenApiRouter { .routes(routes!(finish_lesson_session)) .routes(routes!(mark_lesson_applied)) .routes(routes!(retry_audio_segment)) + .routes(routes!(discard_empty_failed_lesson)) .routes(routes!(discard_audio_segment)) .routes(routes!(create_feedback_summary_run)) .routes(routes!(get_feedback_summary_run)) @@ -796,6 +797,67 @@ async fn retry_audio_segment( })) } +#[utoipa::path( + delete, + path = "/api/v1/feedback-lessons/{lesson_id}", + tag = "语音反馈", + summary = "删除未保留音频的失败课堂", + description = "删除没有任何录音片段、因而无法重试转录的失败课堂记录。", + params( + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), + ("lesson_id" = Uuid, Path, description = "失败课堂录音 ID") + ), + responses( + (status = 204, description = "空失败课堂已删除"), + (status = 404, description = "课堂不存在、不属于当前用户或仍包含录音片段", body = ErrorBody), + CommonApiErrors + ) +)] +async fn discard_empty_failed_lesson( + State(state): State, + headers: HeaderMap, + Path(lesson_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 session_id = sqlx::query_scalar::<_, Uuid>( + "SELECT f.id FROM lesson_sessions l \ + JOIN feedback_sessions f ON f.id = l.feedback_session_id \ + WHERE l.id = $1 AND f.owner_id = $2 AND f.status = 'active' \ + AND l.status = 'failed' \ + AND NOT EXISTS (SELECT 1 FROM audio_segments s WHERE s.lesson_session_id = l.id) \ + FOR UPDATE OF l", + ) + .bind(lesson_id) + .bind(owner_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(ApiError::NotFound)?; + + sqlx::query("DELETE FROM lesson_sessions WHERE id = $1") + .bind(lesson_id) + .execute(&mut *transaction) + .await?; + sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1") + .bind(session_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + tracing::info!( + event = "empty_failed_lesson_discarded", + feedback_session_id = %session_id, + lesson_id = %lesson_id, + status = "discarded", + "empty failed lesson discarded" + ); + Ok(StatusCode::NO_CONTENT) +} + #[utoipa::path( delete, path = "/api/v1/audio-segments/{segment_id}", diff --git a/server/src/routes.rs b/server/src/routes.rs index b26faa5..2108df2 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -924,7 +924,8 @@ mod tests { .filter(|operation| operation.get("responses").is_some()) .collect::>(); - assert_eq!(operations.len(), 27); + assert_eq!(operations.len(), 28); + assert!(paths.contains_key("/api/v1/feedback-lessons/{lesson_id}")); assert!(paths.contains_key("/api/v1/feedback-sessions/{session_id}/summary-runs")); assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}")); assert!(paths.contains_key("/api/v1/feedback-summary-runs/{run_id}/apply")); diff --git a/tests/voice-state.test.js b/tests/voice-state.test.js index 878f8fa..520cc34 100644 --- a/tests/voice-state.test.js +++ b/tests/voice-state.test.js @@ -3,6 +3,7 @@ const test = require('node:test') const { getVoiceFailureHint, + getVoiceLessonRecoveryAction, getVoiceLessonStatusText, getVoicePrimaryAction, getVoicePrimaryActionText @@ -83,6 +84,23 @@ test('ready lessons explain whether their transcript can be added again', () => assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈') }) +test('failed lessons distinguish retryable audio from empty failed records', () => { + const failedLesson = { + status: 'failed', + segments: [] + } + + assert.equal(getVoiceLessonRecoveryAction(failedLesson), 'rerecord') + assert.equal(getVoiceLessonRecoveryAction({ + ...failedLesson, + segments: [{ status: 'failed' }] + }), 'retry') + assert.equal(getVoiceLessonRecoveryAction({ + ...failedLesson, + status: 'ready' + }), null) +}) + test('session refresh runs after a failed API action', async () => { const expected = new Error('generation rejected') let refreshCount = 0 diff --git a/utils/api.ts b/utils/api.ts index 113bc67..c755a8a 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -641,6 +641,13 @@ export async function discardVoiceSegment(segmentId: string): Promise { ) } +export async function discardEmptyVoiceLesson(lessonId: string): Promise { + await request( + `/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}`, + 'DELETE' + ) +} + export async function generateFeedbackFromVoice( sessionId: string, existingContent: string