fix(aihr): guard partial organization snapshot replace

This commit is contained in:
2026-07-14 04:43:15 +08:00
parent 4487721f3a
commit c9a7ceca0c
6 changed files with 37 additions and 7 deletions
@@ -14,7 +14,8 @@ public final class AihrOrgSyncDto {
Integer maxPages,
String groupId,
String companyId,
String departmentId
String departmentId,
Boolean allowPartialReplace
) {
}
@@ -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<String> 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();
@@ -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());