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 4f6cdb1f..56ac8e1e 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 @@ -1,6 +1,9 @@ package org.dromara.aihr.controller; import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.model.LoginUser; +import org.dromara.common.core.enums.UserType; +import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.aihr.domain.AihrCaseDto.CurateRequest; import org.dromara.aihr.domain.AihrCaseDto.CurateResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; @@ -33,21 +36,32 @@ public class AihrCaseController { @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) { - return R.ok(caseService.upload(file, projectExtOrgId)); + return R.ok(caseService.upload(file, projectExtOrgId, currentProjectScopes())); } @PostMapping("/organize") public R organize(@RequestBody OrganizeRequest request) { - return R.ok(caseService.organize(request)); + return R.ok(caseService.organize(request, currentProjectScopes())); } @PostMapping("/curate") public R curate(@RequestBody CurateRequest request) { - return R.ok(caseService.curate(request)); + return R.ok(caseService.curate(request, currentProjectScopes())); } @GetMapping("/records") public R> records(@RequestParam(value = "limit", required = false) Integer limit) { - return R.ok(caseService.records(limit)); + return R.ok(caseService.records(limit, currentProjectScopes())); + } + + private List currentProjectScopes() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) { + return null; + } + if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + return List.of(); + } + return caseService.projectScopesForAppUser(loginUser.getUsername()); } } 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 fce14b08..52f933d3 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 @@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -19,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,10 +41,11 @@ public class AihrCaseService { private final Map cases = new ConcurrentHashMap<>(); private volatile boolean tableReady; - public UploadResponse upload(MultipartFile file, String projectExtOrgId) { + public UploadResponse upload(MultipartFile file, String projectExtOrgId, List projectScopes) { if (file == null || file.isEmpty()) { throw new IllegalArgumentException("请上传语音文件"); } + String scopedProject = resolveUploadProject(projectExtOrgId, projectScopes); String fileName = firstNonBlank(file.getOriginalFilename(), "case-audio.webm"); String contentType = firstNonBlank(file.getContentType(), "application/octet-stream"); try { @@ -50,7 +53,7 @@ public class AihrCaseService { .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); transcript = maskSensitiveText(transcript); String caseId = "case-" + UUID.randomUUID(); - CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null); + CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null); cases.put(caseId, state); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr"); @@ -62,8 +65,8 @@ public class AihrCaseService { } } - public OrganizeResponse organize(OrganizeRequest request) { - CaseState state = requireCase(request == null ? null : request.caseId()); + public OrganizeResponse organize(OrganizeRequest request, List projectScopes) { + CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes); CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary()); cases.put(state.id(), next); @@ -71,8 +74,8 @@ public class AihrCaseService { return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source()); } - public CurateResponse curate(CurateRequest request) { - CaseState state = requireCase(request == null ? null : request.caseId()); + public CurateResponse curate(CurateRequest request, List projectScopes) { + CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes); List summary = state.summary() == null ? localSummary(state).summary() : state.summary(); String title = caseTitle(summary, state.fileName()); RecordResponse record = new RecordResponse( @@ -87,23 +90,57 @@ public class AihrCaseService { return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,已进入案例库。"); } - public List records(Integer limit) { + public List records(Integer limit, List projectScopes) { ensureCaseTable(); + if (projectScopes != null && projectScopes.isEmpty()) { + return List.of(); + } int safeLimit = limit == null ? 10 : Math.max(1, Math.min(20, limit)); - return jdbcTemplate.query(""" + List args = new ArrayList<>(); + String projectWhere = ""; + if (projectScopes != null) { + projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; + args.addAll(projectScopes); + } + args.add(safeLimit); + String sql = """ SELECT title, type, status, update_time, owner, summary FROM aihr_case_record WHERE tenant_id = '000000' + """ + projectWhere + """ ORDER BY update_time DESC, id DESC LIMIT ? - """, (rs, rowNum) -> new RecordResponse( + """; + return jdbcTemplate.query(sql, (rs, rowNum) -> new RecordResponse( firstNonBlank(rs.getString("title"), "现场案例"), firstNonBlank(rs.getString("type"), "现场案例"), firstNonBlank(rs.getString("status"), "已入库"), rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), firstNonBlank(rs.getString("owner"), "培训组"), firstNonBlank(rs.getString("summary"), "") - ), safeLimit); + ), args.toArray()); + } + + public List projectScopesForAppUser(String identity) { + String username = clean(identity); + if (username.isBlank()) { + return List.of(); + } + try { + return jdbcTemplate.query(""" + SELECT DISTINCT project_ext_org_id + FROM aihr_org_snapshot + WHERE tenant_id = '000000' + AND employment_status = 'active' + AND project_ext_org_id IS NOT NULL + AND project_ext_org_id <> '' + AND (person_phone = ? OR ext_party_id = ?) + ORDER BY project_ext_org_id + """, (rs, rowNum) -> rs.getString("project_ext_org_id"), username, username); + } catch (DataAccessException e) { + log.warn("resolve app user project scope failed: {}", e.getMessage()); + return List.of(); + } } private void saveCase(CaseState state, String status, String title, String summary) { @@ -225,7 +262,32 @@ public class AihrCaseService { return new CaseSummary(summary, tags, "已按真实转写生成本地结构化案例稿。", "local-transcript"); } - private CaseState requireCase(String caseId) { + static boolean caseProjectAllowed(String projectExtOrgId, List projectScopes) { + return projectScopes == null + || (!projectScopes.isEmpty() && projectScopes.contains(clean(projectExtOrgId))); + } + + private String resolveUploadProject(String requestedProject, List projectScopes) { + if (projectScopes == null) { + return firstNonBlank(requestedProject, ""); + } + if (projectScopes.isEmpty()) { + throw new IllegalArgumentException("当前账号没有可用项目身份,暂不能提交案例"); + } + String requested = clean(requestedProject); + if (requested.isBlank()) { + if (projectScopes.size() != 1) { + throw new IllegalArgumentException("当前账号对应多个项目,请明确选择案例所属项目"); + } + return projectScopes.get(0); + } + if (!projectScopes.contains(requested)) { + throw new IllegalArgumentException("无权向该项目提交案例"); + } + return requested; + } + + private CaseState requireCase(String caseId, List projectScopes) { String normalizedCaseId = clean(caseId); CaseState state = cases.get(normalizedCaseId); if (state == null && !normalizedCaseId.isBlank()) { @@ -251,9 +313,16 @@ public class AihrCaseService { if (state == null) { throw new IllegalArgumentException("案例不存在或已过期,请重新上传语音"); } + if (!caseProjectAllowed(state.projectExtOrgId(), projectScopes)) { + throw new IllegalArgumentException("无权访问该项目案例"); + } return state; } + private static String placeholders(int size) { + return String.join(",", Collections.nCopies(size, "?")); + } + private static List tagsFromText(String text) { String source = clean(text); List tags = new ArrayList<>(); 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 77006ee1..f833bfae 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 @@ -15,6 +15,7 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -32,7 +33,7 @@ class AihrCaseServiceTest { CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate); - UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1"); + UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); assertEquals("张*先生住在12栋****,手机号1390****1111,说2-****漏水", response.transcript()); assertEquals(response.transcript(), jdbcTemplate.updateArgs[6]); @@ -50,13 +51,22 @@ class AihrCaseServiceTest { var response = service.curate(new org.dromara.aihr.domain.AihrCaseDto.CurateRequest( "persisted-case", "可复用培训案例", 1 - )); + ), null); assertEquals(List.of("persisted-case"), response.selectedCaseIds()); assertEquals("已入库", response.records().get(0).status()); assertEquals("persisted-case", jdbcTemplate.lastCaseId); } + @Test + @Tag("dev") + void appCaseScopeMustMatchAnActiveProject() { + assertTrue(AihrCaseService.caseProjectAllowed("P1", List.of("P1", "P2"))); + assertFalse(AihrCaseService.caseProjectAllowed("P3", List.of("P1", "P2"))); + assertFalse(AihrCaseService.caseProjectAllowed("P1", List.of())); + assertTrue(AihrCaseService.caseProjectAllowed("P1", null)); + } + private static class CaseJdbcTemplate extends JdbcTemplate { private Object[] updateArgs = new Object[0]; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 49f92d8e..213d3507 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -10,7 +10,7 @@ | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 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}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | -| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | +| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库和列表均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;管理端系统用户保留全局运营视图 | | 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` 等字段。默认 `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 7d1a849c..37af8919 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -18,7 +18,7 @@ | 候选人面试与 HR 复核 | `mobile-uni/src/pages/candidate/*`、`frontend/src/views/recruit/interview.vue` | 工程闭环已在位,需继续保留人工复核证据 | | SOP/RAG/资料处理 | `frontend/src/views/knowledge/*`、`backend/.../AihrSopSeedService.java` | 搜索、引用、反馈、异步处理和评审入口已在位 | | 对练/每日题/主管带教 | `backend/.../AihrPracticeSeedService.java`、`mobile-uni/src/pages/user/practice` | 主闭环已在位;低分再练、错题归因仍需更明确的用户可见闭环 | -| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 | +| 案例上传/整理/筛选 | `backend/.../AihrCaseService.java`、`mobile-uni/src/pages/user/cases`、`frontend/src/views/knowledge/cases.vue` | 上传与整理已在位,案例状态在内存缺失时可从 `aihr_case_record` 回读恢复;APP 用户的上传、整理、入库和列表现在按 `aihr_org_snapshot` 项目范围校验,缺少正式组织映射时拒绝访问;当前仓库仍没有可用预渲染视频样片资产,也没有稳定的案例详情/学习点模型,不能用假播放器宣称案例学习闭环完成 | ## 3. 未完成项与优先级