fix(aihr): reject duplicate organization identities

This commit is contained in:
2026-07-14 04:48:29 +08:00
parent 6b84f1ab60
commit 5ea8d02486
4 changed files with 30 additions and 2 deletions
@@ -27,8 +27,10 @@ import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
@@ -105,6 +107,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 duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList());
if (!rows.isEmpty() && phoneLinked < rows.size()) {
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
}
@@ -114,9 +117,15 @@ public class AihrOrgSyncService {
if (maskedPhone > 0) {
warnings.add("外部员工快照存在 " + maskedPhone + " 条脱敏/不可用手机号,不能用于移动端正式登录映射");
}
if (duplicatePartyIds > 0) {
warnings.add("外部员工快照存在 " + duplicatePartyIds + " 条重复 ext_party_id,写入会被唯一键折叠,请先修复上游快照");
}
if (!dryRun && replaceExisting && rows.isEmpty()) {
throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照");
}
if (!dryRun && duplicatePartyIds > 0) {
throw new IllegalArgumentException("外部员工快照存在重复 ext_party_id,已阻止写入组织人员快照;请先修复上游身份数据");
}
if (!dryRun && replaceExisting && !allowPartialReplace
&& hasUnsafeReplaceData(employeeItems.size(), rows.size(), skipped, phoneLinked, maskedPhone, suspectText)) {
throw new IllegalArgumentException("外部员工快照存在不完整或疑似异常数据,已阻止覆盖本地组织人员快照;请先 dry-run,确认后显式传 allowPartialReplace=true");
@@ -150,6 +159,17 @@ public class AihrOrgSyncService {
|| suspectText > 0;
}
static int countDuplicatePartyIds(List<String> extPartyIds) {
Set<String> seen = new HashSet<>();
int duplicates = 0;
for (String extPartyId : extPartyIds) {
if (!seen.add(extPartyId)) {
duplicates++;
}
}
return duplicates;
}
public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status,
Integer pageNum, Integer pageSize, Integer limit) {
requireSnapshotTable();
@@ -11,6 +11,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -37,6 +38,13 @@ public class AihrOrgSyncServiceTest {
assertFalse(AihrOrgSyncService.hasUnsafeReplaceData(22, 22, 0, 22, 0, 0));
}
@Test
public void duplicateExternalPartyIdsAreCountedAfterTheFirstOccurrence() {
assertEquals(1, AihrOrgSyncService.countDuplicatePartyIds(List.of("EMP-1", "EMP-2", "EMP-1")));
assertEquals(2, AihrOrgSyncService.countDuplicatePartyIds(List.of("EMP-1", "EMP-1", "EMP-1")));
assertEquals(0, AihrOrgSyncService.countDuplicatePartyIds(List.of("EMP-1", "EMP-2")));
}
@Test
public void unusablePhoneCandidateDetectsMaskedPhone() throws Exception {
ObjectMapper mapper = new ObjectMapper();
+1 -1
View File
@@ -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`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;`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` 等字段。默认 `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}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
+1 -1
View File
@@ -26,7 +26,7 @@
| 缺口 | BRD依据 | 当前事实 | 下一步 |
|---|---|---|---|
| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;生产快照当前为 22 条 active、2 个项目,但手机号关联为 0/22;生产环境当前未配置 `AIHR_ORG_SYNC_*` 组织同步凭据;移动端角色查询失败已改为安全降级员工端,线上视觉测试不能替代真实主管身份验证;组织覆盖写入现在会拒绝被跳过、手机号不完整、脱敏手机号或疑似乱码的快照,除非显式传 `allowPartialReplace=true` | 由业务/运维提供正式组织接口配置后先做 dry-run,核对项目、岗位、`ext_party_id`、手机号;补齐手机号映射并确认主管身份后,才执行正式同步和主管数据权限验收 |
| 正式主管身份、项目范围和数据权限 | 5.4、L1-B、G3 | 代码已有 `aihr_org_snapshot`、岗位/项目过滤和主管接口;生产快照当前为 22 条 active、2 个项目,但手机号关联为 0/22;生产环境当前未配置 `AIHR_ORG_SYNC_*` 组织同步凭据;移动端角色查询失败已改为安全降级员工端,线上视觉测试不能替代真实主管身份验证;组织覆盖写入现在会拒绝被跳过、手机号不完整、脱敏手机号或疑似乱码的快照,除非显式传 `allowPartialReplace=true`;重复 `ext_party_id` 无论是否允许部分覆盖都直接拒绝,避免唯一键折叠不同员工身份 | 由业务/运维提供正式组织接口配置后先做 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 建议且不直接挂钩绩效/晋升 |