fix(aihr): tighten upload and rag evidence boundaries

This commit is contained in:
2026-07-14 09:28:22 +08:00
parent dc8d4840bf
commit 896495dddf
9 changed files with 97 additions and 5 deletions
@@ -16,6 +16,8 @@ import java.sql.Statement;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -24,6 +26,9 @@ public class AihrCandidateMaterialService {
private static final String TENANT_ID = "000000";
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final long MAX_SIZE = 20L * 1024 * 1024;
private static final Set<String> SUPPORTED_MATERIAL_EXTENSIONS = Set.of(
".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".webp"
);
private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService;
@@ -39,7 +44,10 @@ public class AihrCandidateMaterialService {
throw new IllegalArgumentException("资料不能超过20MB");
}
String boundCandidateId = requireCandidateId(candidateId);
String fileName = clean(file.getOriginalFilename(), "candidate-material");
String fileName = sanitizeMaterialFileName(file.getOriginalFilename());
if (!isSupportedMaterialFile(fileName)) {
throw new IllegalArgumentException("候选人资料仅支持 PDF、Word 或 JPG/PNG/WebP 图片");
}
String type = clean(materialType, materialType(fileName));
SysOssVo oss = ossService.upload(file);
KeyHolder keyHolder = new GeneratedKeyHolder();
@@ -205,6 +213,20 @@ public class AihrCandidateMaterialService {
return text;
}
static boolean isSupportedMaterialFile(String fileName) {
String lower = clean(fileName, "").toLowerCase(Locale.ROOT);
return SUPPORTED_MATERIAL_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
static String sanitizeMaterialFileName(String original) {
String name = clean(original, "candidate-material");
name = name.replaceAll("[\\\\/\\r\\n\\t]", "_");
if (name.length() > 200) {
name = name.substring(name.length() - 200);
}
return name;
}
private static String normalizeReviewStatus(String status) {
String text = clean(status, "待审核");
if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) {
@@ -26,8 +26,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@@ -38,6 +40,9 @@ import java.util.stream.Collectors;
public class AihrCaseService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final Set<String> SUPPORTED_AUDIO_EXTENSIONS = Set.of(
".mp3", ".wav", ".m4a", ".webm", ".ogg", ".aac", ".flac"
);
private final AihrSpeechService speechService;
private final AihrModelSeedService modelService;
@@ -51,8 +56,11 @@ public class AihrCaseService {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请上传语音文件");
}
String fileName = sanitizeAudioFileName(file.getOriginalFilename());
if (!isSupportedAudioFile(fileName)) {
throw new IllegalArgumentException("案例素材仅支持 MP3、WAV、M4A、WebM、OGG、AAC 或 FLAC 音频");
}
String scopedProject = resolveUploadProject(projectExtOrgId, projectScopes);
String fileName = firstNonBlank(file.getOriginalFilename(), "case-audio.webm");
String contentType = firstNonBlank(file.getContentType(), "application/octet-stream");
try {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
@@ -527,6 +535,20 @@ public class AihrCaseService {
return clean(AihrSensitiveText.forModel(text));
}
static boolean isSupportedAudioFile(String fileName) {
String lower = clean(fileName).toLowerCase(Locale.ROOT);
return SUPPORTED_AUDIO_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
static String sanitizeAudioFileName(String original) {
String name = firstNonBlank(original, "case-audio.webm");
name = name.replaceAll("[\\\\/\\r\\n\\t]", "_");
if (name.length() > 200) {
name = name.substring(name.length() - 200);
}
return name;
}
private record CaseState(
String id,
String fileName,
@@ -1832,7 +1832,7 @@ public class AihrSopSeedService {
}
private SearchResponse withReviewId(SearchResponse response, String source) {
Long reviewId = skipSopReview(source) ? null : createSopReview(response, source);
Long reviewId = shouldCreateSopReview(response.queryText(), source) ? createSopReview(response, source) : null;
return new SearchResponse(
response.queryText(),
response.category(),
@@ -1853,6 +1853,9 @@ public class AihrSopSeedService {
return "verify_demo_questions".equals(source);
}
static boolean shouldCreateSopReview(String queryText, String source) {
return !isBlank(queryText) && !skipSopReview(source);
}
private Long createSopReview(SearchResponse response, String source) {
try {
ensureSopReviewTable();
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AihrCandidateMaterialServiceTest {
@@ -16,4 +18,20 @@ class AihrCandidateMaterialServiceTest {
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(""));
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(null));
}
@Test
@Tag("dev")
void candidateMaterialFileMustUseTheSameAllowlistAsTheMobilePicker() {
assertTrue(AihrCandidateMaterialService.isSupportedMaterialFile("resume.PDF"));
assertTrue(AihrCandidateMaterialService.isSupportedMaterialFile("certificate.webp"));
assertFalse(AihrCandidateMaterialService.isSupportedMaterialFile("payload.exe"));
assertFalse(AihrCandidateMaterialService.isSupportedMaterialFile("archive.zip"));
}
@Test
@Tag("dev")
void candidateMaterialFileNameCannotCarryPathOrControlCharacters() {
assertEquals(".._.._candidate.pdf", AihrCandidateMaterialService.sanitizeMaterialFileName("../../candidate.pdf"));
assertEquals("candidate_ name.pdf", AihrCandidateMaterialService.sanitizeMaterialFileName("candidate\t name.pdf"));
}
}
@@ -129,6 +129,16 @@ class AihrCaseServiceTest {
));
}
@Test
@Tag("dev")
void caseAudioAllowlistRejectsNonAudioExtensions() {
assertTrue(AihrCaseService.isSupportedAudioFile("现场录音.WEBM"));
assertTrue(AihrCaseService.isSupportedAudioFile("follow-up.m4a"));
assertFalse(AihrCaseService.isSupportedAudioFile("payload.exe"));
assertFalse(AihrCaseService.isSupportedAudioFile("notes.pdf"));
assertEquals(".._.._case.webm", AihrCaseService.sanitizeAudioFileName("../../case.webm"));
}
@Test
@Tag("dev")
void caseWriteControllerRequiresHrRoleForSystemUsers() throws Exception {
@@ -36,6 +36,14 @@ public class AihrSopSeedServiceTest {
assertFalse(AihrSopSeedService.skipSopReview("knowledge_search"));
}
@Test
@Tag("dev")
public void emptySopQuestionCannotCreateReviewSample() {
assertFalse(AihrSopSeedService.shouldCreateSopReview(" ", "knowledge_search"));
assertTrue(AihrSopSeedService.shouldCreateSopReview("物业费怎么交", "knowledge_search"));
assertFalse(AihrSopSeedService.shouldCreateSopReview("物业费怎么交", "verify_demo_questions"));
}
@Test
@Tag("dev")
public void evidenceHitsKeepsNamedDocumentOnly() {
+2 -2
View File
@@ -11,7 +11,7 @@
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 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}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端和管理端案例详情均可回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 |
| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端和管理端案例详情均可回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 |
| 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` 等字段。默认 `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 名试点人员手机号 |
@@ -46,7 +46,7 @@ ALIYUN_SMS_SIGN_NAME=物业AI助手
| 能力 | 后端接口 | 处理 |
|---|---|---|
| 候选资料上传 | `POST /api/aihr/mobile/candidate/materials` | `multipart/form-data` 字段 `file`,参数 `candidateId/candidateName/materialType`;需移动端登录返回的 `Authorization` 与 `clientid`;APP 用户场景后端强制使用登录手机号和派生候选人名,不信任前端传入身份;文件用 `ISysOssService` 写 `sys_oss`/MinIO,关系写 `aihr_candidate_material`,状态默认 `待审核`,单文件 20MB |
| 候选资料上传 | `POST /api/aihr/mobile/candidate/materials` | `multipart/form-data` 字段 `file`,参数 `candidateId/candidateName/materialType`;需移动端登录返回的 `Authorization` 与 `clientid`;APP 用户场景后端强制使用登录手机号和派生候选人名,不信任前端传入身份;服务端与移动端共同限制 PDF/Word/JPG/PNG/WebP,文件名会去掉路径和控制字符;文件用 `ISysOssService` 写 `sys_oss`/MinIO,关系写 `aihr_candidate_material`,状态默认 `待审核`,单文件 20MB |
| 候选资料列表 | `GET /api/aihr/mobile/candidate/materials?candidateId=` | 返回前 20 条 `{id, ossId, fileName, materialType, fileSize, status, time}`,供 `/h5/candidate` 的“已提交资料”展示;APP 用户场景忽略传入 `candidateId`,只返回登录手机号对应资料 |
| HR 资料审核 | `GET /api/aihr/hr/candidate/materials?status=`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 管理端 `/recruit/interview` 展示前 50 条,可按状态筛选;审核请求体 `{status}`,只接受 `待审核/已通过/已驳回`;接口只允许管理端超级管理员或 `hr_operator` 角色访问 |
+7
View File
@@ -52,6 +52,13 @@
- 这些查询只证明数量和已知模式脱敏,不证明 5 个核心流程覆盖、姓名等未结构化 PII、每周每项目产出或视频学习内容;这些仍需业务内容复核和正式窗口数据。
- `scripts/tests/demo-check-pilot-gates.test.sh` 已补充跨租户、错误状态和非 SOP 文档样本,防止严格门禁用错误数据刷过。
### 2026-07-14 候选资料上传边界复核
- 发现移动端候选资料选择器虽然只显示 PDF/Word/JPG/PNG/WebP,后端此前只校验 20MB,直接构造 multipart 请求仍可把任意扩展名文件写入 `sys_oss`。
- 已在 `AihrCandidateMaterialService` 增加服务端同源扩展名白名单,并清理文件名中的路径分隔符和控制字符;补充单测和 `demo-check` marker。该修复只收紧上传边界,不改变候选人本人绑定、HR 审核三态或现有资料表结构。
- RAG 评审追溯同时补上空问题门禁:空查询仍返回明确的无结果响应,但不再写入 `aihr_sop_answer_review`,避免误点击污染正式 SOP 可用率分母;已有 `verify_demo_questions` 演示来源跳过规则保持不变。
- 案例语音上传也补上服务端扩展名白名单(MP3/WAV/M4A/WebM/OGG/AAC/FLAC)和文件名清理,避免只依赖管理端/移动端的 `accept` 属性;该修复不改变 ASR、OSS 留痕或项目权限链路。
### 2026-07-14 增量:BRD 核心流程覆盖无法判定
- BRD 4.3.4 要求投诉处理、费用催缴、服务推介/增值、日常服务与突发应急等一期训练覆盖;TechSpec 只给出 `投诉/催费/推介/应急` 的场景类型示例,当前没有双方确认的核心流程编码表。
+2
View File
@@ -351,6 +351,8 @@ contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "候选人登录态无效"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "@SaCheckLogin"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "SUPPORTED_MATERIAL_EXTENSIONS"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "SUPPORTED_AUDIO_EXTENSIONS"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java "@SaCheckLogin"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "@SaCheckLogin"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "@SaCheckLogin"