fix(aihr): bind practice sessions to owner
This commit is contained in:
+2
-2
@@ -131,12 +131,12 @@ public class AihrPracticeController {
|
||||
|
||||
@PostMapping("/turn")
|
||||
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
|
||||
return R.ok(practiceSeedService.turn(request));
|
||||
return R.ok(practiceSeedService.turn(request, currentAppUsername()));
|
||||
}
|
||||
|
||||
@PostMapping("/finish")
|
||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||
return R.ok(practiceSeedService.finish(request));
|
||||
return R.ok(practiceSeedService.finish(request, currentAppUsername()));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
|
||||
+27
-1
@@ -494,8 +494,15 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public TurnResponse turn(TurnRequest request) {
|
||||
return turn(request, "");
|
||||
}
|
||||
|
||||
public TurnResponse turn(TurnRequest request, String ownerIdentity) {
|
||||
String style = normalizeRegenerateStyle(request == null ? null : request.style());
|
||||
ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId());
|
||||
if (!isBlank(ownerIdentity)) {
|
||||
requireSessionOwner(session, ownerIdentity);
|
||||
}
|
||||
ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId());
|
||||
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
|
||||
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
|
||||
@@ -630,8 +637,19 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
|
||||
public FinishResponse finish(FinishRequest request) {
|
||||
return finish(request, "");
|
||||
}
|
||||
|
||||
public FinishResponse finish(FinishRequest request, String ownerIdentity) {
|
||||
String sessionId = request == null ? null : request.sessionId();
|
||||
ActiveSession activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
|
||||
ActiveSession activeSession;
|
||||
if (isBlank(ownerIdentity)) {
|
||||
activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
|
||||
} else {
|
||||
ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId);
|
||||
requireSessionOwner(existing, ownerIdentity);
|
||||
activeSession = activeSessions.remove(sessionId);
|
||||
}
|
||||
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
|
||||
String trainee = activeSession == null ? scenario.trainee() : activeSession.trainee();
|
||||
PracticeResult result = evaluate(activeSession, scenario);
|
||||
@@ -1896,6 +1914,14 @@ public class AihrPracticeSeedService {
|
||||
return request.extPartyId().trim();
|
||||
}
|
||||
|
||||
private static void requireSessionOwner(ActiveSession session, String ownerIdentity) {
|
||||
String owner = ownerIdentity == null ? "" : ownerIdentity.trim();
|
||||
String sessionOwner = session == null || session.extPartyId() == null ? "" : session.extPartyId().trim();
|
||||
if (session == null || !owner.equals(sessionOwner)) {
|
||||
throw new ServiceException("训练会话不存在或无权访问");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveTrainee(StartRequest request, ScenarioSeed scenario) {
|
||||
if (!isMobile(request) || request.extPartyId() == null || request.extPartyId().isBlank()) {
|
||||
return scenario.trainee();
|
||||
|
||||
+28
@@ -2,8 +2,11 @@ package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -106,6 +109,31 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mobilePracticeSessionRejectsForeignAndUnknownOwner() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
|
||||
.thenReturn(List.of());
|
||||
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
|
||||
var ownerSession = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
|
||||
var otherSession = service.start(new StartRequest("employee-b", "complaint-water", "mobile", null));
|
||||
|
||||
ServiceException foreign = assertThrows(ServiceException.class, () -> service.turn(
|
||||
new TurnRequest(otherSession.sessionId(), 0, "越权回答", null, null, false, null), "employee-a"
|
||||
));
|
||||
assertEquals("训练会话不存在或无权访问", foreign.getMessage());
|
||||
|
||||
ServiceException unknown = assertThrows(ServiceException.class, () -> service.finish(
|
||||
new FinishRequest("seed-not-started", null), "employee-a"
|
||||
));
|
||||
assertEquals("训练会话不存在或无权访问", unknown.getMessage());
|
||||
|
||||
ServiceException missing = assertThrows(ServiceException.class, () -> service.finish(
|
||||
new FinishRequest(ownerSession.sessionId(), null), "employee-other"
|
||||
));
|
||||
assertEquals("训练会话不存在或无权访问", missing.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAssignmentRejectsOutOfProjectAssignee() {
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|---|---|---|
|
||||
| AI面试 `/recruit/interview` | `POST /api/recruit/interview/start`、`/answer`、`/finish`、`GET /records`、`POST /records/{sessionId}/review` | 已接入真实模型优先链路:配置 chat 模型后 `/start` 动态生成面试题,`/finish` 按前端提交的真实回答做结构化评分;未配置模型时使用本地 Rubric 估分,不返回固定候选人分数。AI 分数只作辅助参考,HR/管理员可提交 `reviewedScore/reviewNote` 完成人工复核;记录同时保留 AI 分、人工复核分、最终采用分和复核状态 |
|
||||
| 候选人入职主体关联 `/recruit/interview` | `GET/POST /api/recruit/interview/candidate-links` | HR/管理员在当前租户范围内把本地候选人 ID 关联到已同步的在职 `ext_party_id`;只保存外部主体 ID,不复制姓名、部门等组织字段,重复关联同一主体幂等,候选人更换主体或同一主体已关联其他候选人会拒绝 |
|
||||
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);模型未配置或调用失败自动回退 seed,契约不变 |
|
||||
| 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 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,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端和管理端案例详情均可回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 |
|
||||
|
||||
@@ -164,3 +164,4 @@
|
||||
- 2026-07-14 AI 面试人工复核闭环:`aihr_interview_result` 现在持久化 AI 分、人工复核分、最终采用分、复核状态、复核人、意见和时间;HR/管理员可通过 `/api/recruit/interview/records/{sessionId}/review` 复核,候选人端无该角色权限。`finish` 响应同步返回真实 `sessionId`,便于后续复核定位。AIHR 全量测试 `65/65`、真实本地 HTTP 烟测和旧库懒迁移验证通过;正式试点仍需抽查人工复核证据,生产尚未发布。
|
||||
- 2026-07-14 线上资源只读核对:生产根站、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`;生产管理端仍加载 `index-CJZ3Ax3Z.js`,本地当前管理端构建为 `index-D4ywKu9Y.js`。本次只读检查,未执行生产写入、静态资源同步或后端重启,因此不能把本地人工复核修复宣称为线上已生效。
|
||||
- 2026-07-14 BRD G3 面试会话归属复核:发现候选人端 `answer/finish` 原先只按客户端提交的 `sessionId` 查内存会话,未知会话还会自动创建并落库;现由控制器把当前 APP 手机号传入服务,APP 请求必须匹配启动会话的候选人 ID,外部会话和未知会话统一拒绝;后台系统调用保留原有兼容行为。新增越权/未知会话回归测试与 `demo-check` marker,未修改生产环境。
|
||||
- 2026-07-14 BRD G3 对练会话归属复核:发现三角色对练的 `start` 虽已绑定 APP 手机号,但 `turn/finish` 原先可用任意会话 ID继续提交,未知会话还会回退场景并落成训练记录;现由控制器把当前 APP 手机号传入服务,移动端回合与完成操作必须匹配启动会话的 `ext_party_id`,外部/未知会话统一拒绝;后台管理端调用保留原有演示兼容行为。新增越权/未知会话回归测试与 `demo-check` marker,未修改生产环境。
|
||||
|
||||
@@ -397,6 +397,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
|
||||
contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"
|
||||
contains frontend/src/api/aihr/practice.ts "/api/aihr/prompt-templates"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "practiceSeedService.turn(request, currentAppUsername())"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "practiceSeedService.finish(request, currentAppUsername())"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "训练会话不存在或无权访问"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "@SaCheckLogin"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "sessions/{sessionId}/annotations"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "/api/aihr/prompt-templates"
|
||||
|
||||
Reference in New Issue
Block a user