fix(aihr): tighten org and daily drill gates

This commit is contained in:
2026-07-14 13:38:58 +08:00
parent acc895e240
commit 1730819bfc
9 changed files with 55 additions and 15 deletions
@@ -156,6 +156,9 @@ mail:
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
aihr:
practice:
# 仅本地演示允许用训练次数代替 hire_date 推断新员工,生产默认关闭
allow-legacy-daily-drill-fallback: true
org-sync:
# 仅开发演示保留姓名/部门展示;生产默认关闭,组织同步只保留必要主体与范围字段
store-display-fields: true
@@ -110,7 +110,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 suspectText = (int) rows.stream().filter(OrgRow::suspectText).count();
int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList());
if (!rows.isEmpty() && phoneLinked < rows.size()) {
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
@@ -397,6 +397,10 @@ public class AihrOrgSyncService {
String positionName = firstNonBlank(text(employee, "position_name", "positionName", "job_title", "jobTitle",
"post_name", "postName", "job_name", "jobName", "role_name", "roleName", "position", "title"), "员工");
String positionLevel = firstNonBlank(text(employee, "position_level", "positionLevel", "job_level", "jobLevel"), level(positionName));
boolean suspectText = hasQuestionMark(projectName)
|| hasQuestionMark(deptName)
|| hasQuestionMark(personName)
|| hasQuestionMark(positionName);
return new OrgRow(
projectCode,
projectName,
@@ -407,7 +411,8 @@ public class AihrOrgSyncService {
positionName,
positionLevel(positionLevel, positionName),
status(text(employee, "status", "employment_status", "employmentStatus")),
LocalDate.now()
LocalDate.now(),
suspectText
);
}
@@ -614,13 +619,6 @@ public class AihrOrgSyncService {
return false;
}
private static boolean hasSuspectText(OrgRow row) {
return hasQuestionMark(row.projectName())
|| hasQuestionMark(row.deptName())
|| hasQuestionMark(row.personName())
|| hasQuestionMark(row.positionName());
}
private static boolean hasQuestionMark(String value) {
return clean(value).contains("?");
}
@@ -728,7 +726,8 @@ public class AihrOrgSyncService {
String positionName,
String positionLevel,
String employmentStatus,
LocalDate snapshotDate
LocalDate snapshotDate,
boolean suspectText
) {
Object[] args(String tenantId) {
return new Object[] {
@@ -48,6 +48,7 @@ import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -214,6 +215,8 @@ public class AihrPracticeSeedService {
private final AihrPracticeLlmService practiceLlmService;
private final AihrSopSeedService sopSeedService;
private final TransactionTemplate pilotExportTransaction;
@Value("${aihr.practice.allow-legacy-daily-drill-fallback:${AIHR_PRACTICE_ALLOW_LEGACY_DAILY_DRILL_FALLBACK:false}}")
private boolean allowLegacyDailyDrillFallback;
private volatile boolean practiceTableReady;
private volatile boolean assignmentTableReady;
private volatile boolean scenarioTableReady;
@@ -1841,9 +1844,14 @@ public class AihrPracticeSeedService {
.map(hireDate -> isWithinNewHireWindow(hireDate, LocalDate.now()))
.orElse(false);
}
// The legacy Demo snapshot has no hire_date. Keep its deterministic fallback
// until the external organization sync supplies the formal field.
return countMobileRecords(extPartyId) < 3;
// Formal production data must provide hire_date. The record-count fallback is
// intentionally opt-in for the local Demo only; it must not infer employment
// tenure from training activity in a pilot or production tenant.
return isLegacyDailyDrillEligible(allowLegacyDailyDrillFallback, extPartyId, countMobileRecords(extPartyId));
}
static boolean isLegacyDailyDrillEligible(boolean allowFallback, String extPartyId, int mobileRecordCount) {
return allowFallback && extPartyId != null && !extPartyId.isBlank() && mobileRecordCount < 3;
}
private Optional<LocalDate> findHireDate(String extPartyId) {
@@ -76,6 +76,22 @@ public class AihrOrgSyncServiceTest {
assertEquals("项目管理", ReflectionTestUtils.getField(demo, "deptName"));
}
@Test
public void suspectTextIsCheckedBeforeDisplayFieldsAreMasked() throws Exception {
ObjectMapper mapper = new ObjectMapper();
AihrOrgSyncService service = new AihrOrgSyncService(new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
var employee = mapper.readTree("""
{"id":"EMP-1","name":"测试?员工","department_name":"项目?管理","project_name":"翡翠湾","position_name":"主管","phone":"13900001111"}
""");
ReflectionTestUtils.setField(service, "storeDisplayFields", false);
Object masked = ReflectionTestUtils.invokeMethod(service, "orgRow", employee, Map.of(), Map.of());
assertEquals("", ReflectionTestUtils.getField(masked, "personName"));
assertEquals("", ReflectionTestUtils.getField(masked, "deptName"));
assertEquals(true, ReflectionTestUtils.getField(masked, "suspectText"));
}
@Test
public void snapshotSuspectTextQuerySearchesLiteralQuestionMarks() {
SnapshotJdbcTemplate jdbcTemplate = new SnapshotJdbcTemplate();
@@ -120,6 +120,14 @@ public class AihrPracticeSeedServiceTest {
assertFalse(AihrPracticeSeedService.isWithinNewHireWindow(null, asOf));
}
@Test
public void legacyDailyDrillFallbackOnlyUsesTrainingCountAfterExplicitOptIn() {
assertTrue(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "EMP-1", 0));
assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(false, "EMP-1", 0));
assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "EMP-1", 3));
assertFalse(AihrPracticeSeedService.isLegacyDailyDrillEligible(true, "", 0));
}
@Test
public void dailyDrillAnswerDoesNotOverwriteCompletedAttempt() throws Exception {
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));