From 09c3cbecb06faa1f8ea7187a8f9f34064da7c64a Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 01:23:41 +0800 Subject: [PATCH] fix(aihr): protect case media playback --- .../aihr/controller/AihrCaseController.java | 18 +++++++ .../dromara/aihr/service/AihrCaseService.java | 51 ++++++++++++------- .../aihr/service/AihrCaseServiceTest.java | 15 +++++- docs/API_INTEGRATION.md | 1 + docs/BRD_IMPLEMENTATION_AUDIT.md | 2 + frontend/src/api/aihr/case.ts | 8 +++ frontend/src/views/knowledge/cases.vue | 33 ++++++++++-- mobile-uni/src/pages/user/cases/index.vue | 38 ++++++++++++-- mobile-uni/src/services/case.ts | 5 +- scripts/demo-check.sh | 6 +++ 10 files changed, 146 insertions(+), 31 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java index 0f7a0a5d..8d290645 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java @@ -2,6 +2,7 @@ package org.dromara.aihr.controller; import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.stp.StpUtil; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.model.LoginUser; @@ -18,6 +19,7 @@ import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.service.AihrCaseService; import org.dromara.common.core.domain.R; +import org.dromara.system.service.ISysOssService; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; /** @@ -43,6 +46,7 @@ public class AihrCaseController { private static final String HR_OPERATOR_ROLE = "hr_operator"; private final AihrCaseService caseService; + private final ISysOssService ossService; @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) { @@ -85,6 +89,20 @@ public class AihrCaseController { return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail); } + @GetMapping("/records/{caseId}/media") + public void media(@PathVariable String caseId, HttpServletResponse response) throws IOException { + if (!canViewCases()) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权查看案例音频"); + return; + } + Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes()); + if (mediaOssId == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "案例音频不存在或无权访问"); + return; + } + ossService.download(mediaOssId, response); + } + @PostMapping("/records/{caseId}/review") public R review(@PathVariable String caseId, @RequestBody ReviewRequest request) { if (!canManageCases()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index edcb3d44..82a7ab29 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -77,7 +77,7 @@ public class AihrCaseService { List.of(), "", media.ossId(), media.url(), currentCaseOwner()); cases.put(caseId, state); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); - return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId()); + return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", "", media.ossId()); } catch (IllegalStateException e) { cleanupFailedUpload(caseId, mediaOssId); throw e; @@ -199,7 +199,7 @@ public class AihrCaseService { firstNonBlank(rs.getString("owner"), "培训组"), firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("summary"), ""), - resolveMediaUrl(mediaOssId, rs.getString("media_url")), + "", mediaOssId, parseLearningPoints(rs.getString("learning_points")), firstNonBlank(rs.getString("supervisor_comment"), "") @@ -207,6 +207,36 @@ public class AihrCaseService { }, args.toArray()).stream().findFirst().orElse(null); } + /** + * Resolve a case audio object only after the same case/project authorization used by detail(). + * The controller streams the object through the authenticated API instead of exposing the raw OSS URL. + */ + public Long authorizedMediaOssId(String caseId, List projectScopes) { + String normalizedCaseId = clean(caseId); + if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) { + return null; + } + ensureCaseTable(); + List args = new ArrayList<>(); + args.add(normalizedCaseId); + String projectWhere = ""; + if (projectScopes != null) { + projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; + args.addAll(projectScopes); + } + String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; + String sql = """ + SELECT media_oss_id + FROM aihr_case_record + WHERE tenant_id = ? AND case_id = ? + """ + projectWhere + publishedWhere + " LIMIT 1"; + args.add(0, tenantId()); + return jdbcTemplate.query(sql, (rs, rowNum) -> rs.getObject("media_oss_id", Long.class), args.toArray()) + .stream() + .findFirst() + .orElse(null); + } + public List projectScopesForAppUser(String identity) { String username = clean(identity); if (username.isBlank()) { @@ -496,23 +526,6 @@ public class AihrCaseService { } } - private String resolveMediaUrl(Long mediaOssId, String persistedUrl) { - String fallback = firstNonBlank(persistedUrl, ""); - if (mediaOssId == null || ossService == null) { - return fallback; - } - try { - return ossService.listByIds(List.of(mediaOssId)).stream() - .findFirst() - .map(SysOssVo::getUrl) - .map(url -> firstNonBlank(url, fallback)) - .orElse(fallback); - } catch (Exception e) { - log.debug("refresh case media url failed for ossId={}", mediaOssId); - return fallback; - } - } - private void addCaseColumn(String column, String sql) { try { jdbcTemplate.execute(sql); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java index 1dbbf5f5..6f816eb0 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java @@ -70,7 +70,7 @@ class AihrCaseServiceTest { UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); assertEquals(123L, response.mediaOssId()); - assertEquals("https://oss.local/cases/case.webm", response.mediaUrl()); + assertEquals("", response.mediaUrl()); assertEquals(123L, jdbcTemplate.updateArgs[11]); assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]); } @@ -164,12 +164,23 @@ class AihrCaseServiceTest { assertEquals("persisted-case", detail.caseId()); assertEquals("业主投诉后完成跟进", detail.transcript()); - assertEquals("https://oss.local/cases/refreshed-case.webm", detail.mediaUrl()); + assertEquals("", detail.mediaUrl()); assertEquals(456L, detail.mediaOssId()); assertEquals("P1", jdbcTemplate.lastArgs[2]); assertTrue(jdbcTemplate.lastSql.contains("status = '已入库'")); } + @Test + @Tag("dev") + void mediaObjectIdUsesTheSamePublishedProjectScopeAsCaseDetail() { + PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); + + assertEquals(456L, service.authorizedMediaOssId("persisted-case", List.of("P1"))); + assertTrue(jdbcTemplate.lastSql.contains("media_oss_id")); + assertTrue(jdbcTemplate.lastSql.contains("status = '已入库'")); + } + @Test @Tag("dev") void appCaseRecordsOnlyExposePublishedCases() { diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 5be9bedf..c140ac5e 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -12,6 +12,7 @@ | 正式试点数据导出 | `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` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | +| 案例媒体安全 | `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 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 48c5e85e..d1ceb633 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,7 @@ # BRD 功能审查与未完成项记录 +- 2026-07-15 BRD G3/4.6 案例音频地址暴露收紧:案例详情和上传响应此前直接返回 `sys_oss.url`,客户端可绕过案例接口的登录与项目范围校验访问原始音频;现新增 `GET /api/knowledge/case/records/{caseId}/media`,复用案例查看权限和项目范围后由服务端流式下载 OSS,管理端与 `mobile-uni` 改用鉴权 blob/temp 文件播放;详情/上传响应不再返回原始 OSS URL。已通过 AIHR 全量测试、管理端构建和 `mobile-uni` H5 构建;未修改生产资源或生产数据,生产发布仍需窗口、备份/回滚责任和回归验证。 + - 2026-07-15 当前提交线上状态复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上 H5 仍引用 `assets/index-CDn6TrX8.js`,最新提交 `7272fce5` 的岗位 SOP 登录保护尚未发布。此次仅做 GET 只读检查,未执行静态同步、数据库迁移、后端重启或业务写入;生产发布仍需窗口、备份/回滚责任和认证态回归。 - 2026-07-15 BRD G3 知识导航匿名访问收紧:岗位 SOP 导航接口虽然只返回学习摘要,但后端此前使用 `@SaIgnore`,匿名请求可以直接读取生活顾问流程主题;移动端实际只在登录后调用。现改为 `@SaCheckLogin`,补源码回归和 `demo-check` marker;不改变登录后的员工学习路径,也未修改知识库数据。 diff --git a/frontend/src/api/aihr/case.ts b/frontend/src/api/aihr/case.ts index 8581f6a1..b555b28a 100644 --- a/frontend/src/api/aihr/case.ts +++ b/frontend/src/api/aihr/case.ts @@ -106,6 +106,14 @@ export function getCaseDetail(caseId: string): Promise { + return request({ + url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/media`, + method: 'get', + responseType: 'blob' + }); +} + export function reviewKnowledgeCase(caseId: string, comment: string): Promise> { return request({ url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`, diff --git a/frontend/src/views/knowledge/cases.vue b/frontend/src/views/knowledge/cases.vue index d150e3f3..e63ce47e 100644 --- a/frontend/src/views/knowledge/cases.vue +++ b/frontend/src/views/knowledge/cases.vue @@ -133,7 +133,7 @@ - + @@ -171,11 +173,12 @@