diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java index ec14af6e..a6908648 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrDashboardService.java @@ -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(); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 6413d96d..e689d0b9 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -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 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 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 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 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 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 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 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 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 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 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 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); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrDashboardServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrDashboardServiceTest.java new file mode 100644 index 00000000..bf8d6a15 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrDashboardServiceTest.java @@ -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")); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 4cd23d06..1fe6dca3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -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") diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 4b783cc1..bdcba389 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -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 组织同步缺口保持未完成。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index d9893877..75d4f694 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -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 "训练题已生成"