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.annotation.SaCheckLogin;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.dromara.common.core.constant.TenantConstants;
|
import org.dromara.common.core.constant.TenantConstants;
|
||||||
import org.dromara.common.core.domain.model.LoginUser;
|
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.domain.AihrCaseDto.UploadResponse;
|
||||||
import org.dromara.aihr.service.AihrCaseService;
|
import org.dromara.aihr.service.AihrCaseService;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.system.service.ISysOssService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +46,7 @@ public class AihrCaseController {
|
|||||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||||
|
|
||||||
private final AihrCaseService caseService;
|
private final AihrCaseService caseService;
|
||||||
|
private final ISysOssService ossService;
|
||||||
|
|
||||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public R<UploadResponse> upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) {
|
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);
|
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")
|
@PostMapping("/records/{caseId}/review")
|
||||||
public R<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
|
public R<Void> review(@PathVariable String caseId, @RequestBody ReviewRequest request) {
|
||||||
if (!canManageCases()) {
|
if (!canManageCases()) {
|
||||||
|
|||||||
+32
-19
@@ -77,7 +77,7 @@ public class AihrCaseService {
|
|||||||
List.of(), "", media.ossId(), media.url(), currentCaseOwner());
|
List.of(), "", media.ossId(), media.url(), currentCaseOwner());
|
||||||
cases.put(caseId, state);
|
cases.put(caseId, state);
|
||||||
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
|
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) {
|
} catch (IllegalStateException e) {
|
||||||
cleanupFailedUpload(caseId, mediaOssId);
|
cleanupFailedUpload(caseId, mediaOssId);
|
||||||
throw e;
|
throw e;
|
||||||
@@ -199,7 +199,7 @@ public class AihrCaseService {
|
|||||||
firstNonBlank(rs.getString("owner"), "培训组"),
|
firstNonBlank(rs.getString("owner"), "培训组"),
|
||||||
firstNonBlank(rs.getString("transcript"), ""),
|
firstNonBlank(rs.getString("transcript"), ""),
|
||||||
firstNonBlank(rs.getString("summary"), ""),
|
firstNonBlank(rs.getString("summary"), ""),
|
||||||
resolveMediaUrl(mediaOssId, rs.getString("media_url")),
|
"",
|
||||||
mediaOssId,
|
mediaOssId,
|
||||||
parseLearningPoints(rs.getString("learning_points")),
|
parseLearningPoints(rs.getString("learning_points")),
|
||||||
firstNonBlank(rs.getString("supervisor_comment"), "")
|
firstNonBlank(rs.getString("supervisor_comment"), "")
|
||||||
@@ -207,6 +207,36 @@ public class AihrCaseService {
|
|||||||
}, args.toArray()).stream().findFirst().orElse(null);
|
}, 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) {
|
public List<String> projectScopesForAppUser(String identity) {
|
||||||
String username = clean(identity);
|
String username = clean(identity);
|
||||||
if (username.isBlank()) {
|
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) {
|
private void addCaseColumn(String column, String sql) {
|
||||||
try {
|
try {
|
||||||
jdbcTemplate.execute(sql);
|
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);
|
UploadResponse response = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null);
|
||||||
|
|
||||||
assertEquals(123L, response.mediaOssId());
|
assertEquals(123L, response.mediaOssId());
|
||||||
assertEquals("https://oss.local/cases/case.webm", response.mediaUrl());
|
assertEquals("", response.mediaUrl());
|
||||||
assertEquals(123L, jdbcTemplate.updateArgs[11]);
|
assertEquals(123L, jdbcTemplate.updateArgs[11]);
|
||||||
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]);
|
assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[12]);
|
||||||
}
|
}
|
||||||
@@ -164,12 +164,23 @@ class AihrCaseServiceTest {
|
|||||||
|
|
||||||
assertEquals("persisted-case", detail.caseId());
|
assertEquals("persisted-case", detail.caseId());
|
||||||
assertEquals("业主投诉后完成跟进", detail.transcript());
|
assertEquals("业主投诉后完成跟进", detail.transcript());
|
||||||
assertEquals("https://oss.local/cases/refreshed-case.webm", detail.mediaUrl());
|
assertEquals("", detail.mediaUrl());
|
||||||
assertEquals(456L, detail.mediaOssId());
|
assertEquals(456L, detail.mediaOssId());
|
||||||
assertEquals("P1", jdbcTemplate.lastArgs[2]);
|
assertEquals("P1", jdbcTemplate.lastArgs[2]);
|
||||||
assertTrue(jdbcTemplate.lastSql.contains("status = '已入库'"));
|
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
|
@Test
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
void appCaseRecordsOnlyExposePublishedCases() {
|
void appCaseRecordsOnlyExposePublishedCases() {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
|
| 正式试点数据导出 | `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 音频 |
|
| 对练语音 | `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` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
|
| 案例沉淀 `/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` 目前只返回一期生活顾问学习导航,不代表正式上岗资格 |
|
| 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 |
|
| 资料处理 `/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 名试点人员手机号 |
|
| 组织人员同步 | `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 名试点人员手机号 |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# BRD 功能审查与未完成项记录
|
# 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 当前提交线上状态复核:生产根站、`/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;不改变登录后的员工学习路径,也未修改知识库数据。
|
- 2026-07-15 BRD G3 知识导航匿名访问收紧:岗位 SOP 导航接口虽然只返回学习摘要,但后端此前使用 `@SaIgnore`,匿名请求可以直接读取生活顾问流程主题;移动端实际只在登录后调用。现改为 `@SaCheckLogin`,补源码回归和 `demo-check` marker;不改变登录后的员工学习路径,也未修改知识库数据。
|
||||||
|
|||||||
@@ -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>> {
|
export function reviewKnowledgeCase(caseId: string, comment: string): Promise<ApiResult<void>> {
|
||||||
return request({
|
return request({
|
||||||
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`,
|
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/review`,
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" title="案例详情" width="720px">
|
<el-dialog v-model="detailVisible" title="案例详情" width="720px" @closed="releaseCaseMediaUrl">
|
||||||
<template v-if="caseDetail">
|
<template v-if="caseDetail">
|
||||||
<div class="detail-meta">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }} · {{ caseDetail.fileName }}</div>
|
<div class="detail-meta">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }} · {{ caseDetail.fileName }}</div>
|
||||||
<div class="detail-block">
|
<div class="detail-block">
|
||||||
@@ -157,10 +157,12 @@
|
|||||||
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
|
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="caseDetail.mediaUrl" class="detail-block">
|
<div v-if="caseMediaUrl" class="detail-block">
|
||||||
<strong>原始音频</strong>
|
<strong>原始音频</strong>
|
||||||
<audio class="case-audio" :src="caseDetail.mediaUrl" controls preload="metadata" />
|
<audio class="case-audio" :src="caseMediaUrl" controls preload="metadata" />
|
||||||
</div>
|
</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" />
|
<el-alert title="当前版本提供转写、摘要、学习点和主管点评;视频样片仍待正式媒体资源接入。" type="info" :closable="false" />
|
||||||
</template>
|
</template>
|
||||||
<el-empty v-else description="暂无详情" />
|
<el-empty v-else description="暂无详情" />
|
||||||
@@ -171,11 +173,12 @@
|
|||||||
<script setup name="KnowledgeCases" lang="ts">
|
<script setup name="KnowledgeCases" lang="ts">
|
||||||
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
|
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
curateKnowledgeCase,
|
curateKnowledgeCase,
|
||||||
getCaseDetail,
|
getCaseDetail,
|
||||||
|
getCaseMedia,
|
||||||
getCaseRecords,
|
getCaseRecords,
|
||||||
organizeKnowledgeCase,
|
organizeKnowledgeCase,
|
||||||
reviewKnowledgeCase,
|
reviewKnowledgeCase,
|
||||||
@@ -206,6 +209,8 @@ const recordsLoading = ref(true);
|
|||||||
const recordsFallback = ref(false);
|
const recordsFallback = ref(false);
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const caseDetail = ref<CaseDetailResponse | null>(null);
|
const caseDetail = ref<CaseDetailResponse | null>(null);
|
||||||
|
const caseMediaUrl = ref('');
|
||||||
|
const caseMediaLoading = ref(false);
|
||||||
const supervisorComment = ref('');
|
const supervisorComment = ref('');
|
||||||
const reviewSaving = ref(false);
|
const reviewSaving = ref(false);
|
||||||
const projectExtOrgId = ref('');
|
const projectExtOrgId = ref('');
|
||||||
@@ -314,16 +319,36 @@ async function loadCaseRecords() {
|
|||||||
|
|
||||||
async function openCaseDetail(record: CaseRecord) {
|
async function openCaseDetail(record: CaseRecord) {
|
||||||
if (!record.caseId) return;
|
if (!record.caseId) return;
|
||||||
|
releaseCaseMediaUrl();
|
||||||
|
caseMediaLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const { data } = await getCaseDetail(record.caseId);
|
const { data } = await getCaseDetail(record.caseId);
|
||||||
caseDetail.value = data;
|
caseDetail.value = data;
|
||||||
|
if (data.mediaOssId) {
|
||||||
|
try {
|
||||||
|
caseMediaUrl.value = URL.createObjectURL(await getCaseMedia(record.caseId));
|
||||||
|
} catch {
|
||||||
|
caseMediaUrl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
supervisorComment.value = data.supervisorComment || '';
|
supervisorComment.value = data.supervisorComment || '';
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('案例详情加载失败');
|
ElMessage.error('案例详情加载失败');
|
||||||
|
} finally {
|
||||||
|
caseMediaLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releaseCaseMediaUrl() {
|
||||||
|
if (caseMediaUrl.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(caseMediaUrl.value);
|
||||||
|
}
|
||||||
|
caseMediaUrl.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(releaseCaseMediaUrl);
|
||||||
|
|
||||||
async function submitSupervisorReview() {
|
async function submitSupervisorReview() {
|
||||||
if (!caseDetail.value || reviewSaving.value) return;
|
if (!caseDetail.value || reviewSaving.value) return;
|
||||||
reviewSaving.value = true;
|
reviewSaving.value = true;
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
<text class="panel-title">案例详情</text>
|
<text class="panel-title">案例详情</text>
|
||||||
<text class="record-desc">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }}</text>
|
<text class="record-desc">{{ caseDetail.time }} · {{ caseDetail.owner }} · {{ caseDetail.status }}</text>
|
||||||
</view>
|
</view>
|
||||||
<button class="ghost-button refresh-button" @click="caseDetail = null">关闭</button>
|
<button class="ghost-button refresh-button" @click="closeCaseDetail">关闭</button>
|
||||||
</view>
|
</view>
|
||||||
<text class="detail-label">原始转写</text>
|
<text class="detail-label">原始转写</text>
|
||||||
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
|
<text class="case-content">{{ caseDetail.transcript || '暂无转写内容' }}</text>
|
||||||
@@ -127,10 +127,12 @@
|
|||||||
<text class="detail-label">主管点评</text>
|
<text class="detail-label">主管点评</text>
|
||||||
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
|
<text class="case-note">{{ caseDetail.supervisorComment }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="caseDetail.mediaUrl" class="case-media-block">
|
<view v-if="caseMediaUrl" class="case-media-block">
|
||||||
<text class="detail-label">原始音频</text>
|
<text class="detail-label">原始音频</text>
|
||||||
<audio class="case-audio" :src="caseDetail.mediaUrl" controls />
|
<audio class="case-audio" :src="caseMediaUrl" controls />
|
||||||
</view>
|
</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>
|
<text class="case-note">当前版本已保留原始音频回放、学习点和主管点评;预渲染视频样片仍待正式媒体资源接入。</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -138,9 +140,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
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 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 { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
import { navigateBackToToday, resetPageScroll } from '@/services/navigation';
|
||||||
import { ensureEmployeePosition } from '@/services/position';
|
import { ensureEmployeePosition } from '@/services/position';
|
||||||
@@ -152,6 +155,8 @@ const organizeResult = ref<CaseOrganizeResponse | null>(null);
|
|||||||
const curateResult = ref<CaseCurateResponse | null>(null);
|
const curateResult = ref<CaseCurateResponse | null>(null);
|
||||||
const records = ref<CaseRecord[]>([]);
|
const records = ref<CaseRecord[]>([]);
|
||||||
const caseDetail = ref<CaseDetailResponse | null>(null);
|
const caseDetail = ref<CaseDetailResponse | null>(null);
|
||||||
|
const caseMediaUrl = ref('');
|
||||||
|
const caseMediaLoading = ref(false);
|
||||||
const canSubmitCases = ref(false);
|
const canSubmitCases = ref(false);
|
||||||
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
|
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
|
||||||
const recordsLoading = ref(false);
|
const recordsLoading = ref(false);
|
||||||
@@ -175,10 +180,16 @@ const clearCaseData = () => {
|
|||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
records.value = [];
|
records.value = [];
|
||||||
caseDetail.value = null;
|
caseDetail.value = null;
|
||||||
|
clearCaseMediaUrl();
|
||||||
canSubmitCases.value = false;
|
canSubmitCases.value = false;
|
||||||
resetResult();
|
resetResult();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearCaseMediaUrl = () => {
|
||||||
|
releaseAuthenticatedFileUrl(caseMediaUrl.value);
|
||||||
|
caseMediaUrl.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const refreshAuthState = () => {
|
const refreshAuthState = () => {
|
||||||
loggedIn.value = isLoggedIn();
|
loggedIn.value = isLoggedIn();
|
||||||
};
|
};
|
||||||
@@ -246,16 +257,31 @@ const refreshRecords = () => {
|
|||||||
const openCaseDetail = async (record: CaseRecord) => {
|
const openCaseDetail = async (record: CaseRecord) => {
|
||||||
if (!record.caseId || caseDetailLoading.value) return;
|
if (!record.caseId || caseDetailLoading.value) return;
|
||||||
caseDetailLoading.value = true;
|
caseDetailLoading.value = true;
|
||||||
|
caseMediaLoading.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
clearCaseMediaUrl();
|
||||||
try {
|
try {
|
||||||
caseDetail.value = await getCaseDetail(record.caseId);
|
caseDetail.value = await getCaseDetail(record.caseId);
|
||||||
|
if (caseDetail.value.mediaOssId) {
|
||||||
|
try {
|
||||||
|
caseMediaUrl.value = await getCaseMediaUrl(record.caseId);
|
||||||
|
} catch {
|
||||||
|
caseMediaUrl.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleRequestError(error, '案例详情加载失败');
|
handleRequestError(error, '案例详情加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
caseMediaLoading.value = false;
|
||||||
caseDetailLoading.value = false;
|
caseDetailLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeCaseDetail = () => {
|
||||||
|
caseDetail.value = null;
|
||||||
|
clearCaseMediaUrl();
|
||||||
|
};
|
||||||
|
|
||||||
const submitCase = async () => {
|
const submitCase = async () => {
|
||||||
if (!selectedFile.value || busy.value) return;
|
if (!selectedFile.value || busy.value) return;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
@@ -305,6 +331,8 @@ const handleShow = async () => {
|
|||||||
onShow(() => {
|
onShow(() => {
|
||||||
void handleShow();
|
void handleShow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnload(clearCaseMediaUrl);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
CaseSelectedFile,
|
CaseSelectedFile,
|
||||||
CaseUploadResponse
|
CaseUploadResponse
|
||||||
} from '@/types/api';
|
} 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'];
|
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||||
|
|
||||||
@@ -94,3 +94,6 @@ export const getCaseDetail = (caseId: string) =>
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getCaseMediaUrl = (caseId: string) =>
|
||||||
|
authenticatedFileUrl(`/api/knowledge/case/records/${encodeURIComponent(caseId)}/media`);
|
||||||
|
|||||||
@@ -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/upload"
|
||||||
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/records?limit="
|
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/"
|
||||||
|
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 "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/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/service/AihrCaseService.java "projectScopesForAppUser"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
|
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
|
||||||
|
|||||||
Reference in New Issue
Block a user