fix(aihr): harden broadcast and mobile assistant flows

This commit is contained in:
2026-07-23 03:35:36 +08:00
parent 62c8b41672
commit 4b31848e3d
30 changed files with 1127 additions and 208 deletions
@@ -187,8 +187,8 @@ public class AihrMobileController {
}
@GetMapping("/practice/team")
public R<PracticeTeamResponse> practiceTeam() {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId()));
public R<PracticeTeamResponse> practiceTeam(@RequestParam(defaultValue = "false") boolean includeProfiles) {
return R.ok(mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles));
}
@GetMapping("/practice/reviews/{id}")
@@ -424,11 +424,28 @@ public final class AihrPracticeDto {
public record TeamMemberResponse(String extPartyId, String name) {
}
/**
* Lightweight, full-team activity aggregate. Unlike the recent-detail lists,
* this is not capped globally so risk labels remain correct for every member.
*/
public record PracticeTeamProfileResponse(
String extPartyId,
Integer latestScore,
String latestScene,
String lastPracticeAt,
Boolean completedToday,
Integer pendingReview,
Integer pendingLowScore,
Integer pendingAssignment
) {
}
public record PracticeTeamResponse(
List<TeamMemberResponse> members,
List<RecordResponse> records,
List<PracticeAssignmentResponse> assignments,
List<MistakeAggregateResponse> mistakes
List<MistakeAggregateResponse> mistakes,
List<PracticeTeamProfileResponse> profiles
) {
}
@@ -103,7 +103,11 @@ public class AihrMobileSeedService {
}
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId) {
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200);
return practiceTeam(supervisorExtPartyId, false);
}
public PracticeTeamResponse practiceTeam(String supervisorExtPartyId, boolean includeProfiles) {
return practiceSeedService.practiceTeamSnapshot(supervisorExtPartyId, 200, includeProfiles);
}
public String requireSupervisorIdentity(String authenticatedIdentity) {
@@ -26,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
@@ -988,19 +989,25 @@ public class AihrPracticeSeedService {
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit) {
return practiceTeamSnapshot(supervisorExtPartyId, limit, false);
}
public PracticeTeamResponse practiceTeamSnapshot(String supervisorExtPartyId, int limit, boolean includeProfiles) {
if (!orgSnapshotTableExists() && !isDemoProfile()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of());
}
TeamScope scope = teamScope(supervisorExtPartyId);
int safeLimit = normalizeTeamLimit(limit);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of());
return new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of());
}
List<TeamMemberResponse> members = practiceTeamMembers(scope);
return new PracticeTeamResponse(
practiceTeamMembers(scope),
members,
practiceTeamRecords(scope, safeLimit),
practiceTeamAssignments(scope, safeLimit),
practiceTeamMistakes(scope, safeLimit)
practiceTeamMistakes(scope, safeLimit),
includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of()
);
}
@@ -1146,6 +1153,162 @@ public class AihrPracticeSeedService {
return jdbcTemplate.query(sql, this::mapAssignment, args.toArray());
}
private List<PracticeTeamProfileResponse> practiceTeamProfileResponses(TeamScope scope,
List<TeamMemberResponse> members) {
return buildTeamProfileResponses(
members,
practiceTeamProfileRecordSummaries(scope),
practiceTeamProfileAssignmentSummaries(scope)
);
}
/**
* The team list cannot derive risk from the recent-detail payload because that
* payload is intentionally capped. Aggregate all activity server-side, then
* return only one compact fact row for each active member.
*/
static List<PracticeTeamProfileResponse> buildTeamProfileResponses(
List<TeamMemberResponse> members,
List<TeamProfileRecordSummary> records,
List<TeamProfileAssignmentSummary> assignments
) {
Map<String, TeamProfileAggregate> aggregates = new LinkedHashMap<>();
for (TeamMemberResponse member : members) {
String extPartyId = firstNonBlankStatic(member == null ? null : member.extPartyId(), "");
if (!extPartyId.isEmpty()) {
aggregates.putIfAbsent(extPartyId, new TeamProfileAggregate(extPartyId));
}
}
for (TeamProfileRecordSummary record : records) {
TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(record == null ? null : record.extPartyId(), ""));
if (aggregate == null || record == null) {
continue;
}
aggregate.latestScore = record.latestScore();
aggregate.latestScene = firstNonBlankStatic(record.latestScene(), "");
aggregate.latestPracticeAt = record.latestPracticeAt();
aggregate.completedToday = record.completedToday();
aggregate.pendingReview = record.pendingReview();
aggregate.pendingLowScore = record.pendingLowScore();
}
for (TeamProfileAssignmentSummary assignment : assignments) {
TeamProfileAggregate aggregate = aggregates.get(firstNonBlankStatic(assignment == null ? null : assignment.extPartyId(), ""));
if (aggregate == null || assignment == null) {
continue;
}
aggregate.pendingAssignment = assignment.pendingAssignment();
aggregate.latestAssignmentScene = firstNonBlankStatic(assignment.latestScene(), "");
}
List<PracticeTeamProfileResponse> result = new ArrayList<>();
for (TeamProfileAggregate aggregate : aggregates.values()) {
result.add(new PracticeTeamProfileResponse(
aggregate.extPartyId,
aggregate.latestScore,
firstNonBlankStatic(aggregate.latestScene, firstNonBlankStatic(aggregate.latestAssignmentScene, "")),
formatTimestamp(aggregate.latestPracticeAt),
aggregate.completedToday,
aggregate.pendingReview,
aggregate.pendingLowScore,
aggregate.pendingAssignment
));
}
return result;
}
private List<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries(TeamScope scope) {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE s.tenant_id = ? AND s.mode = 'mobile'");
if (scope.scoped()) {
where.append(" AND s.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("s", hasPhone)
: "s.ext_party_id";
LocalDate today = LocalDate.now(BUSINESS_ZONE);
args.add(Timestamp.valueOf(today.atStartOfDay()));
args.add(Timestamp.valueOf(today.plusDays(1).atStartOfDay()));
String sql = """
WITH scoped_records AS (
SELECT %s AS ext_party_id, s.scenario_name, s.total_score, s.status, s.finished_time, s.id
FROM aihr_practice_session s
%s
), ranked_records AS (
SELECT scoped_records.*,
ROW_NUMBER() OVER (
PARTITION BY ext_party_id
ORDER BY finished_time DESC, id DESC
) AS latest_rank
FROM scoped_records
)
SELECT ext_party_id,
MAX(CASE WHEN latest_rank = 1 THEN total_score END) AS latest_score,
MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene,
MAX(CASE WHEN latest_rank = 1 THEN finished_time END) AS latest_practice_at,
MAX(CASE WHEN finished_time >= ? AND finished_time < ? THEN 1 ELSE 0 END) AS completed_today,
SUM(CASE WHEN status = '待复盘' THEN 1 ELSE 0 END) AS pending_review,
MIN(CASE WHEN status = '待复盘' AND total_score < 80 THEN total_score END) AS pending_low_score
FROM ranked_records
GROUP BY ext_party_id
""".formatted(identityExpression, where);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileRecordSummary(
rs.getString("ext_party_id"),
rs.getObject("latest_score") == null ? null : rs.getInt("latest_score"),
rs.getString("latest_scene"),
rs.getTimestamp("latest_practice_at"),
rs.getInt("completed_today") > 0,
rs.getInt("pending_review"),
rs.getObject("pending_low_score") == null ? null : rs.getInt("pending_low_score")
), args.toArray());
}
private List<TeamProfileAssignmentSummary> practiceTeamProfileAssignmentSummaries(TeamScope scope) {
ensureAssignmentTable();
List<Object> args = new ArrayList<>();
args.add(tenantId());
StringBuilder where = new StringBuilder("WHERE a.tenant_id = ? AND a.source <> 'daily'");
if (scope.scoped()) {
where.append(" AND a.ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds());
}
boolean hasOrgSnapshot = orgSnapshotTableExists();
boolean hasPhone = hasOrgSnapshot && orgSnapshotColumnExists("person_phone");
String identityExpression = hasOrgSnapshot
? teamCanonicalIdentityExpression("a", hasPhone)
: "a.ext_party_id";
String sql = """
WITH scoped_assignments AS (
SELECT %s AS ext_party_id, a.scenario_name, a.status, a.create_time, a.id
FROM aihr_practice_assignment a
%s
), ranked_assignments AS (
SELECT scoped_assignments.*,
ROW_NUMBER() OVER (
PARTITION BY ext_party_id
ORDER BY create_time DESC, id DESC
) AS latest_rank
FROM scoped_assignments
)
SELECT ext_party_id,
MAX(CASE WHEN latest_rank = 1 THEN scenario_name END) AS latest_scene,
SUM(CASE WHEN status IS NULL OR status <> '已完成' THEN 1 ELSE 0 END) AS pending_assignment
FROM ranked_assignments
GROUP BY ext_party_id
""".formatted(identityExpression, where);
return jdbcTemplate.query(sql, (rs, rowNum) -> new TeamProfileAssignmentSummary(
rs.getString("ext_party_id"),
rs.getString("latest_scene"),
rs.getInt("pending_assignment")
), args.toArray());
}
private List<MistakeAggregateResponse> practiceTeamMistakes(TeamScope scope, int limit) {
ensurePracticeTable();
List<Object> args = new ArrayList<>();
@@ -4597,7 +4760,7 @@ public class AihrPracticeSeedService {
return Math.min(limit, 200);
}
private String formatTimestamp(Timestamp value) {
private static String formatTimestamp(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMATTER);
}
@@ -5341,6 +5504,30 @@ public class AihrPracticeSeedService {
Set<String> ambiguousExtPartyIds) {
}
static record TeamProfileRecordSummary(String extPartyId, Integer latestScore, String latestScene,
Timestamp latestPracticeAt, boolean completedToday, int pendingReview,
Integer pendingLowScore) {
}
static record TeamProfileAssignmentSummary(String extPartyId, String latestScene, int pendingAssignment) {
}
private static final class TeamProfileAggregate {
private final String extPartyId;
private Integer latestScore;
private String latestScene = "";
private Timestamp latestPracticeAt;
private boolean completedToday;
private Integer pendingLowScore;
private int pendingReview;
private int pendingAssignment;
private String latestAssignmentScene = "";
private TeamProfileAggregate(String extPartyId) {
this.extPartyId = extPartyId;
}
}
private record TeamScope(boolean scoped, List<String> extPartyIds) {
static TeamScope unscoped() {
return new TeamScope(false, List.of());
@@ -47,7 +47,7 @@ class AihrKnowledgeDataToolServiceTest {
AihrMobileSeedService mobile = mock(AihrMobileSeedService.class);
when(mobile.requireSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor");
when(mobile.practiceTeam("canonical-supervisor"))
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of()));
.thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of()));
var service = new AihrKnowledgeDataToolService(mobile);
var principal = new AihrKnowledgePrincipal("000000", 2L, "app_user", "supervisor-1",
Set.of("employee", "supervisor"), Set.of("P1"), "app");
@@ -8,9 +8,11 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentBatchRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
@@ -235,6 +237,50 @@ public class AihrPracticeSeedServiceTest {
assertTrue(code.contains("if (!orgSnapshotTableExists() && !isDemoProfile())"));
}
@Test
public void teamProfilesAggregateEveryMemberBeforeThePageWindow() throws Exception {
List<TeamMemberResponse> members = new ArrayList<>();
for (int index = 0; index < 201; index++) {
members.add(new TeamMemberResponse("EMP-" + index, "员工" + index));
}
List<PracticeTeamProfileResponse> profiles = AihrPracticeSeedService.buildTeamProfileResponses(
members,
List.of(new AihrPracticeSeedService.TeamProfileRecordSummary(
"EMP-200", 62, "投诉接待", Timestamp.valueOf("2026-07-23 10:00:00"), true, 1, 62
)),
List.of(new AihrPracticeSeedService.TeamProfileAssignmentSummary(
"EMP-200", "投诉接待", 1
))
);
PracticeTeamProfileResponse lastMember = profiles.stream()
.filter(item -> "EMP-200".equals(item.extPartyId()))
.findFirst()
.orElseThrow();
assertEquals(62, lastMember.pendingLowScore());
assertEquals(1, lastMember.pendingReview());
assertEquals(1, lastMember.pendingAssignment());
assertTrue(lastMember.completedToday());
String source = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
int methodStart = source.indexOf("private List<TeamProfileRecordSummary> practiceTeamProfileRecordSummaries");
int methodEnd = source.indexOf("private List<TeamProfileAssignmentSummary> practiceTeamProfileAssignmentSummaries", methodStart);
assertTrue(methodStart >= 0 && methodEnd > methodStart);
String recordSummarySource = source.substring(methodStart, methodEnd);
assertFalse(recordSummarySource.contains("LIMIT ?"));
assertTrue(recordSummarySource.contains("ROW_NUMBER() OVER"));
assertTrue(recordSummarySource.contains("GROUP BY ext_party_id"));
assertTrue(recordSummarySource.contains("AS completed_today"));
int assignmentMethodEnd = source.indexOf("private List<MistakeAggregateResponse> practiceTeamMistakes", methodEnd);
assertTrue(assignmentMethodEnd > methodEnd);
String assignmentSummarySource = source.substring(methodEnd, assignmentMethodEnd);
assertFalse(assignmentSummarySource.contains("LIMIT ?"));
assertTrue(assignmentSummarySource.contains("ROW_NUMBER() OVER"));
assertTrue(assignmentSummarySource.contains("GROUP BY ext_party_id"));
assertTrue(source.contains("includeProfiles ? practiceTeamProfileResponses(scope, members) : List.of()"));
}
@Test
public void calibrationRequiresExplicitCorrectedScore() {
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), null, null, null);
@@ -832,7 +878,8 @@ public class AihrPracticeSeedServiceTest {
String controllerSource = Files.readString(source);
assertTrue(controllerSource.contains("mobileSeedService.practiceReviews(supervisorScopeExtPartyId(),"));
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("@RequestParam(defaultValue = \"false\") boolean includeProfiles"));
assertTrue(controllerSource.contains("mobileSeedService.practiceTeam(supervisorScopeExtPartyId(), includeProfiles)"));
assertTrue(controllerSource.contains("mobileSeedService.practiceReview(id, supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.markReviewed(id, bindReviewOperator(request), supervisorScopeExtPartyId())"));
assertTrue(controllerSource.contains("mobileSeedService.practiceAlerts(supervisorScopeExtPartyId())"));
@@ -1691,8 +1738,7 @@ public class AihrPracticeSeedServiceTest {
"ext_party_id", extPartyId
)));
}
if (sql.contains("FROM aihr_practice_assignment")
&& (sql.contains("SELECT id, ext_party_id") || sql.contains("AS ext_party_id"))) {
if (sql.contains("FROM aihr_practice_assignment") && sql.contains("SELECT id,")) {
assignmentsSql = sql;
assignmentsArgs = List.of(args);
String extPartyId = sql.contains("ext_party_id IN")