From 77c576bf5cbefa3c4da45480e04b31d2ebba444c Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 20:57:50 +0800 Subject: [PATCH] fix(aihr): require explicit org sync dry run --- .../org/dromara/aihr/service/AihrOrgSyncService.java | 5 ++++- .../dromara/aihr/service/AihrOrgSyncServiceTest.java | 12 ++++++++++++ docs/API_INTEGRATION.md | 6 +++--- docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + scripts/demo-check.sh | 2 ++ 5 files changed, 22 insertions(+), 4 deletions(-) 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 3567eb27..d95a54e2 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 @@ -74,6 +74,9 @@ public class AihrOrgSyncService { public SyncResponse sync(SyncRequest request) { SyncRequest req = request == null ? new SyncRequest(null, null, null, null, null, null, null, null) : request; + if (req.dryRun() == null) { + throw new IllegalArgumentException("组织同步必须明确传 dryRun=true 预检或 dryRun=false 写入"); + } String baseUrl = normalizeBaseUrl(configuredBaseUrl); if (baseUrl.isBlank()) { throw new IllegalArgumentException("请先配置 AIHR_ORG_SYNC_BASE_URL,值为外部开放平台 /api/open/v1 前缀"); @@ -81,7 +84,7 @@ public class AihrOrgSyncService { int pageSize = clamp(req.pageSize(), DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE); int maxPages = clamp(req.maxPages(), DEFAULT_MAX_PAGES, 1, DEFAULT_MAX_PAGES); - boolean dryRun = Boolean.TRUE.equals(req.dryRun()); + boolean dryRun = req.dryRun(); if (!dryRun) { 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 800306ba..c7b12fa6 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 @@ -42,6 +42,18 @@ public class AihrOrgSyncServiceTest { assertFalse(AihrOrgSyncService.hasUnsafeReplaceData(22, 22, 0, 22, 0, 0)); } + @Test + public void syncRequiresExplicitDryRunMode() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + TransactionTemplate transactionTemplate = mock(TransactionTemplate.class); + AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), jdbcTemplate, transactionTemplate); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.sync(null)); + + assertTrue(error.getMessage().contains("dryRun")); + verifyNoInteractions(jdbcTemplate, transactionTemplate); + } + @Test public void duplicateExternalPartyIdsAreCountedAfterTheFirstOccurrence() { assertEquals(1, AihrOrgSyncService.countDuplicatePartyIds(List.of("EMP-1", "EMP-2", "EMP-1"))); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index d00d2f0f..c539ff51 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -14,7 +14,7 @@ | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/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 名试点人员手机号 | +| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `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 名试点人员手机号 | | 移动端手机号登录 | `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` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 | @@ -114,13 +114,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,"allowPartialReplace":false}' + -d '{"dryRun":false,"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}' + -d '{"dryRun":false,"replaceExisting":true,"allowPartialReplace":true}' ``` ## 前端落点 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 0770bf74..877fe2d3 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -2,6 +2,7 @@ 更新时间:2026-07-14 +- 2026-07-14 BRD 5.4/G3 组织同步写入保护:`POST /api/aihr/org/sync` 现在要求请求显式传 `dryRun=true` 或 `dryRun=false`,省略请求体或传 `null` 会在外部拉取前直接拒绝,避免空请求因 `replaceExisting=true` 默认值意外写入组织快照;补充服务单测、`demo-check` marker 和 API 对接说明,未修改线上组织数据。 - 2026-07-14 BRD 严格门禁真实窗口复核:`AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-11 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 已通过源码、页面壳、租户和验证码检查,正式数据门禁实际失败为 `service_promotion=0`、`daily_service=0`、住宅 SOP `3/5`、案例 `2/20`、在职手机号映射 `1/3001`、入职日期 `0/3001`、满足十次训练 `0/52`、校准 `0/20`、SOP 可用 `0/0`、满意度响应 `0`;未写入业务数据,不能用本地演示样本伪造通过。 - 2026-07-14 BRD 验收脚本 marker 第三处同步:员工画像页当前使用“AI训练综合分仅作训练参考”,严格预检仍检查已废弃的“AI评分仅作训练参考”;现同步为当前文案,保留 AI 不直接等同绩效的风险提示。 - 2026-07-14 BRD 验收脚本 marker 再同步:严格预检继续发现成长激励页已将“当前运营口径(只读)”改为“学分规则(待 HR 配置)/规划参考,当前不生效”,脚本现检查当前真实文案,仍保留“规则未配置、不生效”的业务边界。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index eaf5a430..6d7ea0b3 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -358,6 +358,8 @@ contains frontend/src/views/admin/org-permission.vue "row.personName || row.extP 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-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "组织同步必须明确传 dryRun" +contains backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java "syncRequiresExplicitDryRunMode" contains backend/ruoyi-admin/src/main/resources/application-prod.yml "store-display-fields: false" contains mobile-uni/package.json "aihr-mobile-uni"