fix(aihr): require explicit org sync dry run

This commit is contained in:
2026-07-14 20:57:50 +08:00
parent 7e01ded79c
commit 77c576bf5c
5 changed files with 22 additions and 4 deletions
@@ -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();
}
@@ -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")));
+3 -3
View File
@@ -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}'
```
## 前端落点
+1
View File
@@ -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 配置)/规划参考,当前不生效”,脚本现检查当前真实文案,仍保留“规则未配置、不生效”的业务边界。
+2
View File
@@ -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"