From 92e80b2d09f8d80358b42113df5e243861dad525 Mon Sep 17 00:00:00 2001 From: tanglong <842690096@qq.com> Date: Wed, 22 Jul 2026 13:06:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E9=80=81=E5=90=8D=E5=8D=95=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WecomCallbackController.java | 6 ++ .../LowHoursWarningPushIntentHandler.java | 5 +- .../report/LowHoursWarningPushService.java | 37 ++++++-- ...entTeachingAttendanceReportController.java | 7 +- .../report/TeachingAlertPushService.java | 90 ++++++++++++++----- .../report/TeachingWarningPushRequest.java | 11 ++- .../LowHoursWarningPushIntentHandlerTest.java | 38 ++++++++ .../report/TeachingAlertPushServiceTest.java | 28 ++++++ 8 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java b/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java index 0f0b0ec..c625f25 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/controller/WecomCallbackController.java @@ -232,6 +232,12 @@ public class WecomCallbackController { } private String startedMessage(String reportLabel) { + if ("学员运营预警数据".equals(reportLabel)) { + return "### 📤 正在准备推送\n" + + "> **" + reportLabel + "**\n" + + "> 正在获取预警名单并匹配对应馆长,请稍候……\n\n" + + "名单准备完成后将立即发送,请勿重复提交推送指令。"; + } return "### ⏳ 正在查询\n" + "> **" + reportLabel + "**\n" + "> 正在获取最新数据,请稍候……\n\n" diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java index 414ff37..1dbe241 100644 --- a/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java +++ b/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java @@ -32,8 +32,11 @@ public class LowHoursWarningPushIntentHandler implements SemanticIntentHandler { public String handle(SemanticRequestContext context, SemanticRecognition recognition) { try { SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve(context, recognition); + String command = context.userText() == null ? "" : context.userText(); + boolean lowHoursOnly = command.contains("低课时") + && !command.contains("高频缺勤") && !command.contains("预警数据"); LowHoursWarningPushService.PushSummary result = service.push( - context.wecomUserId(), range.startDate(), range.endDate()); + context.wecomUserId(), range.startDate(), range.endDate(), true, !lowHoursOnly); return "### ✅ 学员运营预警推送完成\n" + "> 日期:**" + range.startDate() + (range.startDate().equals(range.endDate()) ? "" : " 至 " + range.endDate()) + "**\n" diff --git a/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java b/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java index 84b3dfc..0ea915e 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java @@ -6,6 +6,8 @@ import com.company.agentfoundation.security.ReportDataScopeService.Scope; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -25,6 +27,7 @@ import java.util.function.Supplier; @Service public class LowHoursWarningPushService { + private static final Logger log = LoggerFactory.getLogger(LowHoursWarningPushService.class); private static final String STUDENT = "/api/student/findStudentsByInstitution"; private static final String CONTRACT = "/api/contract/findByInstitution"; private static final String CONTRACT_PRODUCT = "/api/contractProduct/findByInstitution"; @@ -53,6 +56,12 @@ public class LowHoursWarningPushService { } public PushSummary push(String wecomUserId, LocalDate startDate, LocalDate endDate) { + return push(wecomUserId, startDate, endDate, true, true); + } + + public PushSummary push(String wecomUserId, LocalDate startDate, LocalDate endDate, + boolean includeLowHours, boolean includeFrequentAbsence) { + long startedAt = System.nanoTime(); if (wecomUserId == null || wecomUserId.isBlank()) { throw new IllegalArgumentException("wecomUserId is required"); } @@ -67,15 +76,29 @@ public class LowHoursWarningPushService { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅管理员、区域总、城市总或馆长可以推送学员运营预警"); } - XuebangTeachingReportDataService.Snapshot snapshot = realtimeData.load(effectiveStart, effectiveEnd, scope); - List> lowHoursAlerts = loadLowHoursAlerts(scope); + if (!includeLowHours && !includeFrequentAbsence) { + throw new IllegalArgumentException("at least one warning type is required"); + } + XuebangTeachingReportDataService.Snapshot snapshot = includeFrequentAbsence + ? realtimeData.load(effectiveStart, effectiveEnd, scope) : null; + long attendanceLoadedAt = System.nanoTime(); + List> lowHoursAlerts = includeLowHours ? loadLowHoursAlerts(scope) : List.of(); + long warningsLoadedAt = System.nanoTime(); + List> absenceAlerts = snapshot == null + ? List.of() : snapshot.frequentAbsenceStudents(); List> alerts = new ArrayList<>( - lowHoursAlerts.size() + snapshot.frequentAbsenceStudents().size()); + lowHoursAlerts.size() + absenceAlerts.size()); alerts.addAll(lowHoursAlerts); - alerts.addAll(snapshot.frequentAbsenceStudents()); + alerts.addAll(absenceAlerts); recipientService.enrich(alerts); TeachingAlertPushService.PushResult pushed = pushService.pushToVenueManagers(alerts); - return new PushSummary(scope.label(), lowHoursAlerts.size(), snapshot.frequentAbsenceCount(), + log.info("teaching warning push completed userId={} scope={} lowHours={} frequentAbsence={} " + + "attendanceMs={} lowHoursMs={} totalMs={} messages={}", + wecomUserId, scope.label(), lowHoursAlerts.size(), snapshot == null ? 0 : snapshot.frequentAbsenceCount(), + millis(startedAt, attendanceLoadedAt), millis(attendanceLoadedAt, warningsLoadedAt), + millis(startedAt, System.nanoTime()), pushed.sentMessageCount()); + return new PushSummary(scope.label(), lowHoursAlerts.size(), + snapshot == null ? 0 : snapshot.frequentAbsenceCount(), pushed.recipientCount(), pushed.sentMessageCount(), pushed.unmatchedAlertCount(), pushed.failedRecipientCount()); } @@ -152,6 +175,10 @@ public class LowHoursWarningPushService { } } + private static long millis(long start, long end) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(end - start); + } + private static String text(JsonNode row, String field) { if (row == null) return ""; JsonNode value = row.path(field); diff --git a/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java index cc494f0..1b02856 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/StudentTeachingAttendanceReportController.java @@ -29,6 +29,11 @@ public class StudentTeachingAttendanceReportController { @PostMapping("/warning-push") public LowHoursWarningPushService.PushSummary pushWarnings( @RequestBody TeachingWarningPushRequest request) { - return warningPushService.push(request.wecomUserId(), request.startDate(), request.endDate()); + return pushSelectedWarnings(request); + } + + private LowHoursWarningPushService.PushSummary pushSelectedWarnings(TeachingWarningPushRequest request) { + return warningPushService.push(request.wecomUserId(), request.startDate(), request.endDate(), + request.includes("LOW_HOURS"), request.includes("FREQUENT_ABSENCE")); } } diff --git a/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java b/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java index 0420ea4..e7e5887 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -16,7 +17,7 @@ import java.util.Set; class TeachingAlertPushService { private static final String APP_KEY = "sports-venue"; private static final int MAX_STUDENTS_PER_RECIPIENT = 100; - private static final int STUDENTS_PER_MESSAGE = 25; + private static final int STUDENTS_PER_MESSAGE = 30; private final WecomClient wecom; private final FoundationProperties properties; @@ -71,12 +72,19 @@ class TeachingAlertPushService { int failedRecipients = 0; for (RecipientBatch batch : batches.values()) { int displayed = Math.min(batch.alerts().size(), MAX_STUDENTS_PER_RECIPIENT); + List> ordered = batch.alerts().stream() + .sorted(Comparator.comparing((Map value) -> campusName(value)) + .thenComparing(value -> text(value, "warningType")) + .thenComparing(value -> text(value, "studentName"))) + .limit(displayed) + .toList(); + List>> pages = paginateByCampus(ordered); boolean failed = false; - for (int offset = 0; offset < displayed; offset += STUDENTS_PER_MESSAGE) { - List> page = batch.alerts().subList(offset, - Math.min(offset + STUDENTS_PER_MESSAGE, displayed)); + for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { + List> page = pages.get(pageIndex); try { - wecom.sendMarkdown(APP_KEY, batch.userId(), format(batch, page, offset, displayed)); + wecom.sendMarkdown(APP_KEY, batch.userId(), + format(batch, page, pageIndex + 1, pages.size(), displayed)); sentMessages++; } catch (RuntimeException exception) { failed = true; @@ -94,30 +102,72 @@ class TeachingAlertPushService { return app.getAlertCopyUserIds().stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); } - private String format(RecipientBatch batch, List> alerts, int offset, int displayed) { + private String format(RecipientBatch batch, List> alerts, + int pageNumber, int pageCount, int displayed) { StringBuilder message = new StringBuilder("### ⚠️ 学员运营预警\n") .append("> 接收人:").append(batch.name().isEmpty() ? batch.userId() : batch.name()).append("\n") - .append("> 预警学员:").append(batch.alerts().size()).append("人\n\n"); - for (int index = 0; index < alerts.size(); index++) { - Map alert = alerts.get(index); - message.append(offset + index + 1).append(". **").append(text(alert, "studentName")).append("**") - .append(" ").append(text(alert, "campusName")); - if ("LOW_REMAINING_HOURS".equals(text(alert, "warningType"))) { - message.append(" 剩余").append(text(alert, "remainingHours")) - .append("课时"); - } else { - message.append(" 排课").append(text(alert, "scheduleCount")) - .append("次/缺勤").append(text(alert, "absenceCount")).append("次"); - } - message.append("\n"); + .append("> 预警记录:").append(batch.alerts().size()).append("条"); + if (pageCount > 1) message.append(" | 第").append(pageNumber).append("/").append(pageCount).append("页"); + message.append("\n"); + + Map>> byCampus = new LinkedHashMap<>(); + for (Map alert : alerts) { + byCampus.computeIfAbsent(campusName(alert), ignored -> new ArrayList<>()).add(alert); } - if (offset + alerts.size() == displayed && batch.alerts().size() > displayed) { + for (Map.Entry>> group : byCampus.entrySet()) { + message.append("\n**🏟 ").append(group.getKey()).append("**") + .append(" → ").append(group.getValue().size()).append("条\n"); + for (int index = 0; index < group.getValue().size(); index++) { + Map alert = group.getValue().get(index); + message.append("> ").append(index + 1).append(". **") + .append(text(alert, "studentName")).append("** "); + if ("LOW_REMAINING_HOURS".equals(text(alert, "warningType"))) { + message.append("剩余 ").append(text(alert, "remainingHours")) + .append(" 课时"); + } else { + message.append("排课 ").append(text(alert, "scheduleCount")) + .append(" 次/缺勤 ").append(text(alert, "absenceCount")) + .append(" 次"); + } + message.append("\n"); + } + } + if (pageNumber == pageCount && batch.alerts().size() > displayed) { message.append("\n> 名单较多,仅展示课时最低的").append(displayed).append("人,其余") .append(batch.alerts().size() - displayed).append("人请在学邦系统处理。"); } return message.toString(); } + private List>> paginateByCampus(List> ordered) { + Map>> groups = new LinkedHashMap<>(); + ordered.forEach(alert -> groups.computeIfAbsent(campusName(alert), ignored -> new ArrayList<>()).add(alert)); + List>> pages = new ArrayList<>(); + List> current = new ArrayList<>(); + for (List> group : groups.values()) { + for (int offset = 0; offset < group.size(); offset += STUDENTS_PER_MESSAGE) { + List> part = group.subList(offset, + Math.min(offset + STUDENTS_PER_MESSAGE, group.size())); + if (!current.isEmpty() && current.size() + part.size() > STUDENTS_PER_MESSAGE) { + pages.add(List.copyOf(current)); + current.clear(); + } + current.addAll(part); + if (current.size() == STUDENTS_PER_MESSAGE) { + pages.add(List.copyOf(current)); + current.clear(); + } + } + } + if (!current.isEmpty()) pages.add(List.copyOf(current)); + return pages; + } + + private static String campusName(Map alert) { + String value = text(alert, "campusName"); + return value.isEmpty() ? "未匹配运动馆" : value; + } + private static String text(Map row, String key) { Object value = row.get(key); return value == null ? "" : String.valueOf(value).trim(); } diff --git a/src/main/java/com/company/agentfoundation/teaching/report/TeachingWarningPushRequest.java b/src/main/java/com/company/agentfoundation/teaching/report/TeachingWarningPushRequest.java index eacc1fa..00d6606 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/TeachingWarningPushRequest.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/TeachingWarningPushRequest.java @@ -1,9 +1,16 @@ package com.company.agentfoundation.teaching.report; import java.time.LocalDate; +import java.util.List; public record TeachingWarningPushRequest( LocalDate startDate, LocalDate endDate, - String wecomUserId -) {} + String wecomUserId, + List warningTypes +) { + public boolean includes(String warningType) { + return warningTypes == null || warningTypes.isEmpty() + || warningTypes.stream().anyMatch(warningType::equalsIgnoreCase); + } +} diff --git a/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java b/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java new file mode 100644 index 0000000..572c4d8 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java @@ -0,0 +1,38 @@ +package com.company.agentfoundation.semantic.handler; + +import com.company.agentfoundation.semantic.SemanticDateRangeResolver; +import com.company.agentfoundation.semantic.SemanticRecognition; +import com.company.agentfoundation.semantic.SemanticRequestContext; +import com.company.agentfoundation.teaching.report.LowHoursWarningPushService; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LowHoursWarningPushIntentHandlerTest { + @Test + void lowHoursCommandSkipsFrequentAbsenceQuery() { + LowHoursWarningPushService service = mock(LowHoursWarningPushService.class); + SemanticDateRangeResolver resolver = mock(SemanticDateRangeResolver.class); + LowHoursWarningPushIntentHandler handler = new LowHoursWarningPushIntentHandler(service, resolver); + SemanticRequestContext context = new SemanticRequestContext("YiNianYiQingJing", "推送本月低课时名单", + ZonedDateTime.of(2026, 7, 22, 12, 0, 0, 0, ZoneId.of("Asia/Shanghai"))); + SemanticRecognition recognition = new SemanticRecognition( + "PUSH_TEACHING_WARNING_DATA", 0.99D, Map.of(), "TEST"); + SemanticDateRangeResolver.DateRange range = new SemanticDateRangeResolver.DateRange( + LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 22)); + when(resolver.resolve(context, recognition)).thenReturn(range); + when(service.push("YiNianYiQingJing", range.startDate(), range.endDate(), true, false)) + .thenReturn(new LowHoursWarningPushService.PushSummary("全部组织", 3, 0, 1, 2, 0, 0)); + + handler.handle(context, recognition); + + verify(service).push("YiNianYiQingJing", range.startDate(), range.endDate(), true, false); + } +} diff --git a/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java b/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java index 019678d..bcf0dd9 100644 --- a/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java +++ b/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import org.mockito.ArgumentCaptor; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -54,4 +55,31 @@ class TeachingAlertPushServiceTest { verify(wecom).sendMarkdown(org.mockito.ArgumentMatchers.eq("sports-venue"), org.mockito.ArgumentMatchers.eq("YiNianYiQingJing"), contains("Student A")); } + + @Test + void groupsStudentsByVenueInMessage() { + WecomClient wecom = mock(WecomClient.class); + TeachingAlertPushService service = new TeachingAlertPushService(wecom); + Map first = alert("s1", "学员1", "浦东金桥-跃动运动馆", "3.5"); + Map second = alert("s2", "学员2", "浦东金桥-跃动运动馆", "8"); + Map third = alert("s3", "学员3", "浦西-跃动运动馆", "6"); + + service.pushToVenueManagers(List.of(first, second, third)); + + ArgumentCaptor content = ArgumentCaptor.forClass(String.class); + verify(wecom).sendMarkdown(org.mockito.ArgumentMatchers.eq("sports-venue"), + org.mockito.ArgumentMatchers.eq("manager1"), content.capture()); + assertThat(content.getValue()) + .contains("🏟 浦东金桥-跃动运动馆", "学员1", "学员2", "🏟 浦西-跃动运动馆", "学员3"); + } + + private Map alert(String id, String name, String campus, String hours) { + Map alert = new LinkedHashMap<>(); + alert.put("studentId", id); alert.put("studentName", name); + alert.put("campusName", campus); alert.put("remainingHours", hours); + alert.put("warningType", "LOW_REMAINING_HOURS"); + alert.put("recipients", List.of( + Map.of("role", "VENUE_MANAGER", "name", "Manager", "wecomUserId", "manager1"))); + return alert; + } }