diff --git a/docs/semantic-routing.md b/docs/semantic-routing.md index f4a3946..2763fe0 100644 --- a/docs/semantic-routing.md +++ b/docs/semantic-routing.md @@ -40,6 +40,7 @@ - 课时预警和高频缺勤名单超过聊天展示上限时,统一发送 `.xlsx`,不再发送 `.txt`。 - 日期、接收人、预警数量和运动馆数量通过企业微信消息展示。 - Excel 只保留详细名单,按运动馆、学员和预警类型逐行展示剩余课时、排课及缺勤次数。 +- 对发起推送指令的用户,先发送“推送完成”消息,再发送 Excel;其他馆长仍按“预警说明 → Excel”顺序接收。 ## 扩展新意图 diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/FrequentAbsenceWarningPushIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/FrequentAbsenceWarningPushIntentHandler.java index 124f6a7..9dd1083 100644 --- a/src/main/java/com/company/agentfoundation/semantic/handler/FrequentAbsenceWarningPushIntentHandler.java +++ b/src/main/java/com/company/agentfoundation/semantic/handler/FrequentAbsenceWarningPushIntentHandler.java @@ -34,7 +34,7 @@ public class FrequentAbsenceWarningPushIntentHandler implements SemanticIntentHa try { LowHoursWarningPushService.PushSummary result = service.push( context.wecomUserId(), range.startDate(), range.endDate(), false, true); - return "### ✅ 高频缺勤名单推送完成\n" + String reply = "### ✅ 高频缺勤名单推送完成\n" + "> 日期:**" + range.startDate() + (range.startDate().equals(range.endDate()) ? "" : " 至 " + range.endDate()) + "**\n" + "> 范围:**" + result.scopeLabel() + "**\n\n" @@ -43,6 +43,9 @@ public class FrequentAbsenceWarningPushIntentHandler implements SemanticIntentHa + " | 成功消息:" + result.sentMessageCount() + "条" + "\n\n未匹配馆长的学员:" + result.unmatchedStudentCount() + "人" + " | 发送失败馆长:" + result.failedManagerCount() + "人"; + result.deferredAttachments().forEach(attachment -> + context.deferExcel(attachment.fileName(), attachment.content())); + return reply; } catch (ResponseStatusException exception) { if (exception.getStatusCode().value() == 403) { return exception.getReason() == null ? "无权限执行高频缺勤名单推送" : exception.getReason(); 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 4d6a1a5..3ed82d6 100644 --- a/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java +++ b/src/main/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandler.java @@ -34,7 +34,7 @@ public class LowHoursWarningPushIntentHandler implements SemanticIntentHandler { SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve(context, recognition); LowHoursWarningPushService.PushSummary result = service.push( context.wecomUserId(), range.startDate(), range.endDate(), true, false); - return "### ✅ 课时预警推送完成\n" + String reply = "### ✅ 课时预警推送完成\n" + "> 日期:**" + range.startDate() + (range.startDate().equals(range.endDate()) ? "" : " 至 " + range.endDate()) + "**\n" + "> 范围:**" + result.scopeLabel() + "**\n\n" @@ -43,6 +43,9 @@ public class LowHoursWarningPushIntentHandler implements SemanticIntentHandler { + " | 成功消息:" + result.sentMessageCount() + "条" + "\n\n未匹配馆长的学员:" + result.unmatchedStudentCount() + "人" + " | 发送失败馆长:" + result.failedManagerCount() + "人"; + result.deferredAttachments().forEach(attachment -> + context.deferExcel(attachment.fileName(), attachment.content())); + return reply; } catch (ResponseStatusException exception) { if (exception.getStatusCode().value() == 403) { return exception.getReason() == null ? "无权限执行学员预警推送" : exception.getReason(); 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 ec05b38..e0729e5 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushService.java @@ -3,6 +3,7 @@ package com.company.agentfoundation.teaching.report; import com.company.agentfoundation.integration.xuebang.XuebangApiClient; import com.company.agentfoundation.security.ReportDataScopeService; import com.company.agentfoundation.security.ReportDataScopeService.Scope; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; @@ -96,7 +97,7 @@ public class LowHoursWarningPushService { alert.put("warningEndDate", effectiveEnd.toString()); }); recipientService.enrich(alerts); - TeachingAlertPushService.PushResult pushed = pushService.pushToVenueManagers(alerts); + TeachingAlertPushService.PushResult pushed = pushService.pushToVenueManagers(alerts, wecomUserId); log.info("teaching warning push completed userId={} scope={} lowHours={} frequentAbsence={} " + "attendanceMs={} lowHoursMs={} totalMs={} messages={}", wecomUserId, scope.label(), lowHoursAlerts.size(), snapshot == null ? 0 : snapshot.frequentAbsenceCount(), @@ -105,7 +106,7 @@ public class LowHoursWarningPushService { return new PushSummary(scope.label(), lowHoursAlerts.size(), snapshot == null ? 0 : snapshot.frequentAbsenceCount(), pushed.recipientCount(), pushed.sentMessageCount(), pushed.unmatchedAlertCount(), - pushed.failedRecipientCount()); + pushed.failedRecipientCount(), pushed.deferredAttachments()); } private List> loadLowHoursAlerts( @@ -238,7 +239,19 @@ public class LowHoursWarningPushService { public record PushSummary(String scopeLabel, long lowHoursStudentCount, long frequentAbsenceStudentCount, int managerCount, - int sentMessageCount, long unmatchedStudentCount, int failedManagerCount) {} + int sentMessageCount, long unmatchedStudentCount, int failedManagerCount, + List deferredAttachments) { + public PushSummary(String scopeLabel, long lowHoursStudentCount, + long frequentAbsenceStudentCount, int managerCount, + int sentMessageCount, long unmatchedStudentCount, int failedManagerCount) { + this(scopeLabel, lowHoursStudentCount, frequentAbsenceStudentCount, managerCount, + sentMessageCount, unmatchedStudentCount, failedManagerCount, List.of()); + } + + public PushSummary { + deferredAttachments = deferredAttachments == null ? List.of() : List.copyOf(deferredAttachments); + } + } private static final class Balance { private final String studentId; 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 47fe101..f65ab4f 100644 --- a/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java +++ b/src/main/java/com/company/agentfoundation/teaching/report/TeachingAlertPushService.java @@ -3,6 +3,7 @@ package com.company.agentfoundation.teaching.report; import com.company.agentfoundation.config.FoundationProperties; import com.company.agentfoundation.integration.wecom.client.WecomClient; import com.company.agentfoundation.report.ExcelReportService; +import com.company.agentfoundation.semantic.SemanticReplyAttachment; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -44,11 +45,24 @@ class TeachingAlertPushService { } PushResult pushToVenueManagers(List> alerts) { - return push(alerts, Set.of("VENUE_MANAGER")); + return pushToVenueManagers(alerts, ""); } - private PushResult push(List> alerts, Set targetRoles) { - if (alerts == null || alerts.isEmpty()) return new PushResult(0, 0, 0, 0, 0); + PushResult pushToVenueManagers( + List> alerts, + String deferAttachmentUserId + ) { + return push(alerts, Set.of("VENUE_MANAGER"), deferAttachmentUserId); + } + + private PushResult push( + List> alerts, + Set targetRoles, + String deferAttachmentUserId + ) { + if (alerts == null || alerts.isEmpty()) { + return new PushResult(0, 0, 0, 0, 0, List.of()); + } Map batches = new LinkedHashMap<>(); long unmatched = 0; for (Map alert : alerts) { @@ -81,6 +95,7 @@ class TeachingAlertPushService { int sentMessages = 0; int failedRecipients = 0; + List deferredAttachments = new ArrayList<>(); for (RecipientBatch batch : batches.values()) { List> ordered = batch.alerts().stream() .sorted(Comparator.comparing((Map value) -> campusName(value)) @@ -92,7 +107,12 @@ class TeachingAlertPushService { wecom.sendMarkdown(APP_KEY, batch.userId(), formatFileSummary(batch, ordered)); sentMessages++; byte[] workbook = excelReportService.teachingWarnings(ordered); - wecom.sendExcelFile(APP_KEY, batch.userId(), fileName(ordered), workbook); + String attachmentName = fileName(ordered); + if (sameUser(batch.userId(), deferAttachmentUserId)) { + deferredAttachments.add(SemanticReplyAttachment.excel(attachmentName, workbook)); + } else { + wecom.sendExcelFile(APP_KEY, batch.userId(), attachmentName, workbook); + } sentMessages++; } catch (RuntimeException exception) { failedRecipients++; @@ -115,7 +135,12 @@ class TeachingAlertPushService { } if (failed) failedRecipients++; } - return new PushResult(alerts.size(), targetRecipientCount, sentMessages, unmatched, failedRecipients); + return new PushResult(alerts.size(), targetRecipientCount, sentMessages, unmatched, failedRecipients, + deferredAttachments); + } + + private boolean sameUser(String first, String second) { + return first != null && second != null && !second.isBlank() && first.equalsIgnoreCase(second); } private String formatFileSummary(RecipientBatch batch, List> alerts) { @@ -221,8 +246,24 @@ class TeachingAlertPushService { Object value = row.get(key); return value == null ? "" : String.valueOf(value).trim(); } - record PushResult(long alertCount, int recipientCount, int sentMessageCount, - long unmatchedAlertCount, int failedRecipientCount) {} + record PushResult( + long alertCount, + int recipientCount, + int sentMessageCount, + long unmatchedAlertCount, + int failedRecipientCount, + List deferredAttachments + ) { + PushResult(long alertCount, int recipientCount, int sentMessageCount, + long unmatchedAlertCount, int failedRecipientCount) { + this(alertCount, recipientCount, sentMessageCount, unmatchedAlertCount, + failedRecipientCount, List.of()); + } + + PushResult { + deferredAttachments = deferredAttachments == null ? List.of() : List.copyOf(deferredAttachments); + } + } private record RecipientBatch(String userId, String name, List> alerts) { private RecipientBatch(String userId, String name) { this(userId, name, new ArrayList<>()); } diff --git a/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java b/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java index 44fa9c2..01a0476 100644 --- a/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java +++ b/src/test/java/com/company/agentfoundation/semantic/handler/LowHoursWarningPushIntentHandlerTest.java @@ -9,8 +9,11 @@ import org.junit.jupiter.api.Test; import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -21,18 +24,27 @@ class LowHoursWarningPushIntentHandlerTest { 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"))); + List attachments = new ArrayList<>(); + SemanticRequestContext context = new SemanticRequestContext( + "YiNianYiQingJing", + "推送本月低课时名单", + ZonedDateTime.of(2026, 7, 22, 12, 0, 0, 0, ZoneId.of("Asia/Shanghai")), + attachments::add); SemanticRecognition recognition = new SemanticRecognition( "PUSH_LOW_HOURS_WARNING", 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)); + .thenReturn(new LowHoursWarningPushService.PushSummary( + "全部组织", 3, 0, 1, 2, 0, 0, + List.of(com.company.agentfoundation.semantic.SemanticReplyAttachment.excel( + "课时预警.xlsx", new byte[]{1})))); handler.handle(context, recognition); verify(service).push("YiNianYiQingJing", range.startDate(), range.endDate(), true, false); + assertThat(attachments).hasSize(1); + assertThat(attachments.get(0).fileName()).isEqualTo("课时预警.xlsx"); } } diff --git a/src/test/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushServiceTest.java b/src/test/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushServiceTest.java index 68c3c27..0df5cd1 100644 --- a/src/test/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushServiceTest.java +++ b/src/test/java/com/company/agentfoundation/teaching/report/LowHoursWarningPushServiceTest.java @@ -58,7 +58,9 @@ class LowHoursWarningPushServiceTest { when(api.getAllPages(eq("/api/contract/findByInstitution"), org.mockito.ArgumentMatchers.anyMap())) .thenReturn(List.of(json("{\"id\":\"c1\",\"studentId\":\"s1\",\"studentName\":\"学员1\"," + "\"contractStatus\":\"NORMAL\",\"contractRefundStatus\":\"NORMAL\"}"))); - when(pushes.pushToVenueManagers(org.mockito.ArgumentMatchers.anyList())) + when(pushes.pushToVenueManagers( + org.mockito.ArgumentMatchers.anyList(), + eq("admin"))) .thenReturn(new TeachingAlertPushService.PushResult(1, 1, 1, 0, 0)); LowHoursWarningPushService service = new LowHoursWarningPushService( api, scopes, realtime, recipients, pushes, Runnable::run); 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 a509b2b..8238b88 100644 --- a/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java +++ b/src/test/java/com/company/agentfoundation/teaching/report/TeachingAlertPushServiceTest.java @@ -13,7 +13,9 @@ import static org.assertj.core.api.Assertions.assertThat; import org.mockito.ArgumentCaptor; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; class TeachingAlertPushServiceTest { @@ -96,6 +98,32 @@ class TeachingAlertPushServiceTest { org.mockito.ArgumentMatchers.eq("manager1"), contains("详细名单")); } + @Test + void defersRequesterExcelUntilCallbackSendsCompletionReply() { + WecomClient wecom = mock(WecomClient.class); + TeachingAlertPushService service = new TeachingAlertPushService(wecom); + List> alerts = new ArrayList<>(); + for (int index = 1; index <= 31; index++) { + Map alert = alert("s" + index, "学员" + index, + "浦东金桥-跃动运动馆", String.valueOf(index % 10)); + alert.put("warningStartDate", "2026-07-01"); + alert.put("warningEndDate", "2026-07-28"); + alerts.add(alert); + } + + TeachingAlertPushService.PushResult result = + service.pushToVenueManagers(alerts, "manager1"); + + verify(wecom).sendMarkdown( + org.mockito.ArgumentMatchers.eq("sports-venue"), + org.mockito.ArgumentMatchers.eq("manager1"), + contains("详细名单")); + verify(wecom, never()).sendExcelFile( + anyString(), anyString(), anyString(), any(byte[].class)); + assertThat(result.deferredAttachments()).hasSize(1); + assertThat(result.deferredAttachments().get(0).fileName()).contains("课时预警名单.xlsx"); + } + private Map alert(String id, String name, String campus, String hours) { Map alert = new LinkedHashMap<>(); alert.put("studentId", id); alert.put("studentName", name);