fix(aihr): tighten org privacy and pilot evidence boundary
This commit is contained in:
+9
-3
@@ -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();
|
||||
}
|
||||
|
||||
+162
-59
@@ -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);
|
||||
}
|
||||
|
||||
+26
@@ -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"));
|
||||
}
|
||||
}
|
||||
+61
-15
@@ -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")
|
||||
|
||||
@@ -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 组织同步缺口保持未完成。
|
||||
|
||||
@@ -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 "训练题已生成"
|
||||
|
||||
Reference in New Issue
Block a user