企业微信客户运营日报

This commit is contained in:
tanglong 2026-07-21 15:53:39 +08:00
parent 5755f278ef
commit 52f46aabdc
17 changed files with 635 additions and 22 deletions

View File

@ -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)) {

View File

@ -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<String, Object> dailyReport(@RequestBody WecomCustomerOperationsReportRequest request) {
return reportService.generate(request);
}
}

View File

@ -0,0 +1,9 @@
package com.company.agentfoundation.crm.report;
import java.time.LocalDate;
public record WecomCustomerOperationsReportRequest(
LocalDate startDate,
LocalDate endDate,
String wecomUserId
) {}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> serviceQuality = new LinkedHashMap<>();
serviceQuality.put("replyPercentage", stats.replyPercentage());
serviceQuality.put("averageReplySeconds", stats.avgReplySeconds());
result.put("serviceQuality", serviceQuality);
Map<String, Object> 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();
}
}

View File

@ -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> T postStudentContactApi(String path, Object request, Class<T> 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());

View File

@ -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
) {}

View File

@ -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<GroupMessage> 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
) {}
}

View File

@ -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) {}

View File

@ -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<SendResult> 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
) {}
}

View File

@ -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) {}

View File

@ -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<Task> 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) {}
}

View File

@ -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
) {}

View File

@ -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<String> 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<String> allStudents = new HashSet<>();
Map<String, Long> 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<String> 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<String> 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<String> 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
) {}
}

View File

@ -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");
}

View File

@ -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**客户增长** 新增客户:<font color=\"info\">")
.append(growth.path("newCustomerCount").asInt()).append("</font>")
.append(" | 新增申请:").append(growth.path("newApplicationCount").asInt())
.append(" | 客户总量:").append(growth.path("currentCustomerCount").asInt());
JsonNode activity = report.path("communicationActivity");
reply.append("\n\n**沟通活跃度** 聊天客户:<font color=\"info\">")
.append(activity.path("chatCustomerCount").asInt()).append("</font>")
.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("<font color=\"warning\">统计不可用,请检查客户联系群发统计权限</font>");
} else if (broadcast.path("targetCustomerCount").asInt() == 0) {
reply.append("本期无群发任务");
} else {
reply.append("已触达:").append(broadcast.path("reachedCustomerCount").asInt())
.append("/").append(broadcast.path("targetCustomerCount").asInt())
.append(" | 触达率:<font color=\"info\">")
.append(ratePercent(broadcast.path("reachRate").asDouble())).append("</font>");
}
JsonNode loss = report.path("customerLoss");
reply.append("\n\n**客户流失** 负向反馈:")
.append(loss.path("negativeFeedbackCount").asInt())
.append(" | 流失率:<font color=\"")
.append(loss.path("negativeFeedbackCount").asInt() == 0 ? "info" : "warning")
.append("\">").append(ratePercent(loss.path("lossRate").asDouble())).append("</font>");
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);
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}