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.dromara.common.satoken.utils.LoginHelper;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.sql.ResultSet; import java.sql.ResultSet;
@@ -31,6 +32,9 @@ public class AihrDashboardService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@Value("${aihr.org-sync.store-display-fields:${AIHR_ORG_SYNC_STORE_DISPLAY_FIELDS:false}}")
private boolean storeDisplayFields;
public OverviewResponse overview() { public OverviewResponse overview() {
InterviewStats interview = interviewStats(); InterviewStats interview = interviewStats();
PracticeStats practice = practiceStats(); PracticeStats practice = practiceStats();
@@ -108,13 +112,15 @@ public class AihrDashboardService {
return List.of(); return List.of();
} }
try { try {
return jdbcTemplate.query(""" String traineeExpression = storeDisplayFields ? "trainee_name" : "'员工'";
SELECT trainee_name, scenario_name, total_score, status, finished_time String sql = """
SELECT %s AS trainee_name, scenario_name, total_score, status, finished_time
FROM aihr_practice_session FROM aihr_practice_session
WHERE tenant_id = ? AND finished_time IS NOT NULL WHERE tenant_id = ? AND finished_time IS NOT NULL
ORDER BY finished_time DESC, id DESC ORDER BY finished_time DESC, id DESC
LIMIT 5 LIMIT 5
""", this::trainingRecord, tenantId()); """.formatted(traineeExpression);
return jdbcTemplate.query(sql, this::trainingRecord, tenantId());
} catch (DataAccessException e) { } catch (DataAccessException e) {
return List.of(); return List.of();
} }
@@ -202,7 +202,7 @@ public class AihrPracticeSeedService {
SELECT s.formal_ext_party_id, SELECT s.formal_ext_party_id,
(SELECT GROUP_CONCAT(DISTINCT o.project_code ORDER BY o.project_code SEPARATOR ';') (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, 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.finished_time, s.summary, s.review_advice, s.incentive_point,
s.satisfaction_score, s.satisfaction_comment, s.satisfaction_score, s.satisfaction_comment,
s.total_score AS ai_score, s.total_score AS ai_score,
@@ -228,6 +228,8 @@ public class AihrPracticeSeedService {
private boolean allowLegacyDailyDrillFallback; private boolean allowLegacyDailyDrillFallback;
@Value("${spring.profiles.active:}") @Value("${spring.profiles.active:}")
private String activeProfiles; 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 practiceTableReady;
private volatile boolean assignmentTableReady; private volatile boolean assignmentTableReady;
private volatile boolean scenarioTableReady; private volatile boolean scenarioTableReady;
@@ -861,22 +863,23 @@ public class AihrPracticeSeedService {
public List<RecordResponse> mobileHistory(String extPartyId, int limit) { public List<RecordResponse> mobileHistory(String extPartyId, int limit) {
ensurePracticeTable(); ensurePracticeTable();
int safeLimit = normalizeLimit(limit); int safeLimit = normalizeLimit(limit);
String traineeExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
if (isBlank(extPartyId)) { if (isBlank(extPartyId)) {
return jdbcTemplate.query(""" 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 FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' WHERE tenant_id = ? AND mode = 'mobile'
ORDER BY finished_time DESC, id DESC ORDER BY finished_time DESC, id DESC
LIMIT ? LIMIT ?
""", this::mapRecord, tenantId(), safeLimit); """.formatted(traineeExpression), this::mapRecord, tenantId(), safeLimit);
} }
return jdbcTemplate.query(""" 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 FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ? WHERE tenant_id = ? AND mode = 'mobile' AND ext_party_id = ?
ORDER BY finished_time DESC, id DESC ORDER BY finished_time DESC, id DESC
LIMIT ? LIMIT ?
""", this::mapRecord, tenantId(), extPartyId.trim(), safeLimit); """.formatted(traineeExpression), this::mapRecord, tenantId(), extPartyId.trim(), safeLimit);
} }
public List<MistakeResponse> mobileMistakes(String extPartyId, int limit) { public List<MistakeResponse> mobileMistakes(String extPartyId, int limit) {
@@ -939,6 +942,9 @@ public class AihrPracticeSeedService {
} }
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) { 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); TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit); int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) { if (scope.scoped() && scope.extPartyIds().isEmpty()) {
@@ -953,14 +959,11 @@ public class AihrPracticeSeedService {
} }
private List<TeamMemberResponse> practiceTeamMembers(TeamScope scope) { private List<TeamMemberResponse> practiceTeamMembers(TeamScope scope) {
if (!scope.scoped() || !orgSnapshotTableExists()) { if (!orgSnapshotTableExists()) {
return practiceActivityMembers(scope); return practiceActivityMembers(scope);
} }
boolean hasPhone = orgSnapshotColumnExists("person_phone"); boolean hasPhone = orgSnapshotColumnExists("person_phone");
String memberId = hasPhone
? "COALESCE(NULLIF(person_phone, ''), ext_party_id)"
: "ext_party_id";
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
args.add(tenantId()); args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'"); StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND employment_status = 'active'");
@@ -974,15 +977,19 @@ public class AihrPracticeSeedService {
where.append(")"); where.append(")");
} }
String sql = """ String sql = """
SELECT %s AS ext_party_id, SELECT ext_party_id,
COALESCE(NULLIF(person_name, ''), %s) AS name %s AS name
FROM aihr_org_snapshot FROM aihr_org_snapshot
%s %s
ORDER BY position_level, person_name, ext_party_id ORDER BY position_level, %s, ext_party_id
""".formatted(memberId, memberId, where); """.formatted(
storeDisplayFields ? "COALESCE(NULLIF(person_name, ''), ext_party_id)" : "'员工'",
where,
storeDisplayFields ? "person_name" : "ext_party_id"
);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse( return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"), rs.getString("ext_party_id"),
rs.getString("name") firstNonBlank(rs.getString("name"), "员工")
), args.toArray()); ), args.toArray());
} }
@@ -1002,21 +1009,22 @@ public class AihrPracticeSeedService {
assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")"; assignmentScope = " AND ext_party_id IN (" + inClause(scope.extPartyIds().size()) + ")";
args.addAll(scope.extPartyIds()); args.addAll(scope.extPartyIds());
} }
String nameExpression = storeDisplayFields ? "trainee_name" : "'员工'";
String sql = """ String sql = """
SELECT ext_party_id, MAX(name) AS name SELECT ext_party_id, MAX(name) AS name
FROM ( FROM (
SELECT ext_party_id, trainee_name AS name SELECT ext_party_id, %s AS name
FROM aihr_practice_session FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile'%s WHERE tenant_id = ? AND mode = 'mobile'%s
UNION ALL UNION ALL
SELECT ext_party_id, trainee_name AS name SELECT ext_party_id, %s AS name
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ?%s WHERE tenant_id = ?%s
) real_team_members ) real_team_members
WHERE ext_party_id IS NOT NULL AND ext_party_id <> '' WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
GROUP BY ext_party_id GROUP BY ext_party_id
ORDER BY name, 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( return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamMemberResponse(
rs.getString("ext_party_id"), rs.getString("ext_party_id"),
rs.getString("name") rs.getString("name")
@@ -1027,19 +1035,26 @@ public class AihrPracticeSeedService {
ensurePracticeTable(); ensurePracticeTable();
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
args.add(tenantId()); 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()) { 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.addAll(scope.extPartyIds());
} }
args.add(limit); 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 = """ String sql = """
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,
FROM aihr_practice_session %s AS ext_party_id
FROM aihr_practice_session s
%s %s
ORDER BY finished_time DESC, id DESC ORDER BY s.finished_time DESC, s.id DESC
LIMIT ? LIMIT ?
""".formatted(where); """.formatted(nameExpression, identityExpression, where);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray()); return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
} }
@@ -1047,20 +1062,26 @@ public class AihrPracticeSeedService {
ensureAssignmentTable(); ensureAssignmentTable();
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
args.add(tenantId()); 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()) { 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.addAll(scope.extPartyIds());
} }
args.add(limit); 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 = """ 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 answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment a
%s %s
ORDER BY create_time DESC, id DESC ORDER BY a.create_time DESC, a.id DESC
LIMIT ? LIMIT ?
""".formatted(where); """.formatted(identityExpression, nameExpression, where);
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray()); return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
} }
@@ -1133,37 +1154,59 @@ public class AihrPracticeSeedService {
return List.of(); return List.of();
} }
ensurePracticeTable(); ensurePracticeTable();
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
args.add(tenantId()); 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(); String party = isBlank(extPartyId) ? "" : extPartyId.trim();
if (!party.isEmpty()) { if (!party.isEmpty()) {
where.append(" AND ext_party_id = ?"); where.append(" AND s.ext_party_id = ?");
args.add(party); args.add(party);
} }
if (!project.isEmpty()) { if (!project.isEmpty()) {
where.append(""" where.append("""
AND ext_party_id IN ( AND (s.ext_party_id IN (
SELECT ext_party_id SELECT ext_party_id
FROM aihr_org_snapshot FROM aihr_org_snapshot
WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active' 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(tenantId());
args.add(project); args.add(project);
if (hasOrgSnapshot && hasPhone) {
args.add(tenantId());
args.add(project);
}
} }
if (scope.scoped()) { 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.addAll(scope.extPartyIds());
} }
args.add(normalizeLimit(limit)); args.add(normalizeLimit(limit));
String sql = """ String identityExpression = hasOrgSnapshot
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id ? teamCanonicalIdentityExpression("s", hasPhone)
FROM aihr_practice_session : "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 + """ """ + 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 ? LIMIT ?
"""; """).formatted(nameExpression, identityExpression);
return jdbcTemplate.query(sql, this::mapRecord, args.toArray()); return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
} }
@@ -1210,7 +1253,7 @@ public class AihrPracticeSeedService {
rs.getInt("calibration_count"), rs.getInt("calibration_count"),
rs.getInt("calibration_matched") rs.getInt("calibration_matched")
), tenantId(), startTime, endTime); ), tenantId(), startTime, endTime);
sessionRows = jdbcTemplate.queryForList(PILOT_SESSION_ROWS_SQL, tenantId(), startTime, endTime); sessionRows = jdbcTemplate.queryForList(pilotSessionRowsSql(), tenantId(), startTime, endTime);
} catch (DataAccessException e) { } catch (DataAccessException e) {
throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化"); throw new ServiceException("正式试点数据不可用,请先完成组织快照和二期数据表初始化");
} }
@@ -1297,6 +1340,10 @@ public class AihrPracticeSeedService {
return csv.toString(); return csv.toString();
} }
private String pilotSessionRowsSql() {
return PILOT_SESSION_ROWS_SQL.formatted(storeDisplayFields ? "s.trainee_name" : "'员工'");
}
private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) { private static TransactionTemplate configurePilotExportTransaction(TransactionTemplate transactionTemplate) {
if (transactionTemplate == null) { if (transactionTemplate == null) {
return null; return null;
@@ -1384,14 +1431,15 @@ public class AihrPracticeSeedService {
if (id == null) { if (id == null) {
return null; return null;
} }
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
List<ReviewDetailResponse> rows = jdbcTemplate.query(""" 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, summary, mentor_rewrite, ai_comment, review_advice, incentive_point,
dim_task_completion, dim_response_timeliness, response_latency_ms, dim_task_completion, dim_response_timeliness, response_latency_ms,
dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json dim_compliance, dim_emotion, dim_communication, dim_marketing, dialogue_json, annotations_json
FROM aihr_practice_session FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND id = ? 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); return rows.isEmpty() ? null : rows.get(0);
} }
@@ -1826,8 +1874,9 @@ public class AihrPracticeSeedService {
ensureAssignmentTable(); ensureAssignmentTable();
String party = isBlank(extPartyId) ? "" : extPartyId.trim(); String party = isBlank(extPartyId) ? "" : extPartyId.trim();
ensureDailyDrills(party); ensureDailyDrills(party);
String nameExpression = !storeDisplayFields ? "'员工'" : "trainee_name";
return jdbcTemplate.query(""" 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 answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment
WHERE tenant_id = ? WHERE tenant_id = ?
@@ -1839,7 +1888,7 @@ public class AihrPracticeSeedService {
create_time DESC, create_time DESC,
id DESC id DESC
LIMIT ? 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) { public List<PracticeAssignmentResponse> assignments(String extPartyId, String supervisorExtPartyId, int limit) {
@@ -1864,20 +1913,26 @@ public class AihrPracticeSeedService {
args.add(tenantId()); args.add(tenantId());
args.addAll(scope.extPartyIds()); args.addAll(scope.extPartyIds());
args.add(normalizeLimit(limit)); 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(""" 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 answer_text, answer_audio_url, answer_audio_oss_id, feedback, correct, score, score_mode
FROM aihr_practice_assignment FROM aihr_practice_assignment a
WHERE tenant_id = ? WHERE a.tenant_id = ?
AND ext_party_id IN (%s) AND a.ext_party_id IN (%s)
AND ((source <> 'daily' AND status = '待训练') OR (source = 'daily' AND DATE(create_time) = CURRENT_DATE())) AND ((a.source <> 'daily' AND a.status = '待训练') OR (a.source = 'daily' AND DATE(a.create_time) = CURRENT_DATE()))
ORDER BY ORDER BY
CASE WHEN source = 'daily' THEN 1 ELSE 0 END, CASE WHEN a.source = 'daily' THEN 1 ELSE 0 END,
CASE WHEN source = 'daily' THEN id ELSE NULL END ASC, CASE WHEN a.source = 'daily' THEN a.id ELSE NULL END ASC,
create_time DESC, a.create_time DESC,
id DESC a.id DESC
LIMIT ? 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) { private void ensureDailyDrills(String extPartyId) {
@@ -1905,7 +1960,7 @@ public class AihrPracticeSeedService {
""", """,
tenantId(), tenantId(),
extPartyId, extPartyId,
extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId, persistedTraineeName(extPartyId.matches("\\d{11}") ? "手机用户" + extPartyId.substring(7) : extPartyId),
scenario.id(), scenario.id(),
scenario.name(), scenario.name(),
drill.question(), drill.question(),
@@ -1977,12 +2032,14 @@ public class AihrPracticeSeedService {
if (isBlank(extPartyId)) { if (isBlank(extPartyId)) {
throw new ServiceException("缺少员工ID,无法派发专项训练"); throw new ServiceException("缺少员工ID,无法派发专项训练");
} }
String storedExtPartyId = assignmentStorageIdentity(extPartyId);
ensureAssignmentTable(); ensureAssignmentTable();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking"); String scenarioId = firstNonBlank(request == null ? null : request.scenarioId(), "fee-parking");
ensureScenarioEnabledForStart(scenarioId); ensureScenarioEnabledForStart(scenarioId);
ScenarioSeed scenario = resolveScenario(scenarioId, null); ScenarioSeed scenario = resolveScenario(scenarioId, null);
String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId); String traineeName = firstNonBlank(request == null ? null : request.traineeName(), extPartyId);
String storedTraineeName = storeDisplayFields ? traineeName : "";
// Assignment source is a server-owned classification. Client supplied values // Assignment source is a server-owned classification. Client supplied values
// must not turn a manual supervisor assignment into a daily/retry/camp record. // must not turn a manual supervisor assignment into a daily/retry/camp record.
String source = "manual"; String source = "manual";
@@ -1992,7 +2049,7 @@ public class AihrPracticeSeedService {
INSERT INTO aihr_practice_assignment INSERT INTO aihr_practice_assignment
(tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time) (tenant_id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, '待训练', ?, ?) 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(""" List<PracticeAssignmentResponse> rows = jdbcTemplate.query("""
SELECT id, ext_party_id, trainee_name, scenario_id, scenario_name, source, reason, status, create_time, 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 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 = ? WHERE tenant_id = ? AND ext_party_id = ? AND scenario_id = ? AND source = ? AND reason = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT 1 LIMIT 1
""", this::mapAssignment, tenantId(), extPartyId, scenario.id(), source, reason); """, this::mapAssignment, tenantId(), storedExtPartyId, 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); 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) { public PracticeAssignmentResponse createAssignment(PracticeAssignmentRequest request, String supervisorExtPartyId) {
@@ -2012,6 +2076,22 @@ public class AihrPracticeSeedService {
return createAssignment(request); 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) { private void markAssignmentCompleted(Long requestAssignmentId, ActiveSession activeSession) {
Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId; Long assignmentId = requestAssignmentId == null && activeSession != null ? activeSession.assignmentId() : requestAssignmentId;
if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) { if (assignmentId == null || activeSession == null || isBlank(activeSession.extPartyId())) {
@@ -2270,6 +2350,10 @@ public class AihrPracticeSeedService {
return extPartyId; return extPartyId;
} }
private String persistedTraineeName(String traineeName) {
return storeDisplayFields || !orgSnapshotTableExists() ? firstNonBlank(traineeName, "") : "";
}
private String formatNow() { private String formatNow() {
return LocalDateTime.now().format(TIME_FORMATTER); return LocalDateTime.now().format(TIME_FORMATTER);
} }
@@ -2310,7 +2394,7 @@ public class AihrPracticeSeedService {
tenantId(), tenantId(),
id, id,
activeSession == null ? record.trainee() : activeSession.extPartyId(), activeSession == null ? record.trainee() : activeSession.extPartyId(),
record.trainee(), persistedTraineeName(record.trainee()),
scenario.id(), scenario.id(),
scenario.name(), scenario.name(),
mobile ? "mobile" : "text", mobile ? "mobile" : "text",
@@ -2360,7 +2444,7 @@ public class AihrPracticeSeedService {
""", """,
tenantId(), tenantId(),
extPartyId, extPartyId,
record.trainee(), persistedTraineeName(record.trainee()),
scenario.id(), scenario.id(),
scenario.name(), scenario.name(),
"本场得分 " + result.total() + ",自动生成错题重练", "本场得分 " + result.total() + ",自动生成错题重练",
@@ -2568,6 +2652,10 @@ public class AihrPracticeSeedService {
return count != null && count > 0; return count != null && count > 0;
} }
private boolean isDemoProfile() {
return isLegacyDailyDrillFallbackEnabled(true, activeProfiles);
}
private boolean orgSnapshotColumnExists(String column) { private boolean orgSnapshotColumnExists(String column) {
Integer count = jdbcTemplate.queryForObject(""" Integer count = jdbcTemplate.queryForObject("""
SELECT COUNT(*) SELECT COUNT(*)
@@ -2577,6 +2665,21 @@ public class AihrPracticeSeedService {
return count != null && count > 0; 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) { private static boolean canSeeProject(String positionLevel) {
return "主管".equals(positionLevel) || "项目经理".equals(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 org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.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("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("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("\"training_count\",\"1\""));
assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\"")); assertTrue(csv.contains("\"FORMAL-1\",\"P1\",\"session-1\""));
assertTrue(csv.contains("\"ai_score\",\"calibration_original_score\",\"human_score\"")); assertTrue(csv.contains("\"ai_score\",\"calibration_original_score\",\"human_score\""));
@@ -111,6 +113,24 @@ public class AihrPracticeSeedServiceTest {
assertEquals("缺少员工ID,无法派发专项训练", error.getMessage()); 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 @Test
public void calibrationRequiresExplicitCorrectedScore() { public void calibrationRequiresExplicitCorrectedScore() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null); 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("ext_party_id = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("project_code = ?")); assertTrue(jdbcTemplate.reviewSql.contains("project_code = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?,?)")); 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 @Test
@@ -524,18 +545,20 @@ public class AihrPracticeSeedServiceTest {
List<?> assignments = snapshotItems(snapshot, "assignments"); List<?> assignments = snapshotItems(snapshot, "assignments");
assertEquals(1, members.size()); 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(records.get(0), "status"));
assertEquals("已完成", recordValue(assignments.get(0), "status")); assertEquals("已完成", recordValue(assignments.get(0), "status"));
assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'")); assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?")); assertTrue(jdbcTemplate.membersSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.membersSql.contains("'员工' AS name"));
assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)")); assertTrue(jdbcTemplate.membersSql.contains("ext_party_id IN (?,?,?,?)"));
assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?")); assertTrue(jdbcTemplate.recordsSql.contains("tenant_id = ?"));
assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'")); assertTrue(jdbcTemplate.recordsSql.contains("mode = 'mobile'"));
assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)")); assertTrue(jdbcTemplate.recordsSql.contains("ext_party_id IN (?,?,?,?)"));
assertFalse(jdbcTemplate.recordsSql.contains("status = '待复盘'")); 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); 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("tenant_id = ?"));
assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)")); assertTrue(jdbcTemplate.assignmentsSql.contains("ext_party_id IN (?,?,?,?)"));
@@ -667,7 +690,7 @@ public class AihrPracticeSeedServiceTest {
} }
@Test @Test
public void practiceTeamSnapshotUsesActivityMembersWhenUnscopedWithOrgSnapshot() throws Exception { public void practiceTeamSnapshotUsesSafeOrgMembersWhenUnscopedWithOrgSnapshot() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true); TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(true);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
@@ -675,17 +698,20 @@ public class AihrPracticeSeedServiceTest {
List<?> members = snapshotItems(snapshot, "members"); List<?> members = snapshotItems(snapshot, "members");
assertEquals(1, members.size()); assertEquals(1, members.size());
assertEquals("真实训练员工", recordValue(members.get(0), "name")); assertEquals("EMP-1", recordValue(members.get(0), "extPartyId"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_session")); assertEquals("员工", recordValue(members.get(0), "name"));
assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_practice_assignment")); assertTrue(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot"));
assertFalse(jdbcTemplate.membersSql.contains("FROM aihr_org_snapshot")); assertTrue(jdbcTemplate.membersSql.contains("employment_status = 'active'"));
assertEquals(List.of("000000", "000000"), jdbcTemplate.membersArgs); assertFalse(jdbcTemplate.membersSql.contains("person_name"));
} }
@Test @Test
public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception { public void practiceTeamSnapshotScopesRealFallbackWhenOrgSnapshotIsUnavailable() throws Exception {
TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false); TeamSnapshotJdbcTemplate jdbcTemplate = new TeamSnapshotJdbcTemplate(false);
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null); 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); Object snapshot = invokePracticeTeamSnapshot(service, "SUP-PHONE", 200);
List<?> members = snapshotItems(snapshot, "members"); List<?> members = snapshotItems(snapshot, "members");
@@ -703,6 +729,20 @@ public class AihrPracticeSeedServiceTest {
assertEquals(List.of("000000", "SUP-PHONE", 200), jdbcTemplate.assignmentsArgs); 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 @Test
public void competencyDoesNotSeedScoreForEmptyEmployee() { public void competencyDoesNotSeedScoreForEmptyEmployee() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
@@ -980,7 +1020,8 @@ public class AihrPracticeSeedServiceTest {
"position_level", ownerLevel "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() return mapRows(rowMapper, scopeIds.stream()
.map(id -> Map.of("ext_party_id", id)) .map(id -> Map.of("ext_party_id", id))
.toList()); .toList());
@@ -1039,7 +1080,9 @@ public class AihrPracticeSeedServiceTest {
"position_level", ownerLevel "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() return mapRows(rowMapper, scopeIds.stream()
.map(id -> Map.of("ext_party_id", id)) .map(id -> Map.of("ext_party_id", id))
.toList()); .toList());
@@ -1207,7 +1250,9 @@ public class AihrPracticeSeedServiceTest {
) )
: List.of(Map.of("project_code", "P1", "position_level", "主管"))); : 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 return mapRows(rowMapper, multipleProjects
? List.of( ? List.of(
Map.of("ext_party_id", "EMP-SUP-1"), Map.of("ext_party_id", "EMP-SUP-1"),
@@ -1237,8 +1282,8 @@ public class AihrPracticeSeedServiceTest {
membersSql = sql; membersSql = sql;
membersArgs = List.of(args); membersArgs = List.of(args);
return mapRows(rowMapper, List.of(Map.of( return mapRows(rowMapper, List.of(Map.of(
"ext_party_id", "EMP-PHONE-1", "ext_party_id", "EMP-1",
"name", "稳定员工" "name", "员工"
))); )));
} }
if (sql.contains("UNION ALL") if (sql.contains("UNION ALL")
@@ -1268,7 +1313,8 @@ public class AihrPracticeSeedServiceTest {
"ext_party_id", extPartyId "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; assignmentsSql = sql;
assignmentsArgs = List.of(args); assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN") 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 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 本地验证环境一致性修复:项目 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 线上岗位身份映射修复:生产关闭组织姓名/部门展示时,`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. 审查口径 ## 1. 审查口径
@@ -318,5 +320,9 @@
- 2026-07-14 BRD 生产迁移回归固化:新增 `scripts/tests/aihr-schema-migrations.test.sh`,在临时 MySQL 库验证 SOP 主体/版本字段和组织入职日期迁移面对旧表、缺前置表时可安全执行且重复执行幂等;不写入开发库业务数据。 - 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 生产迁移回归扩展:同一临时 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 当前开发库审查快照:生活顾问启用场景 `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 BRD 严格门禁绕过修复:`AIHR_DEMO_CHECK_LIBRARY_ONLY=true` 仅允许测试加载脚本函数,和 `AIHR_PILOT_STRICT=true` 同时使用时现在直接失败,避免调试开关把正式试点检查静默跳过;新增回归断言,未修改业务数据。
- 2026-07-14 线上浏览器身份复核:使用数据库已有手机号 `13900001111` 和当前 dev 验证码规则登录 `https://peilian.njzhmj.top/h5/` 成功,但随后进入“确认岗位”页并提示“未从组织数据匹配到岗位”,说明线上手机号登录链路可用而正式组织岗位映射仍未闭合;本轮未点击岗位确认,不写入线上岗位数据。 - 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 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java "不等于端到端验收"
contains frontend/src/views/index.vue "正式试点身份待核对" 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 "正式试点身份待核对"
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 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 "核心流程待确认"
contains frontend/src/views/knowledge/sop.vue "训练题已生成" contains frontend/src/views/knowledge/sop.vue "训练题已生成"