客资管理

This commit is contained in:
tanglong 2026-06-12 10:40:13 +08:00
parent 866d14461b
commit d8be530787
9 changed files with 259 additions and 6 deletions

View File

@ -1,17 +1,52 @@
package com.company.agentfoundation.agent.capability;
import com.company.agentfoundation.agent.AgentCapabilityResponse;
import com.company.agentfoundation.agent.AgentIntent;
import com.company.agentfoundation.agent.AgentRequestContext;
import com.company.agentfoundation.integration.wecom.WecomStudentContactStats;
import com.company.agentfoundation.integration.wecom.WecomStudentContactStatsService;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Component
public class CustomerDashboardDailyCapabilityHandler extends AbstractPlaceholderCapabilityHandler {
public CustomerDashboardDailyCapabilityHandler() {
super(
AgentIntent.CUSTOMER_DASHBOARD_DAILY,
"\u5ba2\u8d44\u5927\u76d8\u6570\u636e\u6b63\u5728\u5b8c\u5584\u4e2d\uff0c\u540e\u7eed\u5c06\u8986\u76d6\u5ba2\u8d44\u65b0\u589e\u3001\u5206\u914d\u3001\u8ddf\u8fdb\u3001\u8f6c\u5316\u3001\u6d41\u5931\u4e0e\u5f02\u5e38\u98ce\u9669\u9884\u8b66\u3002",
List.of("\u5b66\u90a6\u6559\u52a1\u7cfb\u7edf", "\u4f01\u4e1a\u5fae\u4fe1", "CRM \u6570\u636e\u5e93")
public class CustomerDashboardDailyCapabilityHandler implements AgentCapabilityHandler {
private final WecomStudentContactStatsService studentContactStatsService;
public CustomerDashboardDailyCapabilityHandler(WecomStudentContactStatsService studentContactStatsService) {
this.studentContactStatsService = studentContactStatsService;
}
@Override
public AgentIntent intent() {
return AgentIntent.CUSTOMER_DASHBOARD_DAILY;
}
@Override
public AgentCapabilityResponse handle(AgentRequestContext context) {
WecomStudentContactStats stats = studentContactStatsService.fetchTodayStats();
return new AgentCapabilityResponse(
intent().code(),
intent().title(),
intent().mcpGroup(),
"ready",
"""
学员联系数据已从企业微信实时获取。
学员总数:%d
今日新增:%d
跟进成员数:%d
今日收款:暂未接入
""".formatted(stats.totalStudents(), stats.todayNewStudents(), stats.followUserCount()).trim(),
List.of("企业微信学员联系 API"),
Map.of(
"totalStudents", stats.totalStudents(),
"todayNewStudents", stats.todayNewStudents(),
"followUserCount", stats.followUserCount()
),
Instant.now()
);
}
}

View File

@ -56,6 +56,7 @@ public class FoundationProperties {
private String corpId;
private Integer agentId;
private String secret;
private String studentContactSecret;
private String token;
private String encodingAesKey;
private String crmAgentId = "crm-assistant";
@ -69,6 +70,8 @@ public class FoundationProperties {
public void setAgentId(Integer agentId) { this.agentId = agentId; }
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public String getStudentContactSecret() { return studentContactSecret; }
public void setStudentContactSecret(String studentContactSecret) { this.studentContactSecret = studentContactSecret; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getEncodingAesKey() { return encodingAesKey; }

View File

@ -8,6 +8,9 @@ import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class WecomClient {
@ -17,6 +20,7 @@ public class WecomClient {
private volatile String cachedToken;
private volatile Instant tokenExpireAt = Instant.EPOCH;
private final Map<String, TokenCache> tokenCaches = new ConcurrentHashMap<>();
public WecomClient(FoundationProperties properties, WebClient.Builder builder) {
this.properties = properties;
@ -48,6 +52,43 @@ public class WecomClient {
log.info("wecom sendText success toUser={}", toUser);
}
public WecomFollowUserListResponse getStudentContactFollowUsers() {
String token = getAccessToken(studentContactSecret());
WecomFollowUserListResponse response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/cgi-bin/externalcontact/get_follow_user_list")
.queryParam("access_token", token)
.build())
.retrieve()
.bodyToMono(WecomFollowUserListResponse.class)
.block();
if (response == null || !response.success()) {
log.error("wecom student contact get follow users failed err={}", response == null ? "null" : response.errmsg());
throw new IllegalStateException("WeCom student contact get follow users failed: " + (response == null ? "null" : response.errmsg()));
}
return response;
}
public WecomExternalContactBatchResponse batchGetStudentContacts(List<String> userIds, String cursor, int limit) {
String token = getAccessToken(studentContactSecret());
WecomExternalContactBatchRequest request = new WecomExternalContactBatchRequest(userIds, cursor, limit);
WecomExternalContactBatchResponse response = webClient.post()
.uri(uriBuilder -> uriBuilder.path("/cgi-bin/externalcontact/batch/get_by_user")
.queryParam("access_token", token)
.build())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToMono(WecomExternalContactBatchResponse.class)
.block();
if (response == null || !response.success()) {
log.error("wecom student contact batch get failed err={}", response == null ? "null" : response.errmsg());
throw new IllegalStateException("WeCom student contact batch get failed: " + (response == null ? "null" : response.errmsg()));
}
return response;
}
private String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(tokenExpireAt)) {
log.info("wecom gettoken use cached token expireAt={}", tokenExpireAt);
@ -75,6 +116,40 @@ public class WecomClient {
return cachedToken;
}
private String getAccessToken(String secret) {
String safeSecret = secret == null || secret.isBlank() ? properties.getWecom().getSecret() : secret.trim();
TokenCache cache = tokenCaches.computeIfAbsent(safeSecret, ignored -> new TokenCache());
if (cache.token != null && Instant.now().isBefore(cache.expireAt)) {
log.info("wecom gettoken use cached token expireAt={}", cache.expireAt);
return cache.token;
}
WecomTokenResponse response = webClient.get()
.uri(uriBuilder -> uriBuilder.path("/cgi-bin/gettoken")
.queryParam("corpid", properties.getWecom().getCorpId())
.queryParam("corpsecret", safeSecret)
.build())
.retrieve()
.bodyToMono(WecomTokenResponse.class)
.block();
if (response == null || !response.success() || response.accessToken() == null) {
log.error("wecom gettoken failed err={}", response == null ? "null" : response.errmsg());
throw new IllegalStateException("WeCom gettoken failed: " + (response == null ? "null" : response.errmsg()));
}
cache.token = response.accessToken();
int expires = response.expiresIn() == null ? 7200 : response.expiresIn();
cache.expireAt = Instant.now().plusSeconds(Math.max(300, expires - 120));
log.info("wecom gettoken success expireAt={}", cache.expireAt);
return cache.token;
}
private String studentContactSecret() {
String secret = properties.getWecom().getStudentContactSecret();
return secret == null || secret.isBlank() ? properties.getWecom().getSecret() : secret;
}
private String preview(String text) {
if (text == null || text.isBlank()) {
return "";
@ -82,4 +157,8 @@ public class WecomClient {
return text.length() <= 80 ? text : text.substring(0, 80) + "...";
}
private static class TokenCache {
private volatile String token;
private volatile Instant expireAt = Instant.EPOCH;
}
}

View File

@ -0,0 +1,11 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record WecomExternalContactBatchRequest(
@JsonProperty("userid_list") List<String> userIdList,
String cursor,
Integer limit
) {}

View File

@ -0,0 +1,31 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record WecomExternalContactBatchResponse(
Integer errcode,
String errmsg,
@JsonProperty("external_contact_list") List<ExternalContactItem> externalContactList,
@JsonProperty("next_cursor") String nextCursor
) {
public boolean success() {
return errcode == null || errcode == 0;
}
public record ExternalContactItem(
@JsonProperty("external_contact") ExternalContact externalContact,
@JsonProperty("follow_info") FollowInfo followInfo
) {}
public record ExternalContact(
@JsonProperty("external_userid") String externalUserId,
String name
) {}
public record FollowInfo(
String userid,
Long createtime
) {}
}

View File

@ -0,0 +1,15 @@
package com.company.agentfoundation.integration.wecom;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record WecomFollowUserListResponse(
Integer errcode,
String errmsg,
@JsonProperty("follow_user") List<String> followUser
) {
public boolean success() {
return errcode == null || errcode == 0;
}
}

View File

@ -0,0 +1,7 @@
package com.company.agentfoundation.integration.wecom;
public record WecomStudentContactStats(
int totalStudents,
int todayNewStudents,
int followUserCount
) {}

View File

@ -0,0 +1,71 @@
package com.company.agentfoundation.integration.wecom;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class WecomStudentContactStatsService {
private static final ZoneId CHINA_ZONE = ZoneId.of("Asia/Shanghai");
private static final int BATCH_SIZE = 100;
private final WecomClient wecomClient;
public WecomStudentContactStatsService(WecomClient wecomClient) {
this.wecomClient = wecomClient;
}
public WecomStudentContactStats fetchTodayStats() {
List<String> followUsers = wecomClient.getStudentContactFollowUsers().followUser();
if (followUsers == null || followUsers.isEmpty()) {
return new WecomStudentContactStats(0, 0, 0);
}
Set<String> allStudents = new HashSet<>();
Map<String, Long> earliestCreateTimeByStudent = new HashMap<>();
for (int start = 0; start < followUsers.size(); start += BATCH_SIZE) {
List<String> batchUsers = followUsers.subList(start, Math.min(start + BATCH_SIZE, followUsers.size()));
fetchBatch(batchUsers, allStudents, earliestCreateTimeByStudent);
}
long todayStart = LocalDate.now(CHINA_ZONE).atStartOfDay(CHINA_ZONE).toEpochSecond();
long tomorrowStart = LocalDate.now(CHINA_ZONE).plusDays(1).atStartOfDay(CHINA_ZONE).toEpochSecond();
int todayNew = (int) earliestCreateTimeByStudent.values().stream()
.filter(createTime -> createTime != null && createTime >= todayStart && createTime < tomorrowStart)
.count();
return new WecomStudentContactStats(allStudents.size(), todayNew, followUsers.size());
}
private void fetchBatch(List<String> userIds, Set<String> allStudents, Map<String, Long> earliestCreateTimeByStudent) {
String cursor = "";
while (true) {
WecomExternalContactBatchResponse response = wecomClient.batchGetStudentContacts(userIds, cursor, BATCH_SIZE);
List<WecomExternalContactBatchResponse.ExternalContactItem> contacts = response.externalContactList();
if (contacts != null) {
for (WecomExternalContactBatchResponse.ExternalContactItem item : contacts) {
if (item == null || item.externalContact() == null || item.externalContact().externalUserId() == null) {
continue;
}
String externalUserId = item.externalContact().externalUserId();
allStudents.add(externalUserId);
Long createTime = item.followInfo() == null ? null : item.followInfo().createtime();
if (createTime != null) {
earliestCreateTimeByStudent.merge(externalUserId, createTime, Math::min);
}
}
}
cursor = response.nextCursor();
if (cursor == null || cursor.isBlank()) {
break;
}
}
}
}

View File

@ -50,6 +50,7 @@ foundation:
corp-id: ww11a329ca8ee0c40e
agent-id: 1000002
secret: 8tnH50ISscWBhQbxSzjRrA6XT2oJBUvEdEpY77hmEK4
student-contact-secret: 8tnH50ISscWBhQbxSzjRrA6XT2oJBUvEdEpY77hmEK4
token: crmAgentToken2026
encoding-aes-key: k3F8mN2vQ7xLp9sTd4YwH6cRb1JuEz5AaZq7BnVcK2D
crm-agent-id: crm-assistant