企业微信客户运营日报
This commit is contained in:
parent
5755f278ef
commit
52f46aabdc
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.company.agentfoundation.crm.report;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record WecomCustomerOperationsReportRequest(
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String wecomUserId
|
||||
) {}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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) {}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user