From 52f46aabdc6ed366049bafb2cbf8836990c366f0 Mon Sep 17 00:00:00 2001 From: tanglong <842690096@qq.com> Date: Tue, 21 Jul 2026 15:53:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=BF=90=E8=90=A5=E6=97=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/CustomerDailyReportService.java | 10 ++ ...comCustomerOperationsReportController.java | 23 +++ .../WecomCustomerOperationsReportRequest.java | 9 ++ .../WecomCustomerOperationsReportService.java | 83 +++++++++++ .../integration/wecom/client/WecomClient.java | 66 +++++++++ .../dto/WecomGroupMessageListRequest.java | 13 ++ .../dto/WecomGroupMessageListResponse.java | 23 +++ .../WecomGroupMessageSendResultRequest.java | 6 + .../WecomGroupMessageSendResultResponse.java | 23 +++ .../dto/WecomGroupMessageTaskRequest.java | 6 + .../dto/WecomGroupMessageTaskResponse.java | 18 +++ .../wecom/dto/WecomStudentContactStats.java | 20 ++- .../WecomStudentContactStatsService.java | 135 +++++++++++++++-- .../semantic/SemanticRecognitionService.java | 6 + ...CustomerOperationsReportIntentHandler.java | 136 ++++++++++++++++++ .../WecomStudentContactStatsServiceTest.java | 70 +++++++++ .../SemanticRecognitionServiceTest.java | 10 ++ 17 files changed, 635 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportController.java create mode 100644 src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportRequest.java create mode 100644 src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportService.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListRequest.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListResponse.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultRequest.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultResponse.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskRequest.java create mode 100644 src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskResponse.java create mode 100644 src/main/java/com/company/agentfoundation/semantic/handler/WecomCustomerOperationsReportIntentHandler.java create mode 100644 src/test/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsServiceTest.java diff --git a/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java index 69473a7..ee377a9 100644 --- a/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java +++ b/src/main/java/com/company/agentfoundation/crm/report/CustomerDailyReportService.java @@ -121,6 +121,16 @@ public class CustomerDailyReportService { return result; } + public void assertCanViewAllOrganizations(String wecomUserId) { + if (clean(wecomUserId).isEmpty()) { + throw new IllegalArgumentException("wecomUserId is required"); + } + Caller caller = resolveCaller(wecomUserId); + if (!caller.resolved() || !deriveScope(caller).all()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权限查看:仅集团管理员可查看企业微信客户运营日报"); + } + } + private Caller resolveCaller(String wecomUserId) { String safeUserId = clean(wecomUserId); if (isSportsVenueAdmin(safeUserId)) { diff --git a/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportController.java b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportController.java new file mode 100644 index 0000000..604a827 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportController.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.crm.report; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/wecom/customer-operations") +public class WecomCustomerOperationsReportController { + private final WecomCustomerOperationsReportService reportService; + + public WecomCustomerOperationsReportController(WecomCustomerOperationsReportService reportService) { + this.reportService = reportService; + } + + @PostMapping("/daily-report") + public Map dailyReport(@RequestBody WecomCustomerOperationsReportRequest request) { + return reportService.generate(request); + } +} diff --git a/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportRequest.java b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportRequest.java new file mode 100644 index 0000000..ef35e6d --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportRequest.java @@ -0,0 +1,9 @@ +package com.company.agentfoundation.crm.report; + +import java.time.LocalDate; + +public record WecomCustomerOperationsReportRequest( + LocalDate startDate, + LocalDate endDate, + String wecomUserId +) {} diff --git a/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportService.java b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportService.java new file mode 100644 index 0000000..ff4da0d --- /dev/null +++ b/src/main/java/com/company/agentfoundation/crm/report/WecomCustomerOperationsReportService.java @@ -0,0 +1,83 @@ +package com.company.agentfoundation.crm.report; + +import com.company.agentfoundation.integration.wecom.dto.WecomStudentContactStats; +import com.company.agentfoundation.integration.wecom.service.WecomStudentContactStatsService; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class WecomCustomerOperationsReportService { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + private final WecomStudentContactStatsService statsService; + private final CustomerDailyReportService accessService; + + public WecomCustomerOperationsReportService( + WecomStudentContactStatsService statsService, + CustomerDailyReportService accessService + ) { + this.statsService = statsService; + this.accessService = accessService; + } + + public Map generate(WecomCustomerOperationsReportRequest request) { + if (request == null || request.wecomUserId() == null || request.wecomUserId().isBlank()) { + throw new IllegalArgumentException("wecomUserId is required"); + } + LocalDate today = LocalDate.now(BUSINESS_ZONE); + LocalDate startDate = request.startDate() == null ? today : request.startDate(); + LocalDate endDate = request.endDate() == null ? startDate : request.endDate(); + if (endDate.isBefore(startDate) || startDate.plusDays(30).isBefore(endDate)) { + throw new IllegalArgumentException("invalid report date range; maximum range is 31 days"); + } + + accessService.assertCanViewAllOrganizations(request.wecomUserId()); + WecomStudentContactStats stats = statsService.fetchStats(startDate, endDate); + double averageMessagesPerChat = stats.chatCount() == 0 ? 0D : round((double) stats.messageCount() / stats.chatCount()); + int lossBase = stats.totalStudents() + stats.negativeFeedbackCount(); + double lossRate = lossBase == 0 ? 0D : round((double) stats.negativeFeedbackCount() / lossBase); + + Map result = new LinkedHashMap<>(); + result.put("reportWindow", Map.of("startDate", startDate, "endDate", endDate, "zoneId", BUSINESS_ZONE.getId())); + result.put("scope", Map.of("type", "ALL_WECOM_CUSTOMERS", "label", "全部企业微信客户")); + result.put("customerGrowth", Map.of( + "newCustomerCount", stats.newContactCount(), + "newApplicationCount", stats.newApplyCount(), + "currentCustomerCount", stats.totalStudents(), + "followEmployeeCount", stats.followUserCount())); + result.put("communicationActivity", Map.of( + "chatCustomerCount", stats.chatCount(), + "messageCount", stats.messageCount(), + "averageMessagesPerChat", averageMessagesPerChat)); + + Map serviceQuality = new LinkedHashMap<>(); + serviceQuality.put("replyPercentage", stats.replyPercentage()); + serviceQuality.put("averageReplySeconds", stats.avgReplySeconds()); + result.put("serviceQuality", serviceQuality); + + Map broadcastReach = new LinkedHashMap<>(); + broadcastReach.put("available", stats.groupStatsAvailable()); + broadcastReach.put("groupMessageCount", stats.groupMessageCount()); + broadcastReach.put("targetCustomerCount", stats.groupTargetCount()); + broadcastReach.put("reachedCustomerCount", stats.groupReachedCount()); + broadcastReach.put("reachRate", stats.groupReachRate()); + broadcastReach.put("definition", "已发送客户数 / 全部群发目标客户数"); + result.put("broadcastReach", broadcastReach); + result.put("customerLoss", Map.of( + "negativeFeedbackCount", stats.negativeFeedbackCount(), + "lossRate", lossRate, + "denominator", lossBase, + "definition", "当期负向反馈数 /(当前客户数 + 当期负向反馈数)")); + return result; + } + + private double round(double value) { + return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP).doubleValue(); + } +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/client/WecomClient.java b/src/main/java/com/company/agentfoundation/integration/wecom/client/WecomClient.java index 2f23021..b53812e 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/client/WecomClient.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/client/WecomClient.java @@ -4,6 +4,12 @@ import com.company.agentfoundation.integration.wecom.dto.WecomApiResponse; import com.company.agentfoundation.integration.wecom.dto.WecomExternalContactBatchRequest; import com.company.agentfoundation.integration.wecom.dto.WecomExternalContactBatchResponse; import com.company.agentfoundation.integration.wecom.dto.WecomFollowUserListResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageListRequest; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageListResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageSendResultRequest; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageSendResultResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageTaskRequest; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageTaskResponse; import com.company.agentfoundation.integration.wecom.dto.WecomSendMessageRequest; import com.company.agentfoundation.integration.wecom.dto.WecomTokenResponse; import com.company.agentfoundation.integration.wecom.dto.WecomUserBehaviorDataRequest; @@ -145,6 +151,66 @@ public class WecomClient { return response; } + public WecomGroupMessageListResponse getCustomerGroupMessages( + long startTime, long endTime, String cursor, int limit + ) { + return postStudentContactApi( + "/cgi-bin/externalcontact/get_groupmsg_list_v2", + new WecomGroupMessageListRequest("single", startTime, endTime, limit, cursor), + WecomGroupMessageListResponse.class, + "get customer group messages" + ); + } + + public WecomGroupMessageTaskResponse getCustomerGroupMessageTasks(String msgId, String cursor, int limit) { + return postStudentContactApi( + "/cgi-bin/externalcontact/get_groupmsg_task", + new WecomGroupMessageTaskRequest(msgId, limit, cursor), + WecomGroupMessageTaskResponse.class, + "get customer group message tasks" + ); + } + + public WecomGroupMessageSendResultResponse getCustomerGroupMessageSendResults( + String msgId, String userId, String cursor, int limit + ) { + return postStudentContactApi( + "/cgi-bin/externalcontact/get_groupmsg_send_result", + new WecomGroupMessageSendResultRequest(msgId, userId, limit, cursor), + WecomGroupMessageSendResultResponse.class, + "get customer group message send results" + ); + } + + private T postStudentContactApi(String path, Object request, Class responseType, String operation) { + String token = getAccessToken(studentContactSecret()); + T response = webClient.post() + .uri(uriBuilder -> uriBuilder.path(path).queryParam("access_token", token).build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .bodyToMono(responseType) + .block(); + + boolean success = response != null; + String error = "null"; + if (response instanceof WecomGroupMessageListResponse value) { + success = value.success(); + error = value.errmsg(); + } else if (response instanceof WecomGroupMessageTaskResponse value) { + success = value.success(); + error = value.errmsg(); + } else if (response instanceof WecomGroupMessageSendResultResponse value) { + success = value.success(); + error = value.errmsg(); + } + if (!success) { + log.error("wecom student contact {} failed err={}", operation, error); + throw new IllegalStateException("WeCom " + operation + " failed: " + error); + } + return response; + } + private String getAccessToken(String secret) { String safeSecret = requireText(secret, "WeCom app secret is missing"); TokenCache cache = tokenCaches.computeIfAbsent(safeSecret, ignored -> new TokenCache()); diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListRequest.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListRequest.java new file mode 100644 index 0000000..74901f1 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListRequest.java @@ -0,0 +1,13 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record WecomGroupMessageListRequest( + @JsonProperty("chat_type") String chatType, + @JsonProperty("start_time") long startTime, + @JsonProperty("end_time") long endTime, + int limit, + String cursor +) {} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListResponse.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListResponse.java new file mode 100644 index 0000000..5765b32 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageListResponse.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record WecomGroupMessageListResponse( + Integer errcode, + String errmsg, + @JsonProperty("group_msg_list") List groupMessageList, + @JsonProperty("next_cursor") String nextCursor +) { + public boolean success() { + return errcode == null || errcode == 0; + } + + public record GroupMessage( + String msgid, + String creator, + @JsonProperty("create_time") Long createTime, + @JsonProperty("create_type") Integer createType + ) {} +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultRequest.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultRequest.java new file mode 100644 index 0000000..d78c36f --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultRequest.java @@ -0,0 +1,6 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record WecomGroupMessageSendResultRequest(String msgid, String userid, int limit, String cursor) {} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultResponse.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultResponse.java new file mode 100644 index 0000000..9f857d8 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageSendResultResponse.java @@ -0,0 +1,23 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record WecomGroupMessageSendResultResponse( + Integer errcode, + String errmsg, + @JsonProperty("send_list") List sendList, + @JsonProperty("next_cursor") String nextCursor +) { + public boolean success() { + return errcode == null || errcode == 0; + } + + public record SendResult( + @JsonProperty("external_userid") String externalUserId, + @JsonProperty("chat_id") String chatId, + Integer status, + @JsonProperty("send_time") Long sendTime + ) {} +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskRequest.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskRequest.java new file mode 100644 index 0000000..d66ba2b --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskRequest.java @@ -0,0 +1,6 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record WecomGroupMessageTaskRequest(String msgid, int limit, String cursor) {} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskResponse.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskResponse.java new file mode 100644 index 0000000..35bb593 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomGroupMessageTaskResponse.java @@ -0,0 +1,18 @@ +package com.company.agentfoundation.integration.wecom.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record WecomGroupMessageTaskResponse( + Integer errcode, + String errmsg, + @JsonProperty("task_list") List taskList, + @JsonProperty("next_cursor") String nextCursor +) { + public boolean success() { + return errcode == null || errcode == 0; + } + + public record Task(String userid, Integer status, @JsonProperty("send_time") Long sendTime) {} +} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomStudentContactStats.java b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomStudentContactStats.java index 648fc74..a024d61 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomStudentContactStats.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/dto/WecomStudentContactStats.java @@ -2,12 +2,18 @@ package com.company.agentfoundation.integration.wecom.dto; public record WecomStudentContactStats( int totalStudents, - int todayNewStudents, + int newStudents, int followUserCount, - int todayNewApplyCount, - int todayChatCount, - int todayMessageCount, - int todayNegativeFeedbackCount, - Integer todayReplyPercentage, - Integer todayAvgReplySeconds + int newApplyCount, + int newContactCount, + int chatCount, + int messageCount, + int negativeFeedbackCount, + Integer replyPercentage, + Integer avgReplySeconds, + int groupMessageCount, + int groupTargetCount, + int groupReachedCount, + Double groupReachRate, + boolean groupStatsAvailable ) {} diff --git a/src/main/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsService.java b/src/main/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsService.java index fadf0d0..9095f81 100644 --- a/src/main/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsService.java +++ b/src/main/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsService.java @@ -2,9 +2,14 @@ package com.company.agentfoundation.integration.wecom.service; import com.company.agentfoundation.integration.wecom.client.WecomClient; import com.company.agentfoundation.integration.wecom.dto.WecomExternalContactBatchResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageListResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageSendResultResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageTaskResponse; import com.company.agentfoundation.integration.wecom.dto.WecomStudentContactStats; import com.company.agentfoundation.integration.wecom.dto.WecomUserBehaviorDataResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.time.LocalDate; @@ -17,6 +22,7 @@ import java.util.Set; @Service public class WecomStudentContactStatsService { + private static final Logger log = LoggerFactory.getLogger(WecomStudentContactStatsService.class); private static final ZoneId CHINA_ZONE = ZoneId.of("Asia/Shanghai"); private static final int BATCH_SIZE = 100; @@ -27,13 +33,22 @@ public class WecomStudentContactStatsService { } public WecomStudentContactStats fetchTodayStats() { + LocalDate today = LocalDate.now(CHINA_ZONE); + return fetchStats(today, today); + } + + public WecomStudentContactStats fetchStats(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null || endDate.isBefore(startDate) || startDate.plusDays(30).isBefore(endDate)) { + throw new IllegalArgumentException("invalid WeCom customer operations report date range"); + } List followUsers = wecomClient.getStudentContactFollowUsers().followUser(); if (followUsers == null || followUsers.isEmpty()) { - return new WecomStudentContactStats(0, 0, 0, 0, 0, 0, 0, null, null); + return new WecomStudentContactStats(0, 0, 0, 0, 0, 0, 0, 0, + null, null, 0, 0, 0, null, true); } - long todayStart = LocalDate.now(CHINA_ZONE).atStartOfDay(CHINA_ZONE).toEpochSecond(); - long tomorrowStart = LocalDate.now(CHINA_ZONE).plusDays(1).atStartOfDay(CHINA_ZONE).toEpochSecond(); + long rangeStart = startDate.atStartOfDay(CHINA_ZONE).toEpochSecond(); + long rangeEndExclusive = endDate.plusDays(1).atStartOfDay(CHINA_ZONE).toEpochSecond(); Set allStudents = new HashSet<>(); Map earliestCreateTimeByStudent = new HashMap<>(); for (int start = 0; start < followUsers.size(); start += BATCH_SIZE) { @@ -41,21 +56,28 @@ public class WecomStudentContactStatsService { fetchBatch(batchUsers, allStudents, earliestCreateTimeByStudent); } - int todayNew = (int) earliestCreateTimeByStudent.values().stream() - .filter(createTime -> createTime != null && createTime >= todayStart && createTime < tomorrowStart) + int rangeNew = (int) earliestCreateTimeByStudent.values().stream() + .filter(createTime -> createTime != null && createTime >= rangeStart && createTime < rangeEndExclusive) .count(); - BehaviorSummary behaviorSummary = fetchBehaviorSummary(followUsers, todayStart, tomorrowStart - 1); + BehaviorSummary behaviorSummary = fetchBehaviorSummary(followUsers, rangeStart, rangeEndExclusive - 1); + GroupMessageSummary groupMessageSummary = fetchGroupMessageSummary(rangeStart, rangeEndExclusive - 1); return new WecomStudentContactStats( allStudents.size(), - todayNew, + rangeNew, followUsers.size(), behaviorSummary.newApplyCount(), + behaviorSummary.newContactCount(), behaviorSummary.chatCount(), behaviorSummary.messageCount(), behaviorSummary.negativeFeedbackCount(), behaviorSummary.replyPercentage(), - behaviorSummary.avgReplySeconds() + behaviorSummary.avgReplySeconds(), + groupMessageSummary.messageCount(), + groupMessageSummary.targetCount(), + groupMessageSummary.reachedCount(), + groupMessageSummary.reachRate(), + groupMessageSummary.available() ); } @@ -87,13 +109,14 @@ public class WecomStudentContactStatsService { private BehaviorSummary fetchBehaviorSummary(List followUsers, long startTime, long endTime) { int newApplyCount = 0; + int newContactCount = 0; int chatCount = 0; int messageCount = 0; int negativeFeedbackCount = 0; - int replyPercentageWeightedSum = 0; - int replyPercentageWeight = 0; - int avgReplyTimeWeightedSum = 0; - int avgReplyTimeWeight = 0; + long replyPercentageWeightedSum = 0L; + long replyPercentageWeight = 0L; + long avgReplyTimeWeightedSum = 0L; + long avgReplyTimeWeight = 0L; for (int start = 0; start < followUsers.size(); start += BATCH_SIZE) { List batchUsers = followUsers.subList(start, Math.min(start + BATCH_SIZE, followUsers.size())); @@ -109,6 +132,7 @@ public class WecomStudentContactStatsService { int itemChatCount = value(item.chatCnt()); int itemMessageCount = value(item.messageCnt()); newApplyCount += value(item.newApplyCnt()); + newContactCount += value(item.newContactCnt()); chatCount += itemChatCount; messageCount += itemMessageCount; negativeFeedbackCount += value(item.negativeFeedbackCnt()); @@ -123,9 +147,81 @@ public class WecomStudentContactStatsService { } } - Integer replyPercentage = replyPercentageWeight == 0 ? null : replyPercentageWeightedSum / replyPercentageWeight; - Integer avgReplySeconds = avgReplyTimeWeight == 0 ? null : avgReplyTimeWeightedSum / avgReplyTimeWeight; - return new BehaviorSummary(newApplyCount, chatCount, messageCount, negativeFeedbackCount, replyPercentage, avgReplySeconds); + Integer replyPercentage = replyPercentageWeight == 0 ? null + : Math.toIntExact(replyPercentageWeightedSum / replyPercentageWeight); + Integer avgReplySeconds = avgReplyTimeWeight == 0 ? null + : Math.toIntExact(avgReplyTimeWeightedSum / avgReplyTimeWeight); + return new BehaviorSummary(newApplyCount, newContactCount, chatCount, messageCount, + negativeFeedbackCount, replyPercentage, avgReplySeconds); + } + + private GroupMessageSummary fetchGroupMessageSummary(long startTime, long endTime) { + try { + Set messageIds = new HashSet<>(); + String cursor = ""; + while (true) { + WecomGroupMessageListResponse response = + wecomClient.getCustomerGroupMessages(startTime, endTime, cursor, BATCH_SIZE); + if (response.groupMessageList() != null) { + response.groupMessageList().stream() + .filter(item -> item != null && item.msgid() != null && !item.msgid().isBlank()) + .map(WecomGroupMessageListResponse.GroupMessage::msgid) + .forEach(messageIds::add); + } + cursor = response.nextCursor(); + if (cursor == null || cursor.isBlank()) break; + } + + int targets = 0; + int reached = 0; + for (String messageId : messageIds) { + GroupMessageSummary summary = fetchMessageSendSummary(messageId); + targets += summary.targetCount(); + reached += summary.reachedCount(); + } + Double rate = targets == 0 ? null : round((double) reached / targets); + return new GroupMessageSummary(messageIds.size(), targets, reached, rate, true); + } catch (RuntimeException e) { + log.warn("wecom customer group message statistics unavailable err={}", e.getMessage()); + return new GroupMessageSummary(0, 0, 0, null, false); + } + } + + private GroupMessageSummary fetchMessageSendSummary(String messageId) { + int targets = 0; + int reached = 0; + String taskCursor = ""; + while (true) { + WecomGroupMessageTaskResponse taskResponse = + wecomClient.getCustomerGroupMessageTasks(messageId, taskCursor, BATCH_SIZE); + if (taskResponse.taskList() != null) { + for (WecomGroupMessageTaskResponse.Task task : taskResponse.taskList()) { + if (task == null || task.userid() == null || task.userid().isBlank()) continue; + String resultCursor = ""; + while (true) { + WecomGroupMessageSendResultResponse resultResponse = wecomClient.getCustomerGroupMessageSendResults( + messageId, task.userid(), resultCursor, BATCH_SIZE); + if (resultResponse.sendList() != null) { + for (WecomGroupMessageSendResultResponse.SendResult result : resultResponse.sendList()) { + if (result == null) continue; + targets++; + if (Integer.valueOf(1).equals(result.status())) reached++; + } + } + resultCursor = resultResponse.nextCursor(); + if (resultCursor == null || resultCursor.isBlank()) break; + } + } + } + taskCursor = taskResponse.nextCursor(); + if (taskCursor == null || taskCursor.isBlank()) break; + } + return new GroupMessageSummary(1, targets, reached, + targets == 0 ? null : round((double) reached / targets), true); + } + + private double round(double value) { + return Math.round(value * 10_000D) / 10_000D; } private int value(Integer value) { @@ -134,10 +230,19 @@ public class WecomStudentContactStatsService { private record BehaviorSummary( int newApplyCount, + int newContactCount, int chatCount, int messageCount, int negativeFeedbackCount, Integer replyPercentage, Integer avgReplySeconds ) {} + + private record GroupMessageSummary( + int messageCount, + int targetCount, + int reachedCount, + Double reachRate, + boolean available + ) {} } diff --git a/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java b/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java index 05ba7cb..389cd75 100644 --- a/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java +++ b/src/main/java/com/company/agentfoundation/semantic/SemanticRecognitionService.java @@ -85,6 +85,12 @@ public class SemanticRecognitionService { private SemanticRecognition fallback(String userText) { String text = userText == null ? "" : userText; + boolean operationsReport = text.contains("客户运营") || text.contains("私域") + || (text.contains("企业微信") && (text.contains("客户日报") || text.contains("运营日报"))); + if (operationsReport && (text.contains("日报") || text.contains("报表") || text.contains("运营"))) { + return new SemanticRecognition("WECOM_CUSTOMER_OPERATIONS_DAILY_REPORT", 0.90D, + Map.of(), "KEYWORD_FALLBACK"); + } if (text.contains("客资") && (text.contains("日报") || text.contains("报表") || text.contains("漏斗"))) { return new SemanticRecognition("CUSTOMER_DAILY_REPORT", 0.85D, Map.of(), "KEYWORD_FALLBACK"); } diff --git a/src/main/java/com/company/agentfoundation/semantic/handler/WecomCustomerOperationsReportIntentHandler.java b/src/main/java/com/company/agentfoundation/semantic/handler/WecomCustomerOperationsReportIntentHandler.java new file mode 100644 index 0000000..6d2b42f --- /dev/null +++ b/src/main/java/com/company/agentfoundation/semantic/handler/WecomCustomerOperationsReportIntentHandler.java @@ -0,0 +1,136 @@ +package com.company.agentfoundation.semantic.handler; + +import com.company.agentfoundation.crm.report.WecomCustomerOperationsReportRequest; +import com.company.agentfoundation.crm.report.WecomCustomerOperationsReportService; +import com.company.agentfoundation.semantic.SemanticDateRangeResolver; +import com.company.agentfoundation.semantic.SemanticIntentHandler; +import com.company.agentfoundation.semantic.SemanticRecognition; +import com.company.agentfoundation.semantic.SemanticRequestContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class WecomCustomerOperationsReportIntentHandler implements SemanticIntentHandler { + private static final Logger log = LoggerFactory.getLogger(WecomCustomerOperationsReportIntentHandler.class); + + private final SemanticDateRangeResolver dateRangeResolver; + private final WecomCustomerOperationsReportService reportService; + private final ObjectMapper objectMapper; + + public WecomCustomerOperationsReportIntentHandler( + SemanticDateRangeResolver dateRangeResolver, + WecomCustomerOperationsReportService reportService, + ObjectMapper objectMapper + ) { + this.dateRangeResolver = dateRangeResolver; + this.reportService = reportService; + this.objectMapper = objectMapper; + } + + @Override + public String intent() { + return "WECOM_CUSTOMER_OPERATIONS_DAILY_REPORT"; + } + + @Override + public String description() { + return "查询企业微信客户运营日报、私域运营日报、新增客户、聊天活跃度、回复时长、群发触达率或客户流失率"; + } + + @Override + public String example() { + return "查看今日企业微信客户运营日报"; + } + + @Override + public String handle(SemanticRequestContext context, SemanticRecognition recognition) { + SemanticDateRangeResolver.DateRange range = dateRangeResolver.resolve( + context.userText(), recognition.parameters(), context.requestTime().toLocalDate()); + try { + JsonNode report = objectMapper.valueToTree(reportService.generate( + new WecomCustomerOperationsReportRequest(range.startDate(), range.endDate(), context.wecomUserId()))); + return format(report, range); + } catch (ResponseStatusException e) { + if (e.getStatusCode().value() == 403) { + return "无权限查看:仅集团管理员可查看企业微信客户运营日报。UserID:" + context.wecomUserId(); + } + throw e; + } catch (Exception e) { + log.error("wecom customer operations report failed wecomUserId={} range={}~{} err={}", + context.wecomUserId(), range.startDate(), range.endDate(), e.getMessage(), e); + return "企业微信客户运营日报查询失败,请稍后重试。"; + } + } + + private String format(JsonNode report, SemanticDateRangeResolver.DateRange range) { + StringBuilder reply = new StringBuilder("### 🌱 企业微信客户运营日报\n> 日期:") + .append(range.startDate()); + if (!range.startDate().equals(range.endDate())) { + reply.append(" 至 ").append(range.endDate()); + } + reply.append(" | 范围:**全部企业微信客户**"); + + JsonNode growth = report.path("customerGrowth"); + reply.append("\n\n**客户增长** 新增客户:") + .append(growth.path("newCustomerCount").asInt()).append("") + .append(" | 新增申请:").append(growth.path("newApplicationCount").asInt()) + .append(" | 客户总量:").append(growth.path("currentCustomerCount").asInt()); + + JsonNode activity = report.path("communicationActivity"); + reply.append("\n\n**沟通活跃度** 聊天客户:") + .append(activity.path("chatCustomerCount").asInt()).append("") + .append(" | 消息数:").append(activity.path("messageCount").asInt()) + .append(" | 每次聊天消息:").append(number(activity.path("averageMessagesPerChat").asDouble())); + + JsonNode quality = report.path("serviceQuality"); + reply.append("\n\n**服务质量** 回复率:") + .append(nullablePercent(quality.get("replyPercentage"))) + .append(" | 平均回复时长:").append(duration(quality.get("averageReplySeconds"))); + + JsonNode broadcast = report.path("broadcastReach"); + reply.append("\n\n**群发触达** "); + if (!broadcast.path("available").asBoolean(false)) { + reply.append("统计不可用,请检查客户联系群发统计权限"); + } else if (broadcast.path("targetCustomerCount").asInt() == 0) { + reply.append("本期无群发任务"); + } else { + reply.append("已触达:").append(broadcast.path("reachedCustomerCount").asInt()) + .append("/").append(broadcast.path("targetCustomerCount").asInt()) + .append(" | 触达率:") + .append(ratePercent(broadcast.path("reachRate").asDouble())).append(""); + } + + JsonNode loss = report.path("customerLoss"); + reply.append("\n\n**客户流失** 负向反馈:") + .append(loss.path("negativeFeedbackCount").asInt()) + .append(" | 流失率:").append(ratePercent(loss.path("lossRate").asDouble())).append(""); + return reply.toString(); + } + + private String nullablePercent(JsonNode value) { + return value == null || value.isNull() ? "暂无数据" : value.asInt() + "%"; + } + + private String duration(JsonNode value) { + if (value == null || value.isNull()) return "暂无数据"; + int seconds = value.asInt(); + if (seconds < 60) return seconds + "秒"; + int minutes = seconds / 60; + int remainder = seconds % 60; + return remainder == 0 ? minutes + "分钟" : minutes + "分" + remainder + "秒"; + } + + private String ratePercent(double rate) { + return number(rate * 100D) + "%"; + } + + private String number(double value) { + return value == Math.rint(value) ? "%.0f".formatted(value) : "%.1f".formatted(value); + } +} diff --git a/src/test/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsServiceTest.java b/src/test/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsServiceTest.java new file mode 100644 index 0000000..b318e9c --- /dev/null +++ b/src/test/java/com/company/agentfoundation/integration/wecom/service/WecomStudentContactStatsServiceTest.java @@ -0,0 +1,70 @@ +package com.company.agentfoundation.integration.wecom.service; + +import com.company.agentfoundation.integration.wecom.client.WecomClient; +import com.company.agentfoundation.integration.wecom.dto.WecomExternalContactBatchResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomFollowUserListResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageListResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageSendResultResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomGroupMessageTaskResponse; +import com.company.agentfoundation.integration.wecom.dto.WecomStudentContactStats; +import com.company.agentfoundation.integration.wecom.dto.WecomUserBehaviorDataResponse; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WecomStudentContactStatsServiceTest { + + @Test + void aggregatesCustomerBehaviorAndGroupMessageReach() { + WecomClient client = mock(WecomClient.class); + LocalDate reportDate = LocalDate.of(2026, 7, 21); + long createTime = reportDate.atStartOfDay(ZoneId.of("Asia/Shanghai")).plusHours(8).toEpochSecond(); + + when(client.getStudentContactFollowUsers()) + .thenReturn(new WecomFollowUserListResponse(0, "ok", List.of("employee-1"))); + when(client.batchGetStudentContacts(eq(List.of("employee-1")), anyString(), anyInt())) + .thenReturn(new WecomExternalContactBatchResponse(0, "ok", List.of( + new WecomExternalContactBatchResponse.ExternalContactItem( + new WecomExternalContactBatchResponse.ExternalContact("customer-1", "客户一"), + new WecomExternalContactBatchResponse.FollowInfo("employee-1", createTime))), "")); + when(client.getStudentContactBehaviorData(eq(List.of("employee-1")), anyLong(), anyLong())) + .thenReturn(new WecomUserBehaviorDataResponse(0, "ok", List.of( + new WecomUserBehaviorDataResponse.BehaviorData( + "2026-07-21", 4, 12, 75, 90, 1, 3, 2)))); + when(client.getCustomerGroupMessages(anyLong(), anyLong(), anyString(), anyInt())) + .thenReturn(new WecomGroupMessageListResponse(0, "ok", List.of( + new WecomGroupMessageListResponse.GroupMessage("message-1", "employee-1", createTime, 1)), "")); + when(client.getCustomerGroupMessageTasks(eq("message-1"), anyString(), anyInt())) + .thenReturn(new WecomGroupMessageTaskResponse(0, "ok", List.of( + new WecomGroupMessageTaskResponse.Task("employee-1", 2, createTime)), "")); + when(client.getCustomerGroupMessageSendResults(eq("message-1"), eq("employee-1"), anyString(), anyInt())) + .thenReturn(new WecomGroupMessageSendResultResponse(0, "ok", List.of( + new WecomGroupMessageSendResultResponse.SendResult("customer-1", null, 1, createTime), + new WecomGroupMessageSendResultResponse.SendResult("customer-2", null, 0, null), + new WecomGroupMessageSendResultResponse.SendResult("customer-3", null, 3, null)), "")); + + WecomStudentContactStats stats = new WecomStudentContactStatsService(client).fetchStats(reportDate, reportDate); + + assertEquals(2, stats.newContactCount()); + assertEquals(4, stats.chatCount()); + assertEquals(12, stats.messageCount()); + assertEquals(75, stats.replyPercentage()); + assertEquals(90, stats.avgReplySeconds()); + assertEquals(1, stats.groupMessageCount()); + assertEquals(3, stats.groupTargetCount()); + assertEquals(1, stats.groupReachedCount()); + assertEquals(0.3333D, stats.groupReachRate()); + assertTrue(stats.groupStatsAvailable()); + } +} diff --git a/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java b/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java index 7a9691d..8eaf28c 100644 --- a/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java +++ b/src/test/java/com/company/agentfoundation/semantic/SemanticRecognitionServiceTest.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.List; +import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,4 +33,13 @@ class SemanticRecognitionServiceTest { """); assertEquals("UNKNOWN", recognition.intent()); } + + @Test + void recognizesWecomCustomerOperationsReportWithKeywordFallback() { + SemanticRecognition recognition = service.recognize( + "查看今日企业微信客户运营日报", LocalDate.of(2026, 7, 21)); + + assertEquals("WECOM_CUSTOMER_OPERATIONS_DAILY_REPORT", recognition.intent()); + assertEquals("KEYWORD_FALLBACK", recognition.source()); + } }