fix(aihr): enforce practice audio ownership
This commit is contained in:
+9
-1
@@ -204,7 +204,7 @@ public class AihrMobileController {
|
||||
|
||||
@PostMapping("/practice/assignments/{id}/answer")
|
||||
public R<DailyDrillAnswerResponse> answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) {
|
||||
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null));
|
||||
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId());
|
||||
return response == null ? R.fail("每日三题不存在或已完成") : R.ok(response);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ public class AihrMobileController {
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
|
||||
private static Long currentAppUserId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return null;
|
||||
}
|
||||
return loginUser.getUserId();
|
||||
}
|
||||
|
||||
private static ReviewAdviceRequest bindReviewOperator(ReviewAdviceRequest request) {
|
||||
return new ReviewAdviceRequest(
|
||||
currentOperatorUsername(),
|
||||
|
||||
+9
-1
@@ -131,7 +131,7 @@ public class AihrPracticeController {
|
||||
|
||||
@PostMapping("/turn")
|
||||
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
|
||||
return R.ok(practiceSeedService.turn(request, currentAppUsername()));
|
||||
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/finish")
|
||||
@@ -187,6 +187,14 @@ public class AihrPracticeController {
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
|
||||
private static Long currentAppUserId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return null;
|
||||
}
|
||||
return loginUser.getUserId();
|
||||
}
|
||||
|
||||
private static String currentOperatorUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || loginUser.getUsername() == null) {
|
||||
|
||||
+4
@@ -182,6 +182,10 @@ public class AihrMobileSeedService {
|
||||
return practiceSeedService.answerDailyDrill(id, request, extPartyId);
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId, Long ownerUserId) {
|
||||
return practiceSeedService.answerDailyDrill(id, request, extPartyId, ownerUserId);
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
if (role == null) {
|
||||
return "user";
|
||||
|
||||
+42
-5
@@ -518,11 +518,16 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public TurnResponse turn(TurnRequest request, String ownerIdentity) {
|
||||
return turn(request, ownerIdentity, null);
|
||||
}
|
||||
|
||||
public TurnResponse turn(TurnRequest request, String ownerIdentity, Long ownerUserId) {
|
||||
String style = normalizeRegenerateStyle(request == null ? null : request.style());
|
||||
ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId());
|
||||
if (!isBlank(ownerIdentity)) {
|
||||
requireSessionOwner(session, ownerIdentity);
|
||||
}
|
||||
validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId);
|
||||
ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId());
|
||||
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
|
||||
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
|
||||
@@ -1455,6 +1460,7 @@ public class AihrPracticeSeedService {
|
||||
|
||||
public boolean canReadPracticeAudio(Long ossId, String identity) {
|
||||
ensureAudioTable();
|
||||
ensureAssignmentTable();
|
||||
if (ossId == null || isBlank(identity)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1464,24 +1470,50 @@ public class AihrPracticeSeedService {
|
||||
JOIN aihr_practice_session s
|
||||
ON s.tenant_id = a.tenant_id AND s.session_id = a.session_id
|
||||
WHERE a.tenant_id = ? AND a.oss_id = ? AND s.mode = 'mobile'
|
||||
""", (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), ossId);
|
||||
UNION ALL
|
||||
SELECT d.ext_party_id
|
||||
FROM aihr_practice_assignment d
|
||||
WHERE d.tenant_id = ? AND d.answer_audio_oss_id = ?
|
||||
""", (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), ossId, tenantId(), ossId);
|
||||
String owner = identity.trim();
|
||||
return sessionParties.stream().anyMatch(party -> owner.equals(party) || inTeamScope(owner, party));
|
||||
}
|
||||
|
||||
public boolean canReadPracticeAudioForTenant(Long ossId) {
|
||||
ensureAudioTable();
|
||||
ensureAssignmentTable();
|
||||
if (ossId == null) {
|
||||
return false;
|
||||
}
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM aihr_practice_audio
|
||||
WHERE tenant_id = ? AND oss_id = ?
|
||||
""", Integer.class, tenantId(), ossId);
|
||||
SELECT (
|
||||
SELECT COUNT(*) FROM aihr_practice_audio
|
||||
WHERE tenant_id = ? AND oss_id = ?
|
||||
) + (
|
||||
SELECT COUNT(*) FROM aihr_practice_assignment
|
||||
WHERE tenant_id = ? AND answer_audio_oss_id = ?
|
||||
)
|
||||
""", Integer.class, tenantId(), ossId, tenantId(), ossId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private void validateAudioOwnership(Long ossId, String ownerIdentity, Long ownerUserId) {
|
||||
if (ossId == null) {
|
||||
return;
|
||||
}
|
||||
if (isBlank(ownerIdentity) || ownerUserId == null || ownerUserId <= 0) {
|
||||
throw new ServiceException("录音归属校验失败,请重新录音后提交");
|
||||
}
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM sys_oss
|
||||
WHERE tenant_id = ? AND oss_id = ? AND create_by = ?
|
||||
""", Integer.class, tenantId(), ossId, ownerUserId);
|
||||
if (count == null || count == 0) {
|
||||
throw new ServiceException("录音不属于当前账号,请重新录音后提交");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean markReviewed(Long id) {
|
||||
return markReviewed(id, null);
|
||||
@@ -2135,6 +2167,10 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) {
|
||||
return answerDailyDrill(id, request, extPartyId, null);
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId, Long ownerUserId) {
|
||||
ensureAssignmentTable();
|
||||
if (id == null) {
|
||||
return null;
|
||||
@@ -2156,6 +2192,7 @@ public class AihrPracticeSeedService {
|
||||
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
|
||||
String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), "");
|
||||
Long audioOssId = request == null ? null : request.audioOssId();
|
||||
validateAudioOwnership(audioOssId, extPartyId, ownerUserId);
|
||||
DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
int updated = jdbcTemplate.update("""
|
||||
|
||||
+14
@@ -305,6 +305,20 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertEquals("训练会话不存在或无权访问", missing.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void practiceAudioMustMatchCurrentAppUploaderAndDailyAudioIsReadable() throws Exception {
|
||||
String service = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
|
||||
String controller = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrPracticeController.java"));
|
||||
String mobileController = Files.readString(Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java"));
|
||||
|
||||
assertTrue(service.contains("FROM sys_oss\n WHERE tenant_id = ? AND oss_id = ? AND create_by = ?"));
|
||||
assertTrue(service.contains("validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId)"));
|
||||
assertTrue(service.contains("validateAudioOwnership(audioOssId, extPartyId, ownerUserId)"));
|
||||
assertTrue(service.contains("d.answer_audio_oss_id = ?"));
|
||||
assertTrue(controller.contains("currentAppUserId()"));
|
||||
assertTrue(mobileController.contains("answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId())"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startChecksScenarioEnabledBeforeSeedFallback() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# BRD 功能审查与未完成项记录
|
||||
|
||||
- 2026-07-14 BRD G3 训练音频上传者边界修复:陪练回合和每日三题此前接受客户端直接提交的 `audioOssId`,服务端只在下载时按训练记录判断归属,存在把其他对象绑定到当前训练/作业的风险;现要求移动端提交的 OSS 对象必须由当前 APP 用户创建,且每日三题音频下载授权同时覆盖 `aihr_practice_assignment.answer_audio_oss_id`。ASR 上传后尚未绑定会话的孤儿对象清理仍需独立的短期 staging/过期策略,不用本次归属校验冒充已解决。
|
||||
- 2026-07-14 BRD G3 SOP 附件绑定补偿:同步/待处理图片文档此前在 `sys_oss` 上传成功后再写 `aihr_knowledge_attach`,附件写入失败时可能留下无业务引用对象;现仅对“附件尚未成功绑定”的异常路径做 OSS 补偿删除,解析失败后已存在的失败/待处理附件仍保留用于重试,不改变现有文档处理状态机。
|
||||
- 2026-07-14 BRD G3 OSS 生命周期补偿:候选资料和案例音频此前先写 `sys_oss`、再写业务记录,业务数据库写入失败时可能留下没有业务引用的对象;现对“本次上传新建且尚未绑定”的 `oss_id` 增加失败补偿删除,案例同时移除内存态,补充两条回归测试和 `demo-check` marker。该修复不触碰已有业务资源;SOP 分片/ASR 等复杂异步链路仍需按各自状态机单独核查,不能用本次补偿覆盖全部媒体生命周期。
|
||||
- 2026-07-14 BRD G3 文件名边界复核:候选资料、案例音频、同步 SOP 文档和 ASR 音频此前虽然分别校验或清理文件名,但仍有路径把原始 `MultipartFile` 直接交给 `sys_oss`,清理后的名字没有贯通到 OSS 元数据;现新增共用 `AihrMultipartFiles` 包装器,四条链路均以清理后的文件名上传/外发,并补单测与 `demo-check` marker。该修复不改变文件类型、大小、权限和业务状态,仍需正式环境抽查 OSS 原始文件名与下载响应头。
|
||||
|
||||
@@ -600,6 +600,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
|
||||
contains backend/ruoyi-admin/src/main/resources/application-dev.yml "allow-legacy-daily-drill-fallback: true"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "instr(coalesce(person_name, '')"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "validateAudioOwnership"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "d.answer_audio_oss_id = ?"
|
||||
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
|
||||
contains mobile-uni/src/services/practice.ts "/api/train/practice/scenarios?position="
|
||||
contains mobile-uni/src/pages/supervisor/assign/index.vue "scenarioOptions"
|
||||
|
||||
Reference in New Issue
Block a user