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];