fix(aihr): protect case media playback

This commit is contained in:
2026-07-15 01:23:41 +08:00
parent 05ba1a249b
commit 09c3cbecb0
10 changed files with 146 additions and 31 deletions
@@ -2,6 +2,7 @@ package org.dromara.aihr.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.model.LoginUser;
@@ -18,6 +19,7 @@ import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R;
import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
/**
@@ -43,6 +46,7 @@ public class AihrCaseController {
private static final String HR_OPERATOR_ROLE = "hr_operator";
private final AihrCaseService caseService;
private final ISysOssService ossService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<UploadResponse> upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) {
@@ -85,6 +89,20 @@ public class AihrCaseController {
return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail);
}
@GetMapping("/records/{caseId}/media")
public void media(@PathVariable String caseId, HttpServletResponse response) throws IOException {
if (!canViewCases()) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权查看案例音频");
return;
}
Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes());
if (mediaOssId == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "案例音频不存在或无权访问");
return;
}
ossService.download(mediaOssId, response);
}
@PostMapping("/records/{caseId}/review")
public R<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
if (!canManageCases()) {
@@ -77,7 +77,7 @@ public class AihrCaseService {
List.of(), "", media.ossId(), media.url(), currentCaseOwner());
cases.put(caseId, state);
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId());
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", "", media.ossId());
} catch (IllegalStateException e) {
cleanupFailedUpload(caseId, mediaOssId);
throw e;
@@ -199,7 +199,7 @@ public class AihrCaseService {
firstNonBlank(rs.getString("owner"), "培训组"),
firstNonBlank(rs.getString("transcript"), ""),
firstNonBlank(rs.getString("summary"), ""),
resolveMediaUrl(mediaOssId, rs.getString("media_url")),
"",
mediaOssId,
parseLearningPoints(rs.getString("learning_points")),
firstNonBlank(rs.getString("supervisor_comment"), "")
@@ -207,6 +207,36 @@ public class AihrCaseService {
}, args.toArray()).stream().findFirst().orElse(null);
}
/**
* Resolve a case audio object only after the same case/project authorization used by detail().
* The controller streams the object through the authenticated API instead of exposing the raw OSS URL.
*/
public Long authorizedMediaOssId(String caseId, List<String> projectScopes) {
String normalizedCaseId = clean(caseId);
if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) {
return null;
}
ensureCaseTable();
List<Object> args = new ArrayList<>();
args.add(normalizedCaseId);
String projectWhere = "";
if (projectScopes != null) {
projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")";
args.addAll(projectScopes);
}
String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'";
String sql = """
SELECT media_oss_id
FROM aihr_case_record
WHERE tenant_id = ? AND case_id = ?
""" + projectWhere + publishedWhere + " LIMIT 1";
args.add(0, tenantId());
return jdbcTemplate.query(sql, (rs, rowNum) -> rs.getObject("media_oss_id", Long.class), args.toArray())
.stream()
.findFirst()
.orElse(null);
}
public List<String> projectScopesForAppUser(String identity) {
String username = clean(identity);
if (username.isBlank()) {
@@ -496,23 +526,6 @@ public class AihrCaseService {
}
}
private String resolveMediaUrl(Long mediaOssId, String persistedUrl) {
String fallback = firstNonBlank(persistedUrl, "");
if (mediaOssId == null || ossService == null) {
return fallback;
}
try {
return ossService.listByIds(List.of(mediaOssId)).stream()
.findFirst()
.map(SysOssVo::getUrl)
.map(url -> firstNonBlank(url, fallback))
.orElse(fallback);
} catch (Exception e) {
log.debug("refresh case media url failed for ossId={}", mediaOssId);
return fallback;
}
}
private void addCaseColumn(String column, String sql) {
try {
jdbcTemplate.execute(sql);
@@ -70,7 +70,7 @@ class AihrCaseServiceTest {
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
assertEquals(123L, response.mediaOssId());
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
assertEquals("", response.mediaUrl());
assertEquals(123L, jdbcTemplate.updateArgs[11]);
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]);
}
@@ -164,12 +164,23 @@ class AihrCaseServiceTest {
assertEquals("persisted-case", detail.caseId());
assertEquals("业主投诉后完成跟进", detail.transcript());
assertEquals("https://oss.local/cases/refreshed-case.webm", detail.mediaUrl());
assertEquals("", detail.mediaUrl());
assertEquals(456L, detail.mediaOssId());
assertEquals("P1", jdbcTemplate.lastArgs[2]);
assertTrue(jdbcTemplate.lastSql.contains("status = '已入库'"));
}
@Test
@Tag("dev")
void mediaObjectIdUsesTheSamePublishedProjectScopeAsCaseDetail() {
PersistentCaseJdbcTemplate jdbcTemplate = new PersistentCaseJdbcTemplate();
AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null);
assertEquals(456L, service.authorizedMediaOssId("persisted-case", List.of("P1")));
assertTrue(jdbcTemplate.lastSql.contains("media_oss_id"));
assertTrue(jdbcTemplate.lastSql.contains("status = '已入库'"));
}
@Test
@Tag("dev")
void appCaseRecordsOnlyExposePublishedCases() {