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() {
+1
View File
@@ -12,6 +12,7 @@
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,成功返回 OSS `audioUrl + ossId`,OSS 上传失败才回退 `inlineAudioUrl` dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问;正常 TTS 响应不重复携带完整 base64 音频 |
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`position-sop` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
+2
View File
@@ -1,5 +1,7 @@
# BRD 功能审查与未完成项记录
- 2026-07-15 BRD G3/4.6 案例音频地址暴露收紧:案例详情和上传响应此前直接返回 `sys_oss.url`,客户端可绕过案例接口的登录与项目范围校验访问原始音频;现新增 `GET /api/knowledge/case/records/{caseId}/media`,复用案例查看权限和项目范围后由服务端流式下载 OSS,管理端与 `mobile-uni` 改用鉴权 blob/temp 文件播放;详情/上传响应不再返回原始 OSS URL。已通过 AIHR 全量测试、管理端构建和 `mobile-uni` H5 构建;未修改生产资源或生产数据,生产发布仍需窗口、备份/回滚责任和回归验证。
- 2026-07-15 当前提交线上状态复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上 H5 仍引用 `assets/index-CDn6TrX8.js`,最新提交 `7272fce5` 的岗位 SOP 登录保护尚未发布。此次仅做 GET 只读检查,未执行静态同步、数据库迁移、后端重启或业务写入;生产发布仍需窗口、备份/回滚责任和认证态回归。
- 2026-07-15 BRD G3 知识导航匿名访问收紧:岗位 SOP 导航接口虽然只返回学习摘要,但后端此前使用 `@SaIgnore`,匿名请求可以直接读取生活顾问流程主题;移动端实际只在登录后调用。现改为 `@SaCheckLogin`,补源码回归和 `demo-check` marker;不改变登录后的员工学习路径,也未修改知识库数据。
+8
View File
@@ -106,6 +106,14 @@ export function getCaseDetail(caseId: string): Promise<ApiResult<CaseDetailRespo
});
}
export function getCaseMedia(caseId: string): Promise<Blob> {
return request({
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/media`,
method: 'get',
responseType: 'blob'
});
}
export function reviewKnowledgeCase(caseId: string, comment: string): Promise<ApiResult<void>> {
return request({
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`,
+29 -4
View File
@@ -133,7 +133,7 @@
</el-table>
</section>
<el-dialog v-model="detailVisible" title="案例详情" width="720px">
<el-dialog v-model="detailVisible" title="案例详情" width="720px" @closed="releaseCaseMediaUrl">
<template v-if="caseDetail">
<div class="detail-meta">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }} · {{ caseDetail.fileName }}</div>
<div class="detail-block">
@@ -157,10 +157,12 @@
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
</div>
</div>
<div v-if="caseDetail.mediaUrl" class="detail-block">
<div v-if="caseMediaUrl" class="detail-block">
<strong>原始音频</strong>
<audio class="case-audio" :src="caseDetail.mediaUrl" controls preload="metadata" />
<audio class="case-audio" :src="caseMediaUrl" controls preload="metadata" />
</div>
<div v-else-if="caseMediaLoading" class="detail-block detail-muted">音频加载中…</div>
<div v-else-if="caseDetail.mediaOssId" class="detail-block detail-muted">音频加载失败,请重试或联系管理员</div>
<el-alert title="当前版本提供转写、摘要、学习点和主管点评;视频样片仍待正式媒体资源接入。" type="info" :closable="false" />
</template>
<el-empty v-else description="暂无详情" />
@@ -171,11 +173,12 @@
<script setup name="KnowledgeCases" lang="ts">
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
curateKnowledgeCase,
getCaseDetail,
getCaseMedia,
getCaseRecords,
organizeKnowledgeCase,
reviewKnowledgeCase,
@@ -206,6 +209,8 @@ const recordsLoading = ref(true);
const recordsFallback = ref(false);
const detailVisible = ref(false);
const caseDetail = ref<CaseDetailResponse | null>(null);
const caseMediaUrl = ref('');
const caseMediaLoading = ref(false);
const supervisorComment = ref('');
const reviewSaving = ref(false);
const projectExtOrgId = ref('');
@@ -314,16 +319,36 @@ async function loadCaseRecords() {
async function openCaseDetail(record: CaseRecord) {
if (!record.caseId) return;
releaseCaseMediaUrl();
caseMediaLoading.value = true;
try {
const { data } = await getCaseDetail(record.caseId);
caseDetail.value = data;
if (data.mediaOssId) {
try {
caseMediaUrl.value = URL.createObjectURL(await getCaseMedia(record.caseId));
} catch {
caseMediaUrl.value = '';
}
}
supervisorComment.value = data.supervisorComment || '';
detailVisible.value = true;
} catch {
ElMessage.error('案例详情加载失败');
} finally {
caseMediaLoading.value = false;
}
}
function releaseCaseMediaUrl() {
if (caseMediaUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(caseMediaUrl.value);
}
caseMediaUrl.value = '';
}
onBeforeUnmount(releaseCaseMediaUrl);
async function submitSupervisorReview() {
if (!caseDetail.value || reviewSaving.value) return;
reviewSaving.value = true;
+33 -5
View File
@@ -110,7 +110,7 @@
<text class="panel-title">案例详情</text>
<text class="record-desc">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }}</text>
</view>
<button class="ghost-button refresh-button" @click="caseDetail = null">关闭</button>
<button class="ghost-button refresh-button" @click="closeCaseDetail">关闭</button>
</view>
<text class="detail-label">原始转写</text>
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
@@ -127,10 +127,12 @@
<text class="detail-label">主管点评</text>
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
</view>
<view v-if="caseDetail.mediaUrl" class="case-media-block">
<view v-if="caseMediaUrl" class="case-media-block">
<text class="detail-label">原始音频</text>
<audio class="case-audio" :src="caseDetail.mediaUrl" controls />
<audio class="case-audio" :src="caseMediaUrl" controls />
</view>
<text v-else-if="caseMediaLoading" class="case-note">音频加载中…</text>
<text v-else-if="caseDetail.mediaOssId" class="case-note">音频加载失败,请重试或联系管理员</text>
<text class="case-note">当前版本已保留原始音频回放、学习点和主管点评;预渲染视频样片仍待正式媒体资源接入。</text>
</view>
</view>
@@ -138,9 +140,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onShow, onUnload } from '@dcloudio/uni-app';
import type { CaseCurateResponse, CaseDetailResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
import { chooseCaseAudio, curateCase, getCaseCapabilities, getCaseDetail, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
import { chooseCaseAudio, curateCase, getCaseCapabilities, getCaseDetail, getCaseMediaUrl, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
import { releaseAuthenticatedFileUrl } from '@/services/api';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
@@ -152,6 +155,8 @@ const organizeResult = ref<CaseOrganizeResponse | null>(null);
const curateResult = ref<CaseCurateResponse | null>(null);
const records = ref<CaseRecord[]>([]);
const caseDetail = ref<CaseDetailResponse | null>(null);
const caseMediaUrl = ref('');
const caseMediaLoading = ref(false);
const canSubmitCases = ref(false);
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
const recordsLoading = ref(false);
@@ -175,10 +180,16 @@ const clearCaseData = () => {
selectedFile.value = null;
records.value = [];
caseDetail.value = null;
clearCaseMediaUrl();
canSubmitCases.value = false;
resetResult();
};
const clearCaseMediaUrl = () => {
releaseAuthenticatedFileUrl(caseMediaUrl.value);
caseMediaUrl.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
@@ -246,16 +257,31 @@ const refreshRecords = () => {
const openCaseDetail = async (record: CaseRecord) => {
if (!record.caseId || caseDetailLoading.value) return;
caseDetailLoading.value = true;
caseMediaLoading.value = true;
message.value = '';
clearCaseMediaUrl();
try {
caseDetail.value = await getCaseDetail(record.caseId);
if (caseDetail.value.mediaOssId) {
try {
caseMediaUrl.value = await getCaseMediaUrl(record.caseId);
} catch {
caseMediaUrl.value = '';
}
}
} catch (error) {
handleRequestError(error, '案例详情加载失败');
} finally {
caseMediaLoading.value = false;
caseDetailLoading.value = false;
}
};
const closeCaseDetail = () => {
caseDetail.value = null;
clearCaseMediaUrl();
};
const submitCase = async () => {
if (!selectedFile.value || busy.value) return;
message.value = '';
@@ -305,6 +331,8 @@ const handleShow = async () => {
onShow(() => {
void handleShow();
});
onUnload(clearCaseMediaUrl);
</script>
<style scoped>
+4 -1
View File
@@ -7,7 +7,7 @@ import type {
CaseSelectedFile,
CaseUploadResponse
} from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -94,3 +94,6 @@ export const getCaseDetail = (caseId: string) =>
method: 'GET',
timeout: 15000
});
export const getCaseMediaUrl = (caseId: string) =>
authenticatedFileUrl(`/api/knowledge/case/records/${encodeURIComponent(caseId)}/media`);
+6
View File
@@ -650,7 +650,13 @@ contains mobile-uni/src/pages/supervisor/review/index.vue "scenarioOptions"
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/records?limit="
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/records/"
contains frontend/src/api/aihr/case.ts '/api/knowledge/case/records/${encodeURIComponent(caseId)}/media'
contains frontend/src/views/knowledge/cases.vue "loadCaseRecords"
contains frontend/src/views/knowledge/cases.vue "caseMediaUrl"
contains mobile-uni/src/services/case.ts '/api/knowledge/case/records/${encodeURIComponent(caseId)}/media'
contains mobile-uni/src/pages/user/cases/index.vue "releaseAuthenticatedFileUrl"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "records/{caseId}/media"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "authorizedMediaOssId"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "currentProjectScopes"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "projectScopesForAppUser"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"