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();