diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 8f82f2d6..a59eccda 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -473,7 +473,7 @@ public class AihrPracticeSeedService { } public StartResponse start(StartRequest request) { - ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null); + ScenarioSeed scenario = resolveScenario(resolveStartScenarioId(request), null); RoundSeed firstRound = scenario.rounds().get(0); String sessionId = "seed-" + scenario.id() + "-" + System.currentTimeMillis(); String trainee = resolveTrainee(request, scenario); @@ -2049,6 +2049,40 @@ public class AihrPracticeSeedService { return request != null && "mobile".equalsIgnoreCase(request.mode()); } + private String resolveStartScenarioId(StartRequest request) { + String requestedScenarioId = request == null ? "" : firstNonBlank(request.scenarioId(), "").trim(); + if (!isMobile(request) || request.assignmentId() == null) { + return requestedScenarioId; + } + String extPartyId = resolveExtPartyId(request, ""); + if (isBlank(extPartyId)) { + throw new ServiceException("缺少员工身份,无法开始派发训练"); + } + ensureAssignmentTable(); + List> rows = jdbcTemplate.queryForList(""" + SELECT scenario_id + FROM aihr_practice_assignment + WHERE tenant_id = ? + AND id = ? + AND ext_party_id = ? + AND source <> 'daily' + AND status = '待训练' + LIMIT 1 + """, tenantId(), request.assignmentId(), extPartyId.trim()); + if (rows.isEmpty()) { + throw new ServiceException("训练任务不存在、已完成或无权访问"); + } + Object assignedValue = rows.get(0).get("scenario_id"); + String assignedScenarioId = assignedValue == null ? "" : String.valueOf(assignedValue).trim(); + if (isBlank(assignedScenarioId)) { + throw new ServiceException("训练任务缺少场景,无法开始"); + } + if (!isBlank(requestedScenarioId) && !assignedScenarioId.equals(requestedScenarioId)) { + throw new ServiceException("训练任务场景与请求不一致"); + } + return assignedScenarioId; + } + private String resolveExtPartyId(StartRequest request, String trainee) { if (request == null || isBlank(request.extPartyId())) { return trainee; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 46d031b4..15c586ea 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -135,6 +135,20 @@ public class AihrPracticeSeedServiceTest { assertTrue(method.contains("if (updated == 0)")); } + @Test + public void mobileAssignmentStartValidatesOwnerStatusAndScenario() throws Exception { + String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + int methodStart = source.indexOf("private String resolveStartScenarioId"); + int methodEnd = source.indexOf("private String resolveExtPartyId", methodStart); + assertTrue(methodStart >= 0 && methodEnd > methodStart); + + String method = source.substring(methodStart, methodEnd); + assertTrue(method.contains("AND ext_party_id = ?")); + assertTrue(method.contains("AND source <> 'daily'")); + assertTrue(method.contains("AND status = '待训练'")); + assertTrue(method.contains("训练任务场景与请求不一致")); + } + @Test public void mobilePracticeSessionRejectsForeignAndUnknownOwner() { JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 19d93ba5..fd8dc5f2 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -176,6 +176,7 @@ - 2026-07-14 BRD G3 音频读取权限复核:发现移动端音频下载接口对已登录 SYS 用户未校验角色,普通后台账号理论上可枚举 `ossId` 读取训练录音;现复用 `superadmin/hr_operator` 门禁,APP 用户继续按本人/主管项目范围校验,其他身份返回 403。AIHR 全量测试 `70/70` 通过,未修改生产环境。 - 2026-07-14 BRD 4.4 每日三题资格补强:每日派题现在在组织快照存在 `hire_date` 列时按“入职日期至今不超过 3 个月”判断,边界按自然月且包含当天;旧 Demo 快照没有该列时继续使用“移动端训练记录少于 3 次”的兼容回退。正式试点仍需在外部组织同步落地并校验 `hire_date`,不能把回退口径当成正式新员工资格。 - 2026-07-14 BRD 4.4 每日三题提交幂等复核:前端虽已禁用已完成题,后端原先仍允许重复 POST 覆盖答案、反馈和音频证据;现要求查询和更新均只接受“当天、本人、`status='待训练'`”的记录,并校验更新行数,已完成或历史日期记录保留原始证据,重复或并发请求按“不存在或已完成”处理。 +- 2026-07-14 BRD 4.4 派发任务启动复核:移动端带 `assignmentId` 开始训练时,后端现校验任务属于当前员工、来源不是每日题、状态仍为“待训练”,并校验请求场景与派发场景一致;自由选场景请求不受影响,避免客户端改写 `scenarioId` 后错误完成主管派发任务。 - 2026-07-14 BRD G3 候选资料审核状态复核:候选人资料审核接口此前在空请求体时默认写回“待审核”,可能覆盖已有“已通过/已驳回”状态;现要求审核动作显式提交三态之一,空值和未知状态直接拒绝,避免后台误操作回退审核结论。 - 2026-07-14 BRD 案例署名复核:案例库已有负责人字段,但此前上传、整理和入库记录都硬编码为“培训组”,无法追溯案例贡献者;现复用既有 `owner` 字段保存首次上传身份,整理和入库阶段保留原署名,手机号身份仅显示员工末四位,避免把原始手机号写入案例展示。 - 2026-07-14 BRD 候选人关联完整性复核:候选人—员工关联接口此前只校验员工主体在职,允许给不存在的任意 `candidateId` 建立入职关联;现要求候选人必须来自内置候选档案、活动面试会话或当前租户已持久化面试记录,保留已有幂等关联和历史数据兼容。