From 2f44e6b7d1d26e2dedd32425aa5b3bc9f47c7600 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 01:32:02 +0800 Subject: [PATCH] fix(aihr): close practice audio URL exposure --- .../aihr/controller/AihrSpeechController.java | 8 ++++-- .../aihr/service/AihrPracticeSeedService.java | 26 ++++++++++++++----- .../service/AihrPracticeSeedServiceTest.java | 2 ++ .../aihr/service/AihrSensitiveTextTest.java | 4 ++- docs/API_INTEGRATION.md | 6 ++--- docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + frontend/src/views/train/reviews.vue | 6 +++-- .../src/pages/supervisor/review/index.vue | 6 +++-- mobile-uni/src/pages/user/history/index.vue | 6 +++-- scripts/demo-check.sh | 5 ++++ 10 files changed, 51 insertions(+), 19 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java index 9bc29644..85c389c8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java @@ -75,7 +75,9 @@ public class AihrSpeechController { return R.ok(new AsrResponse(text, "openai-compatible")); } } - return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl())); + // The OSS URL is intentionally not exposed. Playback goes through the + // authenticated /api/aihr/mobile/oss/{ossId} endpoint after binding. + return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), "")); } catch (Exception e) { log.warn("asr audio upload failed(处理错误已隐藏)"); return R.ok(new AsrResponse(text, "openai-compatible")); @@ -135,7 +137,9 @@ public class AihrSpeechController { Files.write(tempFile, audio); File file = tempFile.toFile(); SysOssVo oss = ossService.upload(file); - return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), "")); + // Keep the old client contract playable without returning a public OSS URL. + String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio); + return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible", oss.getOssId(), inlineAudioUrl)); } catch (Exception e) { log.warn("tts audio upload failed(处理错误已隐藏)"); String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 07bd38f1..1362b5d2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -2290,7 +2290,7 @@ public class AihrPracticeSeedService { } DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id")); String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), "")); - String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), ""); + String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl()); Long audioOssId = request == null ? null : request.audioOssId(); validateAudioOwnership(audioOssId, extPartyId, ownerUserId); DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer()); @@ -2327,7 +2327,7 @@ public class AihrPracticeSeedService { return null; } bindPracticeAudio(audioOssId); - return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), audioUrl, audioOssId); + return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), exposedAudioUrl(audioUrl), audioOssId); } public List promptTemplates(String category, String code, Boolean enabled) { @@ -2933,7 +2933,7 @@ public class AihrPracticeSeedService { correctValue == null ? null : rs.getBoolean("correct"), rs.getObject("score") == null ? null : rs.getInt("score"), firstNonBlank(rs.getString("score_mode"), "RULE_FALLBACK"), - rs.getString("answer_audio_url"), + exposedAudioUrl(rs.getString("answer_audio_url")), rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id") ); } @@ -3267,7 +3267,7 @@ public class AihrPracticeSeedService { while (audioUrls.size() <= roundIndex) { audioUrls.add(""); } - audioUrls.set(roundIndex, firstNonBlank(request.traineeAudioUrl(), "")); + audioUrls.set(roundIndex, exposedAudioUrl(request.traineeAudioUrl())); } List audioOssIds = session.traineeAudioOssIds(); synchronized (audioOssIds) { @@ -3631,7 +3631,7 @@ public class AihrPracticeSeedService { item.path("role").asText(), item.path("label").asText(), item.path("text").asText(), - item.path("audioUrl").asText(""), + exposedAudioUrl(item.path("audioUrl").asText("")), item.hasNonNull("audioOssId") ? item.path("audioOssId").asLong() : null, item.hasNonNull("emotion") ? item.path("emotion").asInt() : null, item.hasNonNull("trust") ? item.path("trust").asInt() : null, @@ -3667,7 +3667,7 @@ public class AihrPracticeSeedService { "trainee", "员工话术", replies.get(i), - i < audioUrls.size() ? audioUrls.get(i) : "", + i < audioUrls.size() ? exposedAudioUrl(audioUrls.get(i)) : "", i < audioOssIds.size() ? audioOssIds.get(i) : null, evidence == null ? null : evidence.emotion(), evidence == null ? null : evidence.trust(), @@ -3725,7 +3725,7 @@ public class AihrPracticeSeedService { turn.role(), turn.label(), maskSensitiveText(turn.text()), - firstNonBlank(turn.audioUrl(), ""), + exposedAudioUrl(turn.audioUrl()), turn.audioOssId(), turn.emotion(), turn.trust(), @@ -3739,6 +3739,18 @@ public class AihrPracticeSeedService { return AihrSensitiveText.forModel(text); } + /** + * Never expose a raw sys_oss URL through a practice response. OSS playback is + * authorized by audioOssId; only an inline data URL is safe to return directly. + */ + private static String exposedAudioUrl(String audioUrl) { + if (audioUrl == null || audioUrl.isBlank()) { + return ""; + } + String value = audioUrl.trim(); + return value.startsWith("data:audio/") ? value : ""; + } + private Integer scoreValue(List scores, String label) { for (DimensionResponse score : scores) { if (label.equals(score.label()) && score.value() instanceof Number) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index bcaa7f64..36472a01 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -317,6 +317,8 @@ public class AihrPracticeSeedServiceTest { assertTrue(service.contains("d.answer_audio_oss_id = ?")); assertTrue(service.contains("aihr_practice_audio_upload")); assertTrue(service.contains("cleanupExpiredPracticeAudioUploads")); + assertTrue(service.contains("private static String exposedAudioUrl(String audioUrl)")); + assertTrue(service.contains("return value.startsWith(\"data:audio/\") ? value : \"\";")); assertTrue(controller.contains("currentAppUserId()")); assertTrue(mobileController.contains("answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId())")); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java index 2d5a9d4a..6f204369 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSensitiveTextTest.java @@ -75,7 +75,9 @@ class AihrSensitiveTextTest { void ttsUploadSuccessDoesNotDuplicateInlineAudio() throws Exception { String speechControllerSource = Files.readString(controllerSource("AihrSpeechController.java")); - assertTrue(speechControllerSource.contains("new TtsResponse(oss.getUrl(), \"openai-compatible\", oss.getOssId(), \"\")")); + assertTrue(speechControllerSource.contains("new AsrResponse(text, \"openai-compatible\", oss.getOssId(), \"\")")); + assertTrue(speechControllerSource.contains("new TtsResponse(inlineAudioUrl, \"openai-compatible\", oss.getOssId(), inlineAudioUrl)")); + assertFalse(speechControllerSource.contains("new TtsResponse(oss.getUrl(),")); assertTrue(speechControllerSource.contains("new TtsResponse(inlineAudioUrl, \"openai-compatible\")")); } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index c140ac5e..70b97049 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -10,8 +10,8 @@ | 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 | | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 seed,契约不变 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | -| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,成功返回 OSS `audioUrl + ossId`,OSS 上传失败才回退 `inlineAudioUrl` dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问;正常 TTS 响应不重复携带完整 base64 音频 | -| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | +| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | +| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | @@ -140,7 +140,7 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \ - 模型配置 API 文件:`frontend/src/api/aihr/model.ts` - 模型配置页面:`frontend/src/views/system/model/index.vue` - 用户侧工程:`mobile-uni/`,路由为 `/h5/#/pages/user/today/index`、`/h5/#/pages/user/practice/index`、`/h5/#/pages/user/sop/index`、`/h5/#/pages/user/profile/index`、`/h5/#/pages/candidate/index/index`、`/h5/#/pages/supervisor/index/index`。未登录先走手机号短信登录,登录后 API 优先请求真实业务接口,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。 -- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;每日三题支持文字或录音转写,作答接口 `/api/aihr/mobile/practice/assignments/{id}/answer` 可携带 `answer/audioUrl/audioOssId`,语音地址会随作业记录保存;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。能力画像按 BRD 五维返回任务完成度、话术规范性、情绪管理能力、响应时效、增值转化潜力;暂无工单/时效数据的维度返回 `null`,前端显示“待采集”,不把训练次数当正式绩效指标。 +- 员工端训练:底部“练”进入 `/h5/#/pages/user/practice/index`,展示今日任务、每日三题、专项训练、训练前预习卡和训练对话;每日三题支持文字或录音转写,作答接口 `/api/aihr/mobile/practice/assignments/{id}/answer` 可携带 `answer/audioUrl/audioOssId`,服务端只持久化可安全回放的内联 data URL,OSS 音频通过 `audioOssId` 受保护下载;完成后记录写入 `aihr_practice_session`,员工端可提交 1-5 分训练体验和可选意见,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。能力画像按 BRD 五维返回任务完成度、话术规范性、情绪管理能力、响应时效、增值转化潜力;暂无工单/时效数据的维度返回 `null`,前端显示“待采集”,不把训练次数当正式绩效指标。 - 候选人端:`/h5/#/pages/candidate/index/index` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。 - 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index d1ceb633..6589a06e 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -410,3 +410,4 @@ - 2026-07-15 BRD G3 候选人面试操作角色边界修复:`/api/recruit/interview/start`、`answer`、`finish` 此前只要求登录,普通后台系统用户可绕过 HR/管理员权限创建或完成面试;现统一限制为 APP 候选人本人或 `superadmin/hr_operator`,候选人会话仍按本人手机号绑定。新增源码契约测试和 `demo-check` marker;尚未发布生产。 - 2026-07-15 BRD G3 面试未知会话状态边界修复:系统用户调用 `answer` 携带不存在或已失效的 `sessionId` 此前会返回“已暂存”而不暴露会话无效;现统一要求会话存在且属于当前租户,`finish` 保留 HR 管理端既有的兼容性创建逻辑。新增服务回归测试和 `demo-check` marker;尚未发布生产。 - 2026-07-15 BRD 发布状态只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 HTTP `200`,`wygj-aihr.service` 为 `active`;生产 H5 仍加载 `index-CDn6TrX8.js`,本地最新候选人面试权限/未知会话修复提交 `217edb09` 尚未证明已发布。本轮仅执行 HTTP、服务状态和 H5 资源只读检查,未同步静态资源、重启后端、执行迁移或写入生产业务数据。 +# 2026-07-15 BRD G3/G6 训练语音原始地址暴露收紧:ASR/TTS 成功响应此前可直接返回 `sys_oss.url`,训练历史、主管复盘和管理端复盘在缺少 `audioOssId` 时也会回退播放原始 `audioUrl`;现统一只回传受保护 OSS 编号,服务端持久化/响应仅保留 `data:audio/*` 内联降级,客户端也拒绝 HTTP 音频地址直接播放。TTS 仍写入 `sys_oss`,旧移动端通过内联 data URL 保持可播放;已补源码契约测试和两端回放边界,未修改生产资源或生产数据。 diff --git a/frontend/src/views/train/reviews.vue b/frontend/src/views/train/reviews.vue index 7bf27128..88c9bdef 100644 --- a/frontend/src/views/train/reviews.vue +++ b/frontend/src/views/train/reviews.vue @@ -204,8 +204,10 @@ const calibrationMetric = computed(() => (metrics.calibrationCount === null ? '- type ReviewDialogueTurn = ReviewDetail['dialogue'][number]; const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`; -const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioUrl || turn.audioOssId); -const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : ''); +const inlineAudioSource = (turn: ReviewDialogueTurn) => + turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : ''; +const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioOssId || inlineAudioSource(turn)); +const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || inlineAudioSource(turn); const needsCalibration = () => Boolean(detail.value?.sessionId) && (reviewForm.correctedTotal !== detail.value.score || Boolean(reviewForm.calibrationReason.trim())); diff --git a/mobile-uni/src/pages/supervisor/review/index.vue b/mobile-uni/src/pages/supervisor/review/index.vue index d0e28e24..8fea1bc8 100644 --- a/mobile-uni/src/pages/supervisor/review/index.vue +++ b/mobile-uni/src/pages/supervisor/review/index.vue @@ -197,9 +197,11 @@ const audioRequests = createAsyncGenerationGuard(); const editable = computed(() => isReviewEditable(detail.value?.status)); const audioKey = (index: number) => `${reviewId.value || 'review'}-${index}`; -const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl); +const inlineAudioSrc = (turn: ReviewDialogue) => + turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : ''; +const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || inlineAudioSrc(turn)); const audioSrc = (turn: ReviewDialogue, index: number) => - audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : ''); + audioSources.value[audioKey(index)] || inlineAudioSrc(turn); const releaseAudioSources = () => { audioRequests.invalidate(); diff --git a/mobile-uni/src/pages/user/history/index.vue b/mobile-uni/src/pages/user/history/index.vue index 858c54af..5f3c5254 100644 --- a/mobile-uni/src/pages/user/history/index.vue +++ b/mobile-uni/src/pages/user/history/index.vue @@ -96,8 +96,10 @@ const audioSources = ref>({}); const audioLoading = ref>({}); const audioErrors = ref>({}); -const audioSrc = (turn: ReviewDialogue, index: number) => audioSources.value[index] || turn.audioUrl || ''; -const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || turn.audioUrl); +const inlineAudioSrc = (turn: ReviewDialogue) => + turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : ''; +const audioSrc = (turn: ReviewDialogue, index: number) => audioSources.value[index] || inlineAudioSrc(turn); +const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || inlineAudioSrc(turn)); const releaseAudio = () => { Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl); diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index a09f3b61..90f02063 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -690,6 +690,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "canReadPracticeAudioForTenant" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "mobileSeedService.canReadPracticeAudioForTenant(ossId)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "oss.getOssId(), \"\")" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "new TtsResponse(inlineAudioUrl, \"openai-compatible\", oss.getOssId(), inlineAudioUrl)" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "exposedAudioUrl" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "asr audio upload failed(处理错误已隐藏)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "visionAllowed" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "!modelService.visionAllowed()" @@ -711,6 +713,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "文件处理失败,请重试或联系管理员" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "case audio upload failed(处理错误已隐藏)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "tts audio upload failed(处理错误已隐藏)" +contains mobile-uni/src/pages/user/history/index.vue "inlineAudioSrc" +contains mobile-uni/src/pages/supervisor/review/index.vue "inlineAudioSrc" +contains frontend/src/views/train/reviews.vue "inlineAudioSource" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSensitiveText.java "外部 AI 模型前" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "AihrSensitiveText.forModel(prompt)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "外部响应体已隐藏"