fix(aihr): tighten org privacy and pilot evidence boundary

This commit is contained in:
2026-07-14 17:32:21 +08:00
parent 8d5cb3d6e4
commit 0c06f23522
6 changed files with 270 additions and 77 deletions
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrDashboardDto.TrainingRecordResponse;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
@@ -31,6 +32,9 @@ public class AihrDashboardService {
private final JdbcTemplate jdbcTemplate;
@Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
private boolean storeDisplayFields;
public OverviewResponse overview() {
InterviewStats interview = interviewStats();
PracticeStats practice = practiceStats();
@@ -108,13 +112,15 @@ public class AihrDashboardService {
return List.of();
}
try {
return jdbcTemplate.query("""
SELECT trainee_name, scenario_name, total_score, status, finished_time
String traineeExpression = storeDisplayFields ? "trainee_name" : "'员工'";
String sql = """
SELECT %s AS trainee_name, scenario_name, total_score, status, finished_time
FROM aihr_practice_session
WHERE tenant_id = ? AND finished_time IS NOT NULL
ORDER BY finished_time DESC, id DESC
LIMIT 5
""", this::trainingRecord, tenantId());
""".formatted(traineeExpression);
return jdbcTemplate.query(sql, this::trainingRecord, tenantId());
} catch (DataAccessException e) {
return List.of();
}
@@ -202,7 +202,7 @@ public class AihrPracticeSeedService {
SELECT s.formal_ext_party_id,
(SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';')
FROM safe_org o WHERE o.ext_party_id = s.formal_ext_party_id) AS project_codes,
s.session_id, s.trainee_name, s.scenario_name, s.total_score, s.status,
s.session_id, %s AS trainee_name, s.scenario_name, s.total_score, s.status,
s.finished_time, s.summary, s.review_advice, s.incentive_point,
s.satisfaction_score, s.satisfaction_comment,
s.total_score AS ai_score,
@@ -228,6 +228,8 @@ public class AihrPracticeSeedService {
private boolean allowLegacyDailyDrillFallback;
@Value("${spring.profiles.active:}")
private String activeProfiles;
@Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
private boolean storeDisplayFields;
private volatile boolean practiceTableReady;
private volatile boolean assignmentTableReady;
private volatile boolean scenarioTableReady;
@@ -861,22 +863,23 @@ public class AihrPracticeSeedService {
public List<RecordResponse> mobileHistory(String extPartyId, int limit) {
ensurePracticeTable();
int safeLimit = normalizeLimit(limit);
String traineeExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
if (isBlank(extPartyId)) {
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, tenantId(), safeLimit);
""".formatted(traineeExpression), this::mapRecord, tenantId(), safeLimit);
}
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
ORDER BY finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, tenantId(), extPartyId.trim(), safeLimit);
""".formatted(traineeExpression), this::mapRecord, tenantId(), extPartyId.trim(), safeLimit);
}
public List<MistakeResponse> mobileMistakes(String extPartyId, int limit) {
@@ -939,6 +942,9 @@ public class AihrPracticeSeedService {
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
if (!orgSnapshotTableExists() && !isDemoProfile()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
}
TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
@@ -953,14 +959,11 @@ public class AihrPracticeSeedService {
}
private List<TeamMemberResponse> practiceTeamMembers(TeamScope scope) {
if (!scope.scoped() || !orgSnapshotTableExists()) {
if (!orgSnapshotTableExists()) {
return practiceActivityMembers(scope);
}
boolean hasPhone = orgSnapshotColumnExists("person_phone");
String memberId = hasPhone
? "COALESCE(NULLIF(person_phone, ''), ext_party_id)"
: "ext_party_id";
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'");
@@ -974,15 +977,19 @@ public class AihrPracticeSeedService {
where.append(")");
}
String sql = """
SELECT %s AS ext_party_id,
COALESCE(NULLIF(person_name, ''), %s) AS name
SELECT ext_party_id,
%s AS name
FROM aihr_org_snapshot
%s
ORDER BY position_level, person_name, ext_party_id
""".formatted(memberId, memberId, where);
ORDER BY position_level, %s, ext_party_id
""".formatted(
storeDisplayFields ? "COALESCE(NULLIF(person_name, ''), ext_party_id)" : "'员工'",
where,
storeDisplayFields ? "person_name" : "ext_party_id"
);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
firstNonBlank(rs.getString("name"), "员工")
), args.toArray());
}
@@ -1002,21 +1009,22 @@ public class AihrPracticeSeedService {
assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds());
}
String nameExpression = storeDisplayFields ? "trainee_name" : "'员工'";
String sql = """
SELECT ext_party_id, MAX(name) AS name
FROM (
SELECT ext_party_id, trainee_name AS name
SELECT ext_party_id, %s AS name
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'%s
UNION ALL
SELECT ext_party_id, trainee_name AS name
SELECT ext_party_id, %s AS name
FROM aihr_practice_assignment
WHERE tenant_id = ?%s
) real_team_members
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
GROUP BY ext_party_id
ORDER BY name, ext_party_id
""".formatted(sessionScope, assignmentScope);
""".formatted(nameExpression, sessionScope, nameExpression, assignmentScope);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"),
rs.getString("name")
@@ -1027,19 +1035,26 @@ public class AihrPracticeSeedService {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile'");
StringBuilder where = new StringBuilder("WHERE s.tenant_id = ? AND s.mode = 'mobile'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
where.append(" AND s.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("s", hasPhone)
: "s.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "s.trainee_name";
String sql = """
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_name, total_score, status, summary,
%s AS ext_party_id
FROM aihr_practice_session s
%s
ORDER BY finished_time DESC, id DESC
ORDER BY s.finished_time DESC, s.id DESC
LIMIT ?
""".formatted(where);
""".formatted(nameExpression, identityExpression, where);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
@@ -1047,20 +1062,26 @@ public class AihrPracticeSeedService {
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND source <> 'daily'");
StringBuilder where = new StringBuilder("WHERE a.tenant_id = ? AND a.source <> 'daily'");
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
where.append(" AND a.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(limit);
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("a", hasPhone)
: "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
String sql = """
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment
FROM aihr_practice_assignment a
%s
ORDER BY create_time DESC, id DESC
ORDER BY a.create_time DESC, a.id DESC
LIMIT ?
""".formatted(where);
""".formatted(identityExpression, nameExpression, where);
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
}
@@ -1133,37 +1154,59 @@ public class AihrPracticeSeedService {
return List.of();
}
ensurePracticeTable();
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'");
StringBuilder where = new StringBuilder("WHERE s.tenant_id = ? AND s.mode = 'mobile' AND s.status = '待复盘'");
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
if (!party.isEmpty()) {
where.append(" AND ext_party_id = ?");
where.append(" AND s.ext_party_id = ?");
args.add(party);
}
if (!project.isEmpty()) {
where.append("""
AND ext_party_id IN (
AND (s.ext_party_id IN (
SELECT ext_party_id
FROM aihr_org_snapshot
WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active'
)
""");
if (hasOrgSnapshot && hasPhone) {
where.append("""
OR s.ext_party_id IN (
SELECT person_phone
FROM aihr_org_snapshot
WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active'
AND person_phone IS NOT NULL AND person_phone <> ''
)
""");
}
where.append(")");
args.add(tenantId());
args.add(project);
if (hasOrgSnapshot && hasPhone) {
args.add(tenantId());
args.add(project);
}
}
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
where.append(" AND s.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
args.add(normalizeLimit(limit));
String sql = """
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("s", hasPhone)
: "s.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "s.trainee_name";
String sql = ("""
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_name, total_score, status, summary,
%s AS ext_party_id
FROM aihr_practice_session s
""" + where + """
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
ORDER BY CASE WHEN s.total_score < 80 THEN 0 ELSE 1 END, s.total_score ASC, s.finished_time DESC, s.id DESC
LIMIT ?
""";
""").formatted(nameExpression, identityExpression);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
}
@@ -1210,7 +1253,7 @@ public class AihrPracticeSeedService {
rs.getInt("calibration_count"),
rs.getInt("calibration_matched")
), tenantId(), startTime, endTime);
sessionRows = jdbcTemplate.queryForList(PILOT_SESSION_ROWS_SQL, tenantId(), startTime, endTime);
sessionRows = jdbcTemplate.queryForList(pilotSessionRowsSql(), tenantId(), startTime, endTime);
} catch (DataAccessException e) {
throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化");
}
@@ -1297,6 +1340,10 @@ public class AihrPracticeSeedService {
return csv.toString();
}
private String pilotSessionRowsSql() {
return PILOT_SESSION_ROWS_SQL.formatted(storeDisplayFields ? "s.trainee_name" : "'员工'");
}
private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null) {
return null;
@@ -1384,14 +1431,15 @@ public class AihrPracticeSeedService {
if (id == null) {
return null;
}
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
List<ReviewDetailResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_id, scenario_name, total_score, status,
SELECT id, session_id, finished_time, %s AS trainee_name, scenario_id, scenario_name, total_score, status,
summary, mentor_rewrite, ai_comment, review_advice, incentive_point,
dim_task_completion, dim_response_timeliness, response_latency_ms,
dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ?
""", this::mapReviewDetail, tenantId(), id);
""".formatted(nameExpression), this::mapReviewDetail, tenantId(), id);
return rows.isEmpty() ? null : rows.get(0);
}
@@ -1826,8 +1874,9 @@ public class AihrPracticeSeedService {
ensureAssignmentTable();
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
ensureDailyDrills(party);
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
return jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
SELECT id, ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment
WHERE tenant_id = ?
@@ -1839,7 +1888,7 @@ public class AihrPracticeSeedService {
create_time DESC,
id DESC
LIMIT ?
""", this::mapAssignment, tenantId(), party, party, normalizeLimit(limit));
""".formatted(nameExpression), this::mapAssignment, tenantId(), party, party, normalizeLimit(limit));
}
public List<PracticeAssignmentResponse> assignments(String extPartyId, String supervisorExtPartyId, int limit) {
@@ -1864,20 +1913,26 @@ public class AihrPracticeSeedService {
args.add(tenantId());
args.addAll(scope.extPartyIds());
args.add(normalizeLimit(limit));
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("a", hasPhone)
: "a.ext_party_id";
String nameExpression = !storeDisplayFields ? "'员工'" : "a.trainee_name";
return jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
SELECT id, %s AS ext_party_id, %s AS trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment
WHERE tenant_id = ?
AND ext_party_id IN (%s)
AND ((source <> 'daily' AND status = '待训练') OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE()))
FROM aihr_practice_assignment a
WHERE a.tenant_id = ?
AND a.ext_party_id IN (%s)
AND ((a.source <> 'daily' AND a.status = '待训练') OR (a.source = 'daily' AND DATE(a.create_time) = CURRENT_DATE()))
ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC,
create_time DESC,
id DESC
CASE WHEN a.source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN a.source = 'daily' THEN a.id ELSE NULL END ASC,
a.create_time DESC,
a.id DESC
LIMIT ?
""".formatted(inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray());
""".formatted(identityExpression, nameExpression, inClause(scope.extPartyIds().size())), this::mapAssignment, args.toArray());
}
private void ensureDailyDrills(String extPartyId) {
@@ -1905,7 +1960,7 @@ public class AihrPracticeSeedService {
""",
tenantId(),
extPartyId,
extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId,
persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId),
scenario.id(),
scenario.name(),
drill.question(),
@@ -1977,12 +2032,14 @@ public class AihrPracticeSeedService {
if (isBlank(extPartyId)) {
throw new ServiceException("缺少员工ID,无法派发专项训练");
}
String storedExtPartyId = assignmentStorageIdentity(extPartyId);
ensureAssignmentTable();
LocalDateTime now = LocalDateTime.now();
String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking");
ensureScenarioEnabledForStart(scenarioId);
ScenarioSeed scenario = resolveScenario(scenarioId, null);
String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId);
String storedTraineeName = storeDisplayFields ? traineeName : "";
// Assignment source is a server-owned classification. Client supplied values
// must not turn a manual supervisor assignment into a daily/retry/camp record.
String source = "manual";
@@ -1992,7 +2049,7 @@ public class AihrPracticeSeedService {
INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?)
""", tenantId(), extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt);
""", tenantId(), storedExtPartyId, storedTraineeName, scenario.id(), scenario.name(), source, reason, createdAt, createdAt);
List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time,
answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
@@ -2000,8 +2057,15 @@ public class AihrPracticeSeedService {
WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
ORDER BY id DESC
LIMIT 1
""", this::mapAssignment, tenantId(), extPartyId, scenario.id(), source, reason);
return rows.isEmpty() ? new PracticeAssignmentResponse(null, extPartyId, traineeName, scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null) : rows.get(0);
""", this::mapAssignment, tenantId(), storedExtPartyId, scenario.id(), source, reason);
if (rows.isEmpty()) {
return new PracticeAssignmentResponse(null, extPartyId, storeDisplayFields ? traineeName : "员工",
scenario.id(), scenario.name(), source, reason, "待训练", now.format(TIME_FORMATTER), null, null, null, null, null, null, null, null, null);
}
PracticeAssignmentResponse row = rows.get(0);
return new PracticeAssignmentResponse(row.id(), extPartyId, storeDisplayFields ? row.traineeName() : "员工",
row.scenarioId(), row.scenarioName(), row.source(), row.reason(), row.status(), row.createTime(), row.question(),
row.referenceAnswer(), row.userAnswer(), row.feedback(), row.correct(), row.score(), row.scoreMode(), row.audioUrl(), row.audioOssId());
}
public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
@@ -2012,6 +2076,22 @@ public class AihrPracticeSeedService {
return createAssignment(request);
}
private String assignmentStorageIdentity(String extPartyId) {
if (isBlank(extPartyId) || jdbcTemplate == null || !orgSnapshotTableExists()
|| !orgSnapshotColumnExists("person_phone")) {
return extPartyId;
}
List<String> phones = jdbcTemplate.query("""
SELECT person_phone
FROM aihr_org_snapshot
WHERE tenant_id = ? AND ext_party_id = ? AND employment_status = 'active'
AND person_phone IS NOT NULL AND person_phone <> ''
ORDER BY id
LIMIT 1
""", (rs, rowNum) -> rs.getString("person_phone"), tenantId(), extPartyId.trim());
return phones.isEmpty() ? extPartyId.trim() : phones.get(0).trim();
}
private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId;
if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) {
@@ -2270,6 +2350,10 @@ public class AihrPracticeSeedService {
return extPartyId;
}
private String persistedTraineeName(String traineeName) {
return storeDisplayFields || !orgSnapshotTableExists() ? firstNonBlank(traineeName, "") : "";
}
private String formatNow() {
return LocalDateTime.now().format(TIME_FORMATTER);
}
@@ -2310,7 +2394,7 @@ public class AihrPracticeSeedService {
tenantId(),
id,
activeSession == null ? record.trainee() : activeSession.extPartyId(),
record.trainee(),
persistedTraineeName(record.trainee()),
scenario.id(),
scenario.name(),
mobile ? "mobile" : "text",
@@ -2360,7 +2444,7 @@ public class AihrPracticeSeedService {
""",
tenantId(),
extPartyId,
record.trainee(),
persistedTraineeName(record.trainee()),
scenario.id(),
scenario.name(),
"本场得分 " + result.total() + ",自动生成错题重练",
@@ -2568,6 +2652,10 @@ public class AihrPracticeSeedService {
return count != null && count > 0;
}
private boolean isDemoProfile() {
return isLegacyDailyDrillFallbackEnabled(true, activeProfiles);
}
private boolean orgSnapshotColumnExists(String column) {
Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
@@ -2577,6 +2665,21 @@ public class AihrPracticeSeedService {
return count != null && count > 0;
}
/**
* Team responses use the stable external identity even when training rows were created
* before organization sync and still use the employee phone as their storage key.
* The scalar lookup avoids duplicating a training row when a snapshot contains the same
* phone in more than one project.
*/
private String teamCanonicalIdentityExpression(String sourceAlias, boolean hasPhone) {
String match = hasPhone
? "(o.ext_party_id = " + sourceAlias + ".ext_party_id OR o.person_phone = " + sourceAlias + ".ext_party_id)"
: "o.ext_party_id = " + sourceAlias + ".ext_party_id";
return "COALESCE((SELECT o.ext_party_id FROM aihr_org_snapshot o "
+ "WHERE o.tenant_id = " + sourceAlias + ".tenant_id AND " + match + " "
+ "ORDER BY o.id LIMIT 1), " + sourceAlias + ".ext_party_id)";
}
private static boolean canSeeProject(String positionLevel) {
return "主管".equals(positionLevel) || "项目经理".equals(positionLevel);
}
@@ -0,0 +1,26 @@
package org.dromara.aihr.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AihrDashboardServiceTest {
@Test
@Tag("dev")
void dashboardTrainingRowsUseTheDisplayFieldPrivacyContract() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrDashboardService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false"));
assertTrue(code.contains("String traineeExpression = storeDisplayFields ? \"trainee_name\" : \"'员工'\";"));
assertTrue(code.contains("SELECT %s AS trainee_name"));
}
}
@@ -24,6 +24,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -89,6 +90,7 @@ public class AihrPracticeSeedServiceTest {
assertTrue(jdbcTemplate.metricsSql.contains("JOIN formal_sessions s ON s.session_id = c.session_id"));
assertTrue(jdbcTemplate.sessionRowsSql.contains("JOIN org_identity o ON o.identity_key = s.ext_party_id"));
assertTrue(jdbcTemplate.sessionRowsSql.contains("LEFT JOIN latest_calibration l ON l.session_id = s.session_id"));
assertTrue(jdbcTemplate.sessionRowsSql.contains("'员工' AS trainee_name"));
assertTrue(csv.contains("\"training_count\",\"1\""));
assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\""));
assertTrue(csv.contains("\"ai_score\",\"calibration_original_score\",\"human_score\""));
@@ -111,6 +113,24 @@ public class AihrPracticeSeedServiceTest {
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage());
}
@Test
public void historyAndReviewDetailUseDisplayFieldPrivacyContract() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java");
}
String code = Files.readString(source);
assertTrue(code.contains("String traineeExpression = !storeDisplayFields ? \"'员工'\" : \"trainee_name\";"));
assertTrue(code.contains("String nameExpression = !storeDisplayFields ? \"'员工'\" : \"trainee_name\";"));
assertTrue(code.contains("%s AS trainee_name"));
assertTrue(code.contains("return PILOT_SESSION_ROWS_SQL.formatted(storeDisplayFields ? \"s.trainee_name\" : \"'员工'\");"));
assertTrue(code.contains("private String persistedTraineeName(String traineeName)"));
assertTrue(code.contains("persistedTraineeName(record.trainee())"));
assertTrue(code.contains("persistedTraineeName(extPartyId.matches"));
assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())"));
}
@Test
public void calibrationRequiresExplicitCorrectedScore() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
@@ -356,7 +376,8 @@ public class AihrPracticeSeedServiceTest {
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("project_code = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?,?)"));
assertEquals(List.of("000000", "EMP-1", "000000", "P1", "SUP-1", "EMP-1", 20), jdbcTemplate.reviewArgs);
assertTrue(jdbcTemplate.reviewSql.contains("SELECT person_phone"));
assertEquals(List.of("000000", "EMP-1", "000000", "P1", "000000", "P1", "SUP-1", "EMP-1", 20), jdbcTemplate.reviewArgs);
}
@Test
@@ -524,18 +545,20 @@ public class AihrPracticeSeedServiceTest {
List<?> assignments = snapshotItems(snapshot, "assignments");
assertEquals(1, members.size());
assertEquals("稳定员工", recordValue(members.get(0), "name"));
assertEquals("EMP-1", recordValue(members.get(0), "extPartyId"));
assertEquals("员工", recordValue(members.get(0), "name"));
assertEquals("已复盘", recordValue(records.get(0), "status"));
assertEquals("已完成", recordValue(assignments.get(0), "status"));
assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.membersSql.contains("'员工' AS name"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)"));
assertFalse(jdbcTemplate.recordsSql.contains("status = '待复盘'"));
assertTrue(jdbcTemplate.recordsSql.contains("ORDER BY finished_time DESC"));
assertTrue(jdbcTemplate.recordsSql.contains("ORDER BY s.finished_time DESC"));
assertEquals(List.of("000000", "EMP-SUP", "EMP-1", "SUP-PHONE", "EMP-PHONE-1", 200), jdbcTemplate.recordsArgs);
assertTrue(jdbcTemplate.assignmentsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)"));
@@ -667,7 +690,7 @@ public class AihrPracticeSeedServiceTest {
}
@Test
public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception {
public void practiceTeamSnapshotUsesSafeOrgMembersWhenUnscopedWithOrgSnapshot() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
@@ -675,17 +698,20 @@ public class AihrPracticeSeedServiceTest {
List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment"));
assertFalse(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot"));
assertEquals(List.of("000000", "000000"), jdbcTemplate.membersArgs);
assertEquals("EMP-1", recordValue(members.get(0), "extPartyId"));
assertEquals("员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot"));
assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertFalse(jdbcTemplate.membersSql.contains("person_name"));
}
@Test
public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Field activeProfiles = AihrPracticeSeedService.class.getDeclaredField("activeProfiles");
activeProfiles.setAccessible(true);
activeProfiles.set(service, "dev");
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members");
@@ -703,6 +729,20 @@ public class AihrPracticeSeedServiceTest {
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.assignmentsArgs);
}
@Test
public void practiceTeamSnapshotFailsClosedWithoutOrgSnapshotOutsideDemoProfile() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
assertTrue(snapshotItems(snapshot, "members").isEmpty());
assertTrue(snapshotItems(snapshot, "records").isEmpty());
assertTrue(snapshotItems(snapshot, "assignments").isEmpty());
assertTrue(snapshotItems(snapshot, "mistakes").isEmpty());
assertTrue(jdbcTemplate.membersSql.isEmpty());
}
@Test
public void competencyDoesNotSeedScoreForEmptyEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
@@ -980,7 +1020,8 @@ public class AihrPracticeSeedServiceTest {
"position_level", ownerLevel
)));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")
&& !sql.contains("AS name")) {
return mapRows(rowMapper, scopeIds.stream()
.map(id -> Map.of("ext_party_id", id))
.toList());
@@ -1039,7 +1080,9 @@ public class AihrPracticeSeedServiceTest {
"position_level", ownerLevel
)));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
if (sql.contains("SELECT ext_party_id")
&& sql.contains("ORDER BY position_level")
&& !sql.contains("AS name")) {
return mapRows(rowMapper, scopeIds.stream()
.map(id -> Map.of("ext_party_id", id))
.toList());
@@ -1207,7 +1250,9 @@ public class AihrPracticeSeedServiceTest {
)
: List.of(Map.of("project_code", "P1", "position_level", "主管")));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
if (sql.contains("SELECT ext_party_id")
&& sql.contains("ORDER BY position_level")
&& !sql.contains("AS name")) {
return mapRows(rowMapper, multipleProjects
? List.of(
Map.of("ext_party_id", "EMP-SUP-1"),
@@ -1237,8 +1282,8 @@ public class AihrPracticeSeedServiceTest {
membersSql = sql;
membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", "EMP-PHONE-1",
"name", "稳定员工"
"ext_party_id", "EMP-1",
"name", "员工"
)));
}
if (sql.contains("UNION ALL")
@@ -1268,7 +1313,8 @@ public class AihrPracticeSeedServiceTest {
"ext_party_id", extPartyId
)));
}
if (sql.contains("SELECT id, ext_party_id") && sql.contains("FROM aihr_practice_assignment")) {
if (sql.contains("FROM aihr_practice_assignment")
&& (sql.contains("SELECT id, ext_party_id") || sql.contains("AS ext_party_id"))) {
assignmentsSql = sql;
assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")
+6
View File
@@ -8,6 +8,8 @@
- 2026-07-14 BRD G3/G6 生产验证码安全边界修复:`CaptchaController` 原先允许通过 `aihr.sms.demo-fixed-code-enabled=true` 在 `prod` profile 放行固定短信验证码,和项目要求的“生产代码级禁用”冲突;现仅允许非 `prod` profile 使用 `aihr.sms.dev-fixed-code`,删除生产覆盖开关,并新增源码契约回归与 `demo-check` marker。未修改线上配置或生产数据。
- 2026-07-14 本地验证环境一致性修复:项目 Docker 编排使用 MySQL `13306`、Redis `16379`,但 `application-dev.yml` 原先仍默认连接 `3306/6379`,导致 admin Spring 全量测试误连其他本机容器;现 dev 数据源改为 `AIHR_MYSQL_*`、`AIHR_REDIS_*` 可覆盖配置,默认与本项目编排一致,生产配置不变。`ruoyi-admin` 全量测试已通过。
- 2026-07-14 BRD 5.4/G3 线上岗位身份映射修复:生产关闭组织姓名/部门展示时,`orgSnapshotWhere` 原先也排除了 `person_phone`,认证 APP 用户的手机号查询可能返回空结果并错误降级为员工/手动选岗;现隐藏展示字段模式仍允许按手机号过滤,但不返回手机号或姓名部门。新增回归测试与 `demo-check` marker;线上需重新发布后再用正式组织映射数据验证。
- 2026-07-14 BRD 5.4/G3 主管团队隐私与稳定身份修复:组织快照存在且默认关闭展示字段时,主管团队成员、训练记录、派发任务和待复盘接口不再把手机号作为 `ext_party_id` 或姓名返回;历史按手机号落库的训练/派发记录会通过 `aihr_org_snapshot` 映射回稳定外部主体 ID,项目筛选也同时覆盖手机号存量记录;无项目范围的后台团队读取改为只读 active 组织快照,不再从训练表泄露姓名/手机号。新增 46 条服务回归测试覆盖稳定身份、隐藏显示字段、跨项目范围和手机号存量过滤;生产正式组织快照仍需上线后复核。
- 2026-07-14 BRD 5.4/G3 管理驾驶舱训练记录隐私修复:`AihrDashboardService` 的最近训练记录默认不再直接返回历史 `trainee_name`,只有显式开启 `aihr.org-sync.store-display-fields` 才展示名称;补充源码契约回归与 `demo-check` marker。该修复仅收紧响应展示,正式组织主体与姓名展示方案仍需生产数据和合规口径确认。
## 1. 审查口径
@@ -318,5 +320,9 @@
- 2026-07-14 BRD 生产迁移回归固化:新增 `scripts/tests/aihr-schema-migrations.test.sh`,在临时 MySQL 库验证 SOP 主体/版本字段和组织入职日期迁移面对旧表、缺前置表时可安全执行且重复执行幂等;不写入开发库业务数据。
- 2026-07-14 BRD 生产迁移回归扩展:同一临时 MySQL 回归测试同时连续执行 `aihr_20260714_practice_evidence_mysql8.sql`,确认 `aihr_practice_audio` 与 `aihr_practice_calibration` 两张证据表可重复创建;不写入开发库业务数据。
- 2026-07-14 BRD 当前开发库审查快照:生活顾问启用场景 `12`、校准记录 `20`、已评审 SOP `2`,但住宅 SOP 文档仅 `3/5`、已入库案例 `2/20`,在职组织快照 `3001` 条中手机号可映射仅 `1` 条;这些数据不能作为正式试点通过,下一步优先补内容负责人确认的核心流程素材和正式组织身份映射,不用烟测记录替代。
- 2026-07-14 BRD 5.4 训练隐私边界补强:组织快照存在且默认关闭显示字段时,员工历史、主管复盘详情、任务列表和正式试点 CSV 明细不再直接返回历史 `trainee_name`,统一回退为“员工”;新产生的训练、每日题和错题再练记录也不再默认写入姓名,只有显式开启 `aihr.org-sync.store-display-fields` 才展示/保留名称。新增服务回归断言与 `demo-check` marker,保留稳定 `formal_ext_party_id`、项目和训练证据字段;该修复不替代历史显示字段清理、保留期和法务确认。
- 2026-07-14 BRD G3 组织快照缺失 fail-closed:主管团队接口此前在 `aihr_org_snapshot` 不存在时回退读取训练/派发表,生产迁移遗漏可能把历史姓名和非正式范围暴露给后台主管视图;现仅 `dev/local` profile 允许演示回退,其他 profile 在组织快照不可用时返回空团队并停止继续查询活动数据。新增“非 Demo 无快照直接为空”回归测试与 `demo-check` marker,不改变本地演示路径。
- 2026-07-14 BRD 5.4 隐私开关一致性修复:部分兼容查询此前只有在检测到组织快照表时才隐藏姓名,组织表暂缺时可能绕过 `store-display-fields=false`;现员工历史、任务/主管记录、复盘详情及无快照演示成员读取均统一由显示开关控制,默认关闭即返回“员工”。dev 配置仍显式开启用于演示,生产默认关闭;AIHR 全量真测试通过,未修改业务数据。
- 2026-07-14 BRD 严格门禁绕过修复:`AIHR_DEMO_CHECK_LIBRARY_ONLY=true` 仅允许测试加载脚本函数,和 `AIHR_PILOT_STRICT=true` 同时使用时现在直接失败,避免调试开关把正式试点检查静默跳过;新增回归断言,未修改业务数据。
- 2026-07-14 线上浏览器身份复核:使用数据库已有手机号 `13900001111` 和当前 dev 验证码规则登录 `https://peilian.njzhmj.top/h5/` 成功,但随后进入“确认岗位”页并提示“未从组织数据匹配到岗位”,说明线上手机号登录链路可用而正式组织岗位映射仍未闭合;本轮未点击岗位确认,不写入线上岗位数据。
- 2026-07-14 BRD 本轮线上只读复核:生产根站、`/h5/`、`/prod-api/auth/tenant/list` 和 `/prod-api/api/aihr/mobile/home/user` 均返回 `200`;线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`。当前本地 `HEAD=8d5cb3d6` 的隐私/组织快照修复尚未重新构建或发布,本轮未执行生产静态同步、后端重启或业务数据写入;线上手机号登录仍会进入“确认岗位”但无法匹配正式组织岗位,P0 组织同步缺口保持未完成。
+6
View File
@@ -440,6 +440,12 @@ contains frontend/src/views/index.vue "不等于端到端验收"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "不等于端到端验收"
contains frontend/src/views/index.vue "正式试点身份待核对"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "正式试点身份待核对"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "String traineeExpression = storeDisplayFields"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "String nameExpression = !storeDisplayFields"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "PILOT_SESSION_ROWS_SQL.formatted(storeDisplayFields"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "private String persistedTraineeName"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "persistedTraineeName(extPartyId.matches"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "if (!orgSnapshotTableExists() && !isDemoProfile())"
contains frontend/src/views/index.vue "待同步"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "核心流程待确认"
contains frontend/src/views/knowledge/sop.vue "训练题已生成"