diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java index 9e27e62a..58c5a657 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrOrgSyncDto.java @@ -14,7 +14,8 @@ public final class AihrOrgSyncDto { Integer maxPages, String groupId, String companyId, - String departmentId + String departmentId, + Boolean allowPartialReplace ) { } 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 b35cbc0d..d2cbdc05 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 @@ -66,7 +66,7 @@ public class AihrOrgSyncService { private String configuredSigningSecret; public SyncResponse sync(SyncRequest request) { - SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null) : request; + SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request; String baseUrl = normalizeBaseUrl(configuredBaseUrl); if (baseUrl.isBlank()) { throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); @@ -79,6 +79,7 @@ public class AihrOrgSyncService { requireSnapshotTable(); } boolean replaceExisting = req.replaceExisting() == null || Boolean.TRUE.equals(req.replaceExisting()); + boolean allowPartialReplace = Boolean.TRUE.equals(req.allowPartialReplace()); String token = accessToken(baseUrl); List warnings = new ArrayList<>(); @@ -116,6 +117,10 @@ public class AihrOrgSyncService { if (!dryRun && replaceExisting && rows.isEmpty()) { throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照"); } + if (!dryRun && replaceExisting && !allowPartialReplace + && hasUnsafeReplaceData(employeeItems.size(), rows.size(), skipped, phoneLinked, maskedPhone, suspectText)) { + throw new IllegalArgumentException("外部员工快照存在不完整或疑似异常数据,已阻止覆盖本地组织人员快照;请先 dry-run,确认后显式传 allowPartialReplace=true"); + } if (!dryRun && !rows.isEmpty()) { saveRows(rows, replaceExisting); } @@ -136,6 +141,15 @@ public class AihrOrgSyncService { ); } + static boolean hasUnsafeReplaceData(int employeeCount, int rowCount, int skipped, + int phoneLinked, int maskedPhone, int suspectText) { + return employeeCount != rowCount + || skipped > 0 + || phoneLinked < rowCount + || maskedPhone > 0 + || suspectText > 0; + } + public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status, Integer pageNum, Integer pageSize, Integer limit) { requireSnapshotTable(); 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 828c5a4d..a7a5dd56 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 @@ -29,6 +29,14 @@ public class AihrOrgSyncServiceTest { assertEquals("", AihrOrgSyncService.normalizeMobilePhone("025-88888888")); } + @Test + public void partialOrSuspiciousSnapshotMustNotReplaceExistingDataByDefault() { + assertTrue(AihrOrgSyncService.hasUnsafeReplaceData(22, 20, 2, 20, 1, 0)); + assertTrue(AihrOrgSyncService.hasUnsafeReplaceData(22, 22, 0, 21, 0, 0)); + assertTrue(AihrOrgSyncService.hasUnsafeReplaceData(22, 22, 0, 22, 0, 1)); + assertFalse(AihrOrgSyncService.hasUnsafeReplaceData(22, 22, 0, 22, 0, 0)); + } + @Test public void unusablePhoneCandidateDetectsMaskedPhone() throws Exception { ObjectMapper mapper = new ObjectMapper(); @@ -50,7 +58,7 @@ public class AihrOrgSyncServiceTest { ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); assertThrows(IllegalStateException.class, () -> service.sync( - new SyncRequest(true, true, 1, 1, null, null, null) + new SyncRequest(true, true, 1, 1, null, null, null, null) )); verifyNoInteractions(jdbcTemplate, transactionTemplate); } @@ -83,7 +91,7 @@ public class AihrOrgSyncServiceTest { ReflectionTestUtils.setField(service, "configuredClientSecret", ""); ReflectionTestUtils.setField(service, "configuredSigningSecret", ""); - var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null)); + var response = service.sync(new SyncRequest(true, true, 1, 1, null, null, null, null)); assertTrue(response.dryRun()); assertEquals(1, response.employeeCount()); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 3a5a25d7..e199f82f 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -13,7 +13,7 @@ | 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate` | `/upload` 改为 multipart 真实语音上传并走 ASR;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;不再用固定样例转写冒充成功 | | 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`。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` 等字段。默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;`replaceExisting=false` 不触发该覆盖闸门。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | | 移动端手机号登录 | `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}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 | | 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | @@ -113,7 +113,13 @@ curl -fsS "$API_BASE/api/aihr/org/sync" \ curl -fsS "$API_BASE/api/aihr/org/sync" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ - -d '{"replaceExisting":true}' + -d '{"replaceExisting":true,"allowPartialReplace":false}' + +# 仅在 dry-run 已人工确认允许不完整快照覆盖时使用 +curl -fsS "$API_BASE/api/aihr/org/sync" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"replaceExisting":true,"allowPartialReplace":true}' ``` ## 前端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 422f7977..f51a6480 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -26,7 +26,7 @@ | 缺口 | BRD依据 | 当前事实 | 下一步 | |---|---|---|---| -| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;生产快照当前为 22 条 active、2 个项目,但手机号关联为 0/22;生产环境当前未配置 `AIHR_ORG_SYNC_*` 组织同步凭据;移动端角色查询失败已改为安全降级员工端,线上视觉测试不能替代真实主管身份验证 | 由业务/运维提供正式组织接口配置后先做 dry-run,核对项目、岗位、`ext_party_id`、手机号;补齐手机号映射并确认主管身份后,才执行正式同步和主管数据权限验收 | +| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;生产快照当前为 22 条 active、2 个项目,但手机号关联为 0/22;生产环境当前未配置 `AIHR_ORG_SYNC_*` 组织同步凭据;移动端角色查询失败已改为安全降级员工端,线上视觉测试不能替代真实主管身份验证;组织覆盖写入现在会拒绝被跳过、手机号不完整、脱敏手机号或疑似乱码的快照,除非显式传 `allowPartialReplace=true` | 由业务/运维提供正式组织接口配置后先做 dry-run,核对项目、岗位、`ext_party_id`、手机号;补齐手机号映射并确认主管身份后,才执行正式同步和主管数据权限验收 | | 管理端/后端生产发布证据 | 6.3、12.1、L1-C | 2026-07-14 已核实并发布:生产根站点 `https://peilian.njzhmj.top/`、Caddy `/prod-api`、systemd `wygj-aihr.service` 和后端 jar 均可核查;管理端静态包、后端 jar 已与本地构建 hash 对齐,租户接口和移动端首页 API 返回 200 | 发布证据已具备;后续仍需把正式发布命令、回滚脚本和生产部署凭据纳入运维交接,不能把“服务可访问”直接等同于正式试点通过 | | M5 正式试点证据 | L1-B/C/D、G1/G3/G4 | 已补员工训练后 1–5 分满意度和可选意见采集,意见脱敏后写入 `aihr_practice_session`,CSV/严格门禁可读满意度响应数与平均分;但本地样本不能代表正式窗口,仍不能用 seed/烟测数据替代,尚无法证明“满意度 ≥4/5” | 固定 1–2 个住宅项目和窗口,按唯一在职身份采集训练、人工校准、SOP 评审、完训率和满意度;严格门禁要求满意度有真实响应且平均分 ≥4.0/5 | | AI 分数与人工复核边界 | 4.1、4.5、G1/G2 | 面试和对练已有人工复核/校准入口;正式试点 CSV 已补明细级 AI 分、人工校准分、校准人、校准时间和最终采用分;正式试点仍需用真实窗口验证人工可否决证据 | 在正式试点报告中抽查校准记录与最终采用值,确认人工分可覆盖 AI 建议且不直接挂钩绩效/晋升 | diff --git a/frontend/src/api/aihr/mobile-admin.ts b/frontend/src/api/aihr/mobile-admin.ts index 0ffba3c5..cccb30a3 100644 --- a/frontend/src/api/aihr/mobile-admin.ts +++ b/frontend/src/api/aihr/mobile-admin.ts @@ -100,6 +100,7 @@ export type PromotionEvidence = { export type OrgSyncRequest = { dryRun?: boolean; replaceExisting?: boolean; + allowPartialReplace?: boolean; pageSize?: number; maxPages?: number; };