diff --git a/backend/ruoyi-admin/src/main/resources/application-dev.yml b/backend/ruoyi-admin/src/main/resources/application-dev.yml index 3d85df18..ac451edb 100644 --- a/backend/ruoyi-admin/src/main/resources/application-dev.yml +++ b/backend/ruoyi-admin/src/main/resources/application-dev.yml @@ -156,6 +156,9 @@ mail: --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 aihr: + practice: + # 仅本地演示允许用训练次数代替 hire_date 推断新员工,生产默认关闭 + allow-legacy-daily-drill-fallback: true org-sync: # 仅开发演示保留姓名/部门展示;生产默认关闭,组织同步只保留必要主体与范围字段 store-display-fields: true diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java index 56eaac83..456c27da 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java @@ -110,7 +110,7 @@ public class AihrOrgSyncService { } } int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count(); - int suspectText = (int) rows.stream().filter(AihrOrgSyncService::hasSuspectText).count(); + int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count(); int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList()); if (!rows.isEmpty() && phoneLinked < rows.size()) { warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员"); @@ -397,6 +397,10 @@ public class AihrOrgSyncService { String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle", "post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工"); String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName)); + boolean suspectText = hasQuestionMark(projectName) + || hasQuestionMark(deptName) + || hasQuestionMark(personName) + || hasQuestionMark(positionName); return new OrgRow( projectCode, projectName, @@ -407,7 +411,8 @@ public class AihrOrgSyncService { positionName, positionLevel(positionLevel, positionName), status(text(employee, "status", "employment_status", "employmentStatus")), - LocalDate.now() + LocalDate.now(), + suspectText ); } @@ -614,13 +619,6 @@ public class AihrOrgSyncService { return false; } - private static boolean hasSuspectText(OrgRow row) { - return hasQuestionMark(row.projectName()) - || hasQuestionMark(row.deptName()) - || hasQuestionMark(row.personName()) - || hasQuestionMark(row.positionName()); - } - private static boolean hasQuestionMark(String value) { return clean(value).contains("?"); } @@ -728,7 +726,8 @@ public class AihrOrgSyncService { String positionName, String positionLevel, String employmentStatus, - LocalDate snapshotDate + LocalDate snapshotDate, + boolean suspectText ) { Object[] args(String tenantId) { return new Object[] { 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 e7b36f3d..b5557998 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 @@ -48,6 +48,7 @@ import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.satoken.utils.LoginHelper; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -214,6 +215,8 @@ public class AihrPracticeSeedService { private final AihrPracticeLlmService practiceLlmService; private final AihrSopSeedService sopSeedService; private final TransactionTemplate pilotExportTransaction; + @Value("${aihr.practice.allow-legacy-daily-drill-fallback:${AIHR_PRACTICE_ALLOW_LEGACY_DAILY_DRILL_FALLBACK:false}}") + private boolean allowLegacyDailyDrillFallback; private volatile boolean practiceTableReady; private volatile boolean assignmentTableReady; private volatile boolean scenarioTableReady; @@ -1841,9 +1844,14 @@ public class AihrPracticeSeedService { .map(hireDate -> isWithinNewHireWindow(hireDate, LocalDate.now())) .orElse(false); } - // The legacy Demo snapshot has no hire_date. Keep its deterministic fallback - // until the external organization sync supplies the formal field. - return countMobileRecords(extPartyId) < 3; + // Formal production data must provide hire_date. The record-count fallback is + // intentionally opt-in for the local Demo only; it must not infer employment + // tenure from training activity in a pilot or production tenant. + return isLegacyDailyDrillEligible(allowLegacyDailyDrillFallback, extPartyId, countMobileRecords(extPartyId)); + } + + static boolean isLegacyDailyDrillEligible(boolean allowFallback, String extPartyId, int mobileRecordCount) { + return allowFallback && extPartyId != null && !extPartyId.isBlank() && mobileRecordCount < 3; } private Optional findHireDate(String extPartyId) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java index 9279bf96..2ba9b467 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java @@ -76,6 +76,22 @@ public class AihrOrgSyncServiceTest { assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName")); } + @Test + public void suspectTextIsCheckedBeforeDisplayFieldsAreMasked() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class)); + var employee = mapper.readTree(""" + {"id":"EMP-1","name":"测试?员工","department_name":"项目?管理","project_name":"翡翠湾","position_name":"主管","phone":"13900001111"} + """); + + ReflectionTestUtils.setField(service, "storeDisplayFields", false); + Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of()); + + assertEquals("", ReflectionTestUtils.getField(masked, "personName")); + assertEquals("", ReflectionTestUtils.getField(masked, "deptName")); + assertEquals(true, ReflectionTestUtils.getField(masked, "suspectText")); + } + @Test public void snapshotSuspectTextQuerySearchesLiteralQuestionMarks() { SnapshotJdbcTemplate jdbcTemplate = new SnapshotJdbcTemplate(); 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 a630d147..7c603707 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 @@ -120,6 +120,14 @@ public class AihrPracticeSeedServiceTest { assertFalse(AihrPracticeSeedService.isWithinNewHireWindow(null, asOf)); } + @Test + public void legacyDailyDrillFallbackOnlyUsesTrainingCountAfterExplicitOptIn() { + assertTrue(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "EMP-1", 0)); + assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(false, "EMP-1", 0)); + assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "EMP-1", 3)); + assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "", 0)); + } + @Test public void dailyDrillAnswerDoesNotOverwriteCompletedAttempt() throws Exception { String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index c17dd101..cd784247 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -18,7 +18,7 @@ | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | | 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | -| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度 | +| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启。 | | 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | ## 后端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 372c6757..bbd49d48 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -263,3 +263,5 @@ - 2026-07-14 BRD G1/4.5 证据包评分边界修复:成长/晋升证据包此前只展示 AI 测评分,人工校准结果仅存在于试点 CSV;现复用 `aihr_practice_calibration` 最新记录,向管理端和员工端同时返回人工校准分、最终采用分和校准样本数,并在导出证据包中保留三项字段。未校准时明确显示“待采集”,不把 AI 分伪装成人工结论;AI 仍仅作训练参考,不直接生成绩效或晋升结论。 - 2026-07-14 BRD 4.5/4.7 案例贡献证据边界复核:现有 `aihr_case_record.owner` 仅保存负责人展示值(系统用户或员工末四位),没有稳定 `ext_party_id`、采纳审核任务或 `incentive_point.source='case_contribution'` 关联;当前案例上传/入库数量不能安全算作员工案例贡献。成长证据包现明确提示“案例上传/入库”与“正式采纳、署名、积分”分离,未把案例数量伪装成晋升证据;待 HR 确认 `review_task` 终审、署名和积分规则后,再设计正式关联与统计。 - 2026-07-14 BRD 4.5/B5 贡献度口径修复:能力画像此前把训练次数和待复盘状态混入 `contribution`,会将训练活跃度误显示为有效贡献;现将贡献度收敛为 `aihr_practice_session.incentive_point` 的人工确认分,训练次数继续单独展示。未新增积分来源或自动案例计分,正式案例贡献仍等待 `review_task`/署名/积分规则。 +- 2026-07-14 BRD G3 组织快照质量门禁修复:生产默认关闭 `store-display-fields` 时,`AihrOrgSyncService` 原先在 `orgRow()` 内先清空姓名/部门,再以已屏蔽的 `OrgRow` 计算 `suspectText`,外部姓名/部门中的问号乱码可能被漏报,覆盖写入保护因此不完整。现先基于外部原始字段计算 `suspectText`,再独立保存质量标记;展示字段仍按生产脱敏策略清空,组织表结构和写入字段不变。新增 `AihrOrgSyncServiceTest` 回归和 `demo-check` marker;未修改生产数据。正式同步仍需真实上游快照、手机号映射和法务确认。 +- 2026-07-14 BRD 4.3.4 每日三题资格边界修复:`aihr_org_snapshot` 没有 `hire_date` 时,旧逻辑用“移动端训练记录少于 3 次”推断新员工,可能把老员工或临时账号误派每日三题。现仅在本地 `application-dev.yml` 显式开启 `allow-legacy-daily-drill-fallback=true`;生产默认关闭,正式环境必须等组织同步提供 `hire_date` 后再按三个月窗口派发。新增边界测试和 `demo-check` marker;不改数据库结构,不伪造正式新员工数据。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index e7e9f3b9..a34af6f5 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -194,7 +194,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true . - 案例沉淀:进入 `/knowledge/cases`,上传真实语音 → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增案例记录;上传必须先完成 ASR 转写。 - SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。 - 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。 -- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。 +- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产默认关闭。 - 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。 - uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。 - uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index a8c02176..e444f25b 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -328,6 +328,8 @@ contains frontend/src/views/admin/org-permission.vue "dryRun ? '同步预检' : contains frontend/src/views/admin/org-permission.vue "组织同步接口不可用,请检查外部同步配置" contains frontend/src/views/admin/org-permission.vue "row.personName || row.extPartyId" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "storeDisplayFields" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "rows.stream().filter(OrgRow::suspectText)" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "boolean suspectText = hasQuestionMark(projectName)" contains backend/ruoyi-admin/src/main/resources/application-prod.yml "store-display-fields: false" contains mobile-uni/package.json "aihr-mobile-uni" @@ -514,6 +516,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "isWithinNewHireWindow" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "allow-legacy-daily-drill-fallback" +contains backend/ruoyi-admin/src/main/resources/application-dev.yml "allow-legacy-daily-drill-fallback: true" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "instr(coalesce(person_name, '')" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText" contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"