fix(aihr): scope case APIs by project

This commit is contained in:
2026-07-14 04:54:01 +08:00
parent afa99f1fec
commit b2bab8633e
5 changed files with 112 additions and 19 deletions
@@ -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<UploadResponse> 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<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
return R.ok(caseService.organize(request));
return R.ok(caseService.organize(request, currentProjectScopes()));
}
@PostMapping("/curate")
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
return R.ok(caseService.curate(request));
return R.ok(caseService.curate(request, currentProjectScopes()));
}
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false) Integer limit) {
return R.ok(caseService.records(limit));
return R.ok(caseService.records(limit, currentProjectScopes()));
}
private List<String> 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());
}
}
@@ -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<String, CaseState> cases = new ConcurrentHashMap<>();
private volatile boolean tableReady;
public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
public UploadResponse upload(MultipartFile file, String projectExtOrgId, List<String> 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<String> 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<String> projectScopes) {
CaseState state = requireCase(request == null ? null : request.caseId(), projectScopes);
List<SummaryResponse> 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<RecordResponse> records(Integer limit) {
public List<RecordResponse> records(Integer limit, List<String> 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<Object> 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<String> 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<String> projectScopes) {
return projectScopes == null
|| (!projectScopes.isEmpty() && projectScopes.contains(clean(projectExtOrgId)));
}
private String resolveUploadProject(String requestedProject, List<String> 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<String> 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<TagResponse> tagsFromText(String text) {
String source = clean(text);
List<TagResponse> tags = new ArrayList<>();
@@ -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];
+1 -1
View File
@@ -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 名试点人员手机号 |
+1 -1
View File
@@ -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. 未完成项与优先级