fix(aihr): protect case media playback
This commit is contained in:
+18
@@ -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()) {
|
||||
|
||||
+32
-19
@@ -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);
|
||||
|
||||
+13
-2
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user