fix(aihr): enforce practice audio ownership

This commit is contained in:
2026-07-14 21:54:27 +08:00
parent 04b9b1bb96
commit d9b609b1cc
7 changed files with 81 additions and 7 deletions
@@ -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(),
@@ -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) {
@@ -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";
@@ -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("""
@@ -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"));