客资管理
This commit is contained in:
parent
866d14461b
commit
d8be530787
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
) {}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.company.agentfoundation.integration.wecom;
|
||||
|
||||
public record WecomStudentContactStats(
|
||||
int totalStudents,
|
||||
int todayNewStudents,
|
||||
int followUserCount
|
||||
) {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user