初始化提交

This commit is contained in:
MerCry 2026-02-23 09:45:23 +08:00
commit 72d84e2622
144 changed files with 7747 additions and 0 deletions

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

19
.idea/compiler.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="wecom-robot" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="wecom-robot" options="-parameters" />
</option>
</component>
</project>

6
.idea/encodings.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
</component>
</project>

20
.idea/jarRepositories.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

12
.idea/misc.xml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

BIN
lib/commons-codec-1.9.jar Normal file

Binary file not shown.

114
pom.xml Normal file
View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.wecom</groupId>
<artifactId>wecom-robot</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>wecom-robot</name>
<description>企业微信智能客服系统</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
<hutool.version>5.8.22</hutool.version>
<fastjson.version>2.0.40</fastjson.version>
<project.basedir>${project.basedir}</project.basedir>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.9</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/commons-codec-1.9.jar</systemPath>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<includeSystemScope>true</includeSystemScope>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,18 @@
package com.wecom.robot;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@MapperScan("com.wecom.robot.mapper")
@EnableAsync
@EnableScheduling
public class WecomRobotApplication {
public static void main(String[] args) {
SpringApplication.run(WecomRobotApplication.class, args);
}
}

View File

@ -0,0 +1,30 @@
package com.wecom.robot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "ai")
public class AiConfig {
private boolean enabled;
private String provider;
private DeepSeekConfig deepseek;
private OpenAiConfig openai;
@Data
public static class DeepSeekConfig {
private String apiKey;
private String baseUrl;
private String model;
}
@Data
public static class OpenAiConfig {
private String apiKey;
private String baseUrl;
private String model;
}
}

View File

@ -0,0 +1,19 @@
package com.wecom.robot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "transfer")
public class TransferConfig {
private List<String> keywords;
private double confidenceThreshold;
private int maxFailRounds;
private long maxSessionDuration;
private int maxMessageRounds;
}

View File

@ -0,0 +1,24 @@
package com.wecom.robot.config;
import com.wecom.robot.websocket.CsWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final CsWebSocketHandler csWebSocketHandler;
public WebSocketConfig(CsWebSocketHandler csWebSocketHandler) {
this.csWebSocketHandler = csWebSocketHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(csWebSocketHandler, "/ws/cs/*")
.setAllowedOrigins("*");
}
}

View File

@ -0,0 +1,25 @@
package com.wecom.robot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "wecom")
public class WecomConfig {
private String corpId;
private String agentId;
private String secret;
private String token;
private String encodingAesKey;
private KfConfig kf;
@Data
public static class KfConfig {
private String callbackUrl;
}
}

View File

@ -0,0 +1,132 @@
package com.wecom.robot.controller;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.service.WecomApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@Slf4j
@RestController
@RequestMapping("/chat-history/api")
@RequiredArgsConstructor
public class ChatHistoryController {
private final SessionManagerService sessionManagerService;
private final WecomApiService wecomApiService;
@GetMapping("/kf-accounts")
public ApiResponse<List<Map<String, Object>>> getKfAccounts() {
try {
JSONObject result = wecomApiService.getKfAccountList(0, 100);
List<Map<String, Object>> accounts = new ArrayList<>();
Integer errcode = result.getInteger("errcode");
if (errcode == null || errcode == 0) {
JSONArray accountList = result.getJSONArray("account_list");
if (accountList != null) {
for (int i = 0; i < accountList.size(); i++) {
JSONObject account = accountList.getJSONObject(i);
Map<String, Object> map = new HashMap<>();
map.put("openKfId", account.getString("open_kfid"));
map.put("name", account.getString("name"));
map.put("avatar", account.getString("avatar"));
accounts.add(map);
}
}
}
return ApiResponse.success(accounts);
} catch (Exception e) {
log.error("获取客服账号列表失败", e);
return ApiResponse.error("获取客服账号列表失败: " + e.getMessage());
}
}
@GetMapping("/sessions")
public ApiResponse<List<Map<String, Object>>> getSessions(
@RequestParam String openKfId,
@RequestParam(required = false) String status,
@RequestParam(required = false, defaultValue = "50") int limit) {
try {
List<Session> sessions = sessionManagerService.getSessionsByKfId(openKfId, status, limit);
List<Map<String, Object>> result = new ArrayList<>();
for (Session session : sessions) {
Map<String, Object> map = new HashMap<>();
map.put("sessionId", session.getSessionId());
map.put("customerId", session.getCustomerId());
map.put("kfId", session.getKfId());
map.put("status", session.getStatus());
map.put("wxServiceState", session.getWxServiceState());
map.put("manualCsId", session.getManualCsId());
map.put("createdAt", session.getCreatedAt() != null ? session.getCreatedAt().toString() : null);
map.put("updatedAt", session.getUpdatedAt() != null ? session.getUpdatedAt().toString() : null);
int msgCount = sessionManagerService.getMessageCount(session.getSessionId());
map.put("messageCount", msgCount);
result.add(map);
}
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取会话列表失败", e);
return ApiResponse.error("获取会话列表失败: " + e.getMessage());
}
}
@GetMapping("/messages")
public ApiResponse<List<Map<String, Object>>> getMessages(@RequestParam String sessionId) {
try {
List<Message> messages = sessionManagerService.getSessionMessages(sessionId);
List<Map<String, Object>> result = new ArrayList<>();
for (Message msg : messages) {
Map<String, Object> map = new HashMap<>();
map.put("msgId", msg.getMsgId());
map.put("sessionId", msg.getSessionId());
map.put("senderType", msg.getSenderType());
map.put("senderId", msg.getSenderId());
map.put("content", msg.getContent());
map.put("msgType", msg.getMsgType());
map.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null);
result.add(map);
}
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取消息列表失败", e);
return ApiResponse.error("获取消息列表失败: " + e.getMessage());
}
}
@GetMapping("/session/{sessionId}/detail")
public ApiResponse<Map<String, Object>> getSessionDetail(@PathVariable String sessionId) {
try {
Session session = sessionManagerService.getSession(sessionId);
if (session == null) {
return ApiResponse.error("会话不存在");
}
Map<String, Object> result = new HashMap<>();
result.put("session", session);
List<Message> messages = sessionManagerService.getSessionMessages(sessionId);
result.put("messages", messages);
result.put("messageCount", messages.size());
return ApiResponse.success(result);
} catch (Exception e) {
log.error("获取会话详情失败", e);
return ApiResponse.error("获取会话详情失败: " + e.getMessage());
}
}
}

View File

@ -0,0 +1,181 @@
package com.wecom.robot.controller;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.dto.ChatCompletionRequest;
import com.wecom.robot.entity.Message;
import com.wecom.robot.service.AiService;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.util.WXBizMsgCrypt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/debug")
@RequiredArgsConstructor
public class DebugController {
private final WecomConfig wecomConfig;
private final AiService aiService;
private final SessionManagerService sessionManagerService;
@GetMapping("/config")
public ApiResponse<Map<String, Object>> getConfig() {
Map<String, Object> config = new HashMap<>();
config.put("corpId", wecomConfig.getCorpId());
config.put("token", wecomConfig.getToken());
config.put("encodingAesKey", wecomConfig.getEncodingAesKey());
config.put("encodingAesKeyLength", wecomConfig.getEncodingAesKey() != null ? wecomConfig.getEncodingAesKey().length() : 0);
try {
byte[] aesKey = Base64.getDecoder().decode(wecomConfig.getEncodingAesKey() + "=");
config.put("aesKeyLength", aesKey.length);
config.put("aesKeyHex", bytesToHex(aesKey));
} catch (Exception e) {
config.put("aesKeyError", e.getMessage());
}
return ApiResponse.success(config);
}
@PostMapping("/decrypt")
public ApiResponse<Map<String, Object>> testDecrypt(
@RequestParam String msgSignature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestBody String encryptedXml) {
Map<String, Object> result = new HashMap<>();
result.put("msgSignature", msgSignature);
result.put("timestamp", timestamp);
result.put("nonce", nonce);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decrypted = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, encryptedXml);
result.put("decrypted", decrypted);
result.put("success", true);
} catch (Exception e) {
result.put("error", e.getMessage());
result.put("success", false);
log.error("解密测试失败", e);
}
return ApiResponse.success(result);
}
@GetMapping("/verify-url")
public ApiResponse<Map<String, Object>> testVerifyUrl(
@RequestParam String msgSignature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr) {
Map<String, Object> result = new HashMap<>();
result.put("msgSignature", msgSignature);
result.put("timestamp", timestamp);
result.put("nonce", nonce);
result.put("echostr", echostr);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decrypted = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr);
result.put("decrypted", decrypted);
result.put("success", true);
} catch (Exception e) {
result.put("error", e.getMessage());
result.put("success", false);
log.error("URL验证测试失败", e);
}
return ApiResponse.success(result);
}
@GetMapping("/ai/context")
public ApiResponse<Map<String, Object>> getLastAiContext() {
Map<String, Object> result = new HashMap<>();
result.put("systemPrompt", aiService.getSystemPrompt());
result.put("lastRequestJson", aiService.getLastRequestJson());
result.put("lastConfidence", aiService.getLastConfidence());
List<ChatCompletionRequest.Message> messages = aiService.getLastRequestMessages();
result.put("messageCount", messages.size());
List<Map<String, String>> messageList = new java.util.ArrayList<>();
for (ChatCompletionRequest.Message msg : messages) {
Map<String, String> msgMap = new HashMap<>();
msgMap.put("role", msg.getRole());
msgMap.put("content", msg.getContent());
messageList.add(msgMap);
}
result.put("messages", messageList);
return ApiResponse.success(result);
}
@GetMapping("/ai/session/{sessionId}/context")
public ApiResponse<Map<String, Object>> getSessionAiContext(
@PathVariable String sessionId,
@RequestParam(required = false, defaultValue = "测试消息") String testMessage) {
Map<String, Object> result = new HashMap<>();
result.put("sessionId", sessionId);
result.put("systemPrompt", aiService.getSystemPrompt());
List<Message> history = sessionManagerService.getSessionMessages(sessionId);
result.put("historyCount", history.size());
List<Map<String, Object>> historyList = new java.util.ArrayList<>();
for (Message msg : history) {
Map<String, Object> msgMap = new HashMap<>();
msgMap.put("senderType", msg.getSenderType());
msgMap.put("senderId", msg.getSenderId());
msgMap.put("content", msg.getContent());
msgMap.put("msgType", msg.getMsgType());
msgMap.put("createdAt", msg.getCreatedAt() != null ? msg.getCreatedAt().toString() : null);
historyList.add(msgMap);
}
result.put("history", historyList);
List<ChatCompletionRequest.Message> contextMessages = aiService.buildMessagesForTest(history, testMessage);
result.put("contextMessageCount", contextMessages.size());
List<Map<String, String>> contextList = new java.util.ArrayList<>();
for (ChatCompletionRequest.Message msg : contextMessages) {
Map<String, String> msgMap = new HashMap<>();
msgMap.put("role", msg.getRole());
msgMap.put("content", msg.getContent());
contextList.add(msgMap);
}
result.put("contextMessages", contextList);
return ApiResponse.success(result);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

View File

@ -0,0 +1,190 @@
package com.wecom.robot.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wecom.robot.dto.*;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.mapper.MessageMapper;
import com.wecom.robot.mapper.SessionMapper;
import com.wecom.robot.service.SessionManagerService;
import com.wecom.robot.service.WecomApiService;
import com.wecom.robot.service.WebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/api/sessions")
@RequiredArgsConstructor
public class SessionController {
private final SessionMapper sessionMapper;
private final MessageMapper messageMapper;
private final SessionManagerService sessionManagerService;
private final WecomApiService wecomApiService;
private final WebSocketService webSocketService;
@GetMapping
public ApiResponse<List<SessionInfo>> getSessions(
@RequestParam(required = false) String status,
@RequestParam(required = false) String csId) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
if (status != null) {
query.eq(Session::getStatus, status);
}
if (csId != null) {
query.eq(Session::getManualCsId, csId);
}
query.orderByDesc(Session::getUpdatedAt);
List<Session> sessions = sessionMapper.selectList(query);
List<SessionInfo> sessionInfos = sessions.stream().map(session -> {
SessionInfo info = new SessionInfo();
info.setSessionId(session.getSessionId());
info.setCustomerId(session.getCustomerId());
info.setKfId(session.getKfId());
info.setStatus(session.getStatus());
info.setManualCsId(session.getManualCsId());
info.setCreatedAt(session.getCreatedAt());
info.setUpdatedAt(session.getUpdatedAt());
info.setMetadata(session.getMetadata());
LambdaQueryWrapper<Message> msgQuery = new LambdaQueryWrapper<>();
msgQuery.eq(Message::getSessionId, session.getSessionId())
.orderByDesc(Message::getCreatedAt)
.last("LIMIT 1");
Message lastMsg = messageMapper.selectOne(msgQuery);
if (lastMsg != null) {
info.setLastMessage(lastMsg.getContent());
info.setLastMessageTime(lastMsg.getCreatedAt());
}
int msgCount = sessionManagerService.getMessageCount(session.getSessionId());
info.setMessageCount(msgCount);
return info;
}).collect(Collectors.toList());
return ApiResponse.success(sessionInfos);
}
@GetMapping("/{sessionId}")
public ApiResponse<SessionInfo> getSession(@PathVariable String sessionId) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
SessionInfo info = new SessionInfo();
info.setSessionId(session.getSessionId());
info.setCustomerId(session.getCustomerId());
info.setKfId(session.getKfId());
info.setStatus(session.getStatus());
info.setManualCsId(session.getManualCsId());
info.setCreatedAt(session.getCreatedAt());
info.setUpdatedAt(session.getUpdatedAt());
info.setMetadata(session.getMetadata());
return ApiResponse.success(info);
}
@GetMapping("/{sessionId}/history")
public ApiResponse<List<MessageInfo>> getSessionHistory(@PathVariable String sessionId) {
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId)
.orderByAsc(Message::getCreatedAt);
List<Message> messages = messageMapper.selectList(query);
List<MessageInfo> messageInfos = messages.stream().map(msg -> {
MessageInfo info = new MessageInfo();
info.setMsgId(msg.getMsgId());
info.setSessionId(msg.getSessionId());
info.setSenderType(msg.getSenderType());
info.setSenderId(msg.getSenderId());
info.setContent(msg.getContent());
info.setMsgType(msg.getMsgType());
info.setCreatedAt(msg.getCreatedAt());
return info;
}).collect(Collectors.toList());
return ApiResponse.success(messageInfos);
}
@PostMapping("/{sessionId}/accept")
public ApiResponse<Void> acceptSession(@PathVariable String sessionId, @RequestBody AcceptSessionRequest request) {
String csId = request.getCsId();
if (csId == null || csId.isEmpty()) {
return ApiResponse.error(400, "客服ID不能为空");
}
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
if (!Session.STATUS_PENDING.equals(session.getStatus())) {
return ApiResponse.error(400, "会话状态不正确");
}
sessionManagerService.acceptTransfer(sessionId, csId);
webSocketService.notifySessionAccepted(sessionId, csId);
return ApiResponse.success(null);
}
@PostMapping("/{sessionId}/message")
public ApiResponse<Void> sendMessage(@PathVariable String sessionId, @RequestBody SendMessageRequest request) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
if (!Session.STATUS_MANUAL.equals(session.getStatus())) {
return ApiResponse.error(400, "会话状态不正确");
}
boolean success = wecomApiService.sendTextMessage(
session.getCustomerId(),
session.getKfId(),
request.getContent()
);
if (!success) {
return ApiResponse.error("消息发送失败");
}
sessionManagerService.saveMessage(
"manual_" + System.currentTimeMillis(),
sessionId,
Message.SENDER_TYPE_MANUAL,
session.getManualCsId(),
request.getContent(),
request.getMsgType() != null ? request.getMsgType() : "text",
null
);
return ApiResponse.success(null);
}
@PostMapping("/{sessionId}/close")
public ApiResponse<Void> closeSession(@PathVariable String sessionId) {
Session session = sessionMapper.selectById(sessionId);
if (session == null) {
return ApiResponse.error(404, "会话不存在");
}
sessionManagerService.closeSession(sessionId);
webSocketService.notifySessionClosed(sessionId);
return ApiResponse.success(null);
}
}

View File

@ -0,0 +1,150 @@
package com.wecom.robot.controller;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.dto.ApiResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.service.MessageProcessService;
import com.wecom.robot.service.WecomApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@RestController
@RequestMapping("/test")
@RequiredArgsConstructor
public class TestController {
private final MessageProcessService messageProcessService;
private final WecomApiService wecomApiService;
@PostMapping("/send-message")
public ApiResponse<Map<String, Object>> sendTestMessage(
@RequestParam(required = false, defaultValue = "test_customer_001") String customerId,
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId,
@RequestParam String content) {
WxCallbackMessage message = new WxCallbackMessage();
message.setToUserName("system");
message.setFromUserName(customerId);
message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
message.setMsgType("text");
message.setContent(content);
message.setMsgId(UUID.randomUUID().toString());
message.setOpenKfId(kfId);
message.setExternalUserId(customerId);
Map<String, String> rawData = new HashMap<>();
rawData.put("ToUserName", "system");
rawData.put("FromUserName", customerId);
rawData.put("MsgType", "text");
rawData.put("Content", content);
rawData.put("MsgId", message.getMsgId());
rawData.put("OpenKfId", kfId);
rawData.put("ExternalUserId", customerId);
message.setRawData(rawData);
log.info("模拟发送消息(测试模式): customerId={}, kfId={}, content={}", customerId, kfId, content);
messageProcessService.processMessage(message);
Map<String, Object> result = new HashMap<>();
result.put("msgId", message.getMsgId());
result.put("customerId", customerId);
result.put("kfId", kfId);
result.put("content", content);
result.put("mode", "test_direct");
return ApiResponse.success(result);
}
@PostMapping("/trigger-transfer")
public ApiResponse<Map<String, Object>> triggerTransfer(
@RequestParam(required = false, defaultValue = "test_customer_001") String customerId,
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId) {
WxCallbackMessage message = new WxCallbackMessage();
message.setToUserName("system");
message.setFromUserName(customerId);
message.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
message.setMsgType("text");
message.setContent("我要转人工");
message.setMsgId(UUID.randomUUID().toString());
message.setOpenKfId(kfId);
message.setExternalUserId(customerId);
Map<String, String> rawData = new HashMap<>();
rawData.put("ToUserName", "system");
rawData.put("FromUserName", customerId);
rawData.put("MsgType", "text");
rawData.put("Content", "我要转人工");
rawData.put("MsgId", message.getMsgId());
rawData.put("OpenKfId", kfId);
rawData.put("ExternalUserId", customerId);
message.setRawData(rawData);
log.info("模拟触发转人工(测试模式): customerId={}, kfId={}", customerId, kfId);
messageProcessService.processMessage(message);
Map<String, Object> result = new HashMap<>();
result.put("msgId", message.getMsgId());
result.put("customerId", customerId);
result.put("kfId", kfId);
result.put("trigger", "transfer");
result.put("mode", "test_direct");
return ApiResponse.success(result);
}
@PostMapping("/simulate-event")
public ApiResponse<Map<String, Object>> simulateKfMsgEvent(
@RequestParam(required = false, defaultValue = "test_kf_001") String kfId,
@RequestParam(required = false) String token) {
WxCallbackMessage event = new WxCallbackMessage();
event.setMsgType("event");
event.setEvent(WxCallbackMessage.EVENT_KF_MSG_FROM_CUSTOMER);
event.setOpenKfId(kfId);
event.setToken(token);
event.setCreateTime(String.valueOf(System.currentTimeMillis() / 1000));
log.info("模拟客户消息事件: kfId={}, token={}", kfId, token);
messageProcessService.processKfMessageEvent(event);
Map<String, Object> result = new HashMap<>();
result.put("kfId", kfId);
result.put("token", token);
result.put("mode", "event_sync");
return ApiResponse.success(result);
}
@GetMapping("/sync-msg")
public ApiResponse<SyncMsgResponse> syncMessages(
@RequestParam String kfId,
@RequestParam(required = false) String token) {
log.info("手动拉取消息: kfId={}, token={}", kfId, token);
SyncMsgResponse response = wecomApiService.syncMessagesByToken(kfId, token);
return ApiResponse.success(response);
}
@PostMapping("/clear-cursor")
public ApiResponse<Void> clearCursor(
@RequestParam String kfId) {
log.info("清除cursor: kfId={}", kfId);
wecomApiService.clearCursor(kfId);
return ApiResponse.success(null);
}
}

View File

@ -0,0 +1,101 @@
package com.wecom.robot.controller;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.service.MessageProcessService;
import com.wecom.robot.util.WXBizMsgCrypt;
import com.wecom.robot.util.XmlUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/wecom")
@RequiredArgsConstructor
public class WecomCallbackController {
private final WecomConfig wecomConfig;
private final MessageProcessService messageProcessService;
@GetMapping("/callback")
public String verifyUrl(
@RequestParam("msg_signature") String msgSignature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam("echostr") String echostr) {
log.info("收到URL验证请求: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String result = wxcpt.VerifyURL(msgSignature, timestamp, nonce, echostr);
log.info("URL验证成功返回: {}", result);
return result;
} catch (Exception e) {
log.error("URL验证失败", e);
return "error";
}
}
@PostMapping("/callback")
public String handleCallback(
@RequestParam(value = "msg_signature", required = false) String msgSignature,
@RequestParam(value = "timestamp", required = false) String timestamp,
@RequestParam(value = "nonce", required = false) String nonce,
@RequestBody String requestBody) {
log.info("收到回调消息: msgSignature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
log.debug("消息内容: {}", requestBody);
try {
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(
wecomConfig.getToken(),
wecomConfig.getEncodingAesKey(),
wecomConfig.getCorpId()
);
String decryptedXml = wxcpt.DecryptMsg(msgSignature, timestamp, nonce, requestBody);
log.info("解密后的XML: {}", decryptedXml);
Map<String, String> messageMap = XmlUtil.parseXml(decryptedXml);
WxCallbackMessage message = WxCallbackMessage.fromMap(messageMap);
log.info("解析后的消息: msgType={}, event={}, openKfId={}",
message.getMsgType(), message.getEvent(), message.getOpenKfId());
if ("event".equals(message.getMsgType())) {
handleEvent(message);
} else {
log.warn("收到非事件消息: msgType={}", message.getMsgType());
}
return "success";
} catch (Exception e) {
log.error("处理回调消息失败", e);
return "success";
}
}
private void handleEvent(WxCallbackMessage message) {
String event = message.getEvent();
log.info("处理事件: event={}, openKfId={}, token={}",
event, message.getOpenKfId(), message.getToken());
if (message.isKfMsgEvent()) {
log.info("收到客户消息事件通知: openKfId={}, token={}",
message.getOpenKfId(), message.getToken());
messageProcessService.processKfMessageEvent(message);
} else if (message.isAccountOnlineEvent()) {
log.info("客服账号上线: openKfId={}", message.getOpenKfId());
} else if (message.isAccountOfflineEvent()) {
log.info("客服账号下线: openKfId={}", message.getOpenKfId());
} else {
log.info("其他事件: event={}", event);
}
}
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class AcceptSessionRequest {
private String csId;
}

View File

@ -0,0 +1,30 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
public static <T> ApiResponse<T> error(String message) {
return error(500, message);
}
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChatCompletionRequest {
private String model;
private List<Message> messages;
private double temperature;
private int maxTokens;
@Data
public static class Message {
private String role;
private String content;
public Message(String role, String content) {
this.role = role;
this.content = content;
}
}
public static ChatCompletionRequest create(String model, List<Message> messages) {
ChatCompletionRequest request = new ChatCompletionRequest();
request.setModel(model);
request.setMessages(messages);
request.setTemperature(0.7);
request.setMaxTokens(2000);
return request;
}
}

View File

@ -0,0 +1,43 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChatCompletionResponse {
private String id;
private String object;
private long created;
private String model;
private List<Choice> choices;
private Usage usage;
@Data
public static class Choice {
private int index;
private Message message;
private String finishReason;
}
@Data
public static class Message {
private String role;
private String content;
}
@Data
public static class Usage {
private int promptTokens;
private int completionTokens;
private int totalTokens;
}
public String getContent() {
if (choices != null && !choices.isEmpty()) {
return choices.get(0).getMessage().getContent();
}
return null;
}
}

View File

@ -0,0 +1,17 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class MessageInfo {
private String msgId;
private String sessionId;
private String senderType;
private String senderId;
private String content;
private String msgType;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,10 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class SendMessageRequest {
private String content;
private String msgType;
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class ServiceStateResponse {
private Integer errcode;
private String errmsg;
private Integer serviceState;
private String servicerUserid;
public boolean isSuccess() {
return errcode == null || errcode == 0;
}
public static final int STATE_UNTREATED = 0;
public static final int STATE_AI = 1;
public static final int STATE_POOL = 2;
public static final int STATE_MANUAL = 3;
public static final int STATE_CLOSED = 4;
public String getStateDesc() {
if (serviceState == null) return "未知";
switch (serviceState) {
case STATE_UNTREATED: return "未处理";
case STATE_AI: return "智能助手接待";
case STATE_POOL: return "待接入池排队";
case STATE_MANUAL: return "人工接待";
case STATE_CLOSED: return "已结束";
default: return "未知(" + serviceState + ")";
}
}
}

View File

@ -0,0 +1,21 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class SessionInfo {
private String sessionId;
private String customerId;
private String kfId;
private String status;
private String manualCsId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String metadata;
private int messageCount;
private String lastMessage;
private LocalDateTime lastMessageTime;
}

View File

@ -0,0 +1,212 @@
package com.wecom.robot.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import java.util.List;
@Data
public class SyncMsgResponse {
private Integer errcode;
private String errmsg;
private String nextCursor;
private Boolean hasMore;
private List<MsgItem> msgList;
@Data
public static class MsgItem {
private String msgId;
private Integer origin;
private String externalUserId;
private String openKfId;
private Long sendTime;
private String msgType;
private String servicerUserid;
private String originData;
private TextContent text;
private ImageContent image;
private VoiceContent voice;
private VideoContent video;
private FileContent file;
private LocationContent location;
private LinkContent link;
private BusinessCardContent businessCard;
private MiniprogramContent miniprogram;
private MsgMenuContent msgmenu;
private EventContent event;
public static final int ORIGIN_CUSTOMER = 3;
public static final int ORIGIN_SYSTEM_EVENT = 4;
public static final int ORIGIN_SERVICER = 5;
public String getTextContent() {
return text != null ? text.getContent() : null;
}
public String getImageMediaId() {
return image != null ? image.getMediaId() : null;
}
public boolean isFromCustomer() {
return origin != null && origin == ORIGIN_CUSTOMER;
}
public boolean isSystemEvent() {
return origin != null && origin == ORIGIN_SYSTEM_EVENT;
}
public boolean isFromServicer() {
return origin != null && origin == ORIGIN_SERVICER;
}
public boolean isEvent() {
return "event".equals(msgType);
}
public String getEventType() {
return event != null ? event.getEventType() : null;
}
}
@Data
public static class TextContent {
private String content;
private String menuId;
}
@Data
public static class ImageContent {
private String mediaId;
}
@Data
public static class VoiceContent {
private String mediaId;
}
@Data
public static class VideoContent {
private String mediaId;
}
@Data
public static class FileContent {
private String mediaId;
}
@Data
public static class LocationContent {
private Double latitude;
private Double longitude;
private String name;
private String address;
}
@Data
public static class LinkContent {
private String title;
private String desc;
private String url;
private String picUrl;
}
@Data
public static class BusinessCardContent {
private String userid;
}
@Data
public static class MiniprogramContent {
private String title;
private String appid;
private String pagepath;
private String thumbMediaId;
}
@Data
public static class MsgMenuContent {
private String headContent;
private List<MenuItem> list;
private String tailContent;
}
@Data
public static class MenuItem {
private String type;
private MenuClick click;
private MenuView view;
private MenuMiniprogram miniprogram;
}
@Data
public static class MenuClick {
private String id;
private String content;
}
@Data
public static class MenuView {
private String url;
private String content;
}
@Data
public static class MenuMiniprogram {
private String appid;
private String pagepath;
private String content;
}
@Data
public static class EventContent {
@JSONField(name = "event_type")
private String eventType;
private String openKfId;
private String externalUserid;
private String scene;
private String sceneParam;
private String welcomeCode;
private String failMsgid;
private Integer failType;
private String servicerUserid;
private Integer status;
private Integer stopType;
private Integer changeType;
private String oldServicerUserid;
private String newServicerUserid;
private String msgCode;
private String recallMsgid;
private Integer rejectSwitch;
private WechatChannels wechatChannels;
public static final String EVENT_ENTER_SESSION = "enter_session";
public static final String EVENT_MSG_SEND_FAIL = "msg_send_fail";
public static final String EVENT_SERVICER_STATUS_CHANGE = "servicer_status_change";
public static final String EVENT_SESSION_STATUS_CHANGE = "session_status_change";
public static final String EVENT_USER_RECALL_MSG = "user_recall_msg";
public static final String EVENT_SERVICER_RECALL_MSG = "servicer_recall_msg";
public static final String EVENT_REJECT_CUSTOMER_MSG_SWITCH_CHANGE = "reject_customer_msg_switch_change";
public static final int CHANGE_TYPE_FROM_POOL = 1;
public static final int CHANGE_TYPE_TRANSFER = 2;
public static final int CHANGE_TYPE_END = 3;
public static final int CHANGE_TYPE_REENTER = 4;
}
@Data
public static class WechatChannels {
private String nickname;
private String shopNickname;
private Integer scene;
}
public boolean isSuccess() {
return errcode == null || errcode == 0;
}
public boolean hasMessages() {
return msgList != null && !msgList.isEmpty();
}
}

View File

@ -0,0 +1,21 @@
package com.wecom.robot.dto;
import lombok.Data;
@Data
public class WxAccessToken {
private String accessToken;
private long expiresIn;
private long createTime;
public WxAccessToken(String accessToken, long expiresIn) {
this.accessToken = accessToken;
this.expiresIn = expiresIn;
this.createTime = System.currentTimeMillis();
}
public boolean isExpired() {
return System.currentTimeMillis() - createTime > (expiresIn - 300) * 1000;
}
}

View File

@ -0,0 +1,65 @@
package com.wecom.robot.dto;
import lombok.Data;
import java.util.Map;
@Data
public class WxCallbackMessage {
private String toUserName;
private String fromUserName;
private String createTime;
private String msgType;
private String content;
private String msgId;
private String event;
private String openKfId;
private String externalUserId;
private String welcomeCode;
private String token;
private String origin;
private String serviceCorpId;
private String changeType;
private String servicerUserid;
private Map<String, String> rawData;
public static final String EVENT_KF_MSG_OR_EVENT = "kf_msg_or_event";
public static final String EVENT_KF_MSG_FROM_CUSTOMER = "kf_msg_from_customer";
public static final String EVENT_KF_ACCOUNT_ONLINE = "kf_account_online";
public static final String EVENT_KF_ACCOUNT_OFFLINE = "kf_account_offline";
public static final String EVENT_MSG_AUDIT_APPROVED = "msg_audit_approved";
public static WxCallbackMessage fromMap(Map<String, String> map) {
WxCallbackMessage msg = new WxCallbackMessage();
msg.setToUserName(map.get("ToUserName"));
msg.setFromUserName(map.get("FromUserName"));
msg.setCreateTime(map.get("CreateTime"));
msg.setMsgType(map.get("MsgType"));
msg.setContent(map.get("Content"));
msg.setMsgId(map.get("MsgId"));
msg.setEvent(map.get("Event"));
msg.setOpenKfId(map.get("OpenKfId"));
msg.setExternalUserId(map.get("ExternalUserId"));
msg.setWelcomeCode(map.get("WelcomeCode"));
msg.setToken(map.get("Token"));
msg.setOrigin(map.get("Origin"));
msg.setServiceCorpId(map.get("ServiceCorpId"));
msg.setChangeType(map.get("ChangeType"));
msg.setServicerUserid(map.get("ServicerUserid"));
msg.setRawData(map);
return msg;
}
public boolean isKfMsgEvent() {
return EVENT_KF_MSG_OR_EVENT.equals(event) || EVENT_KF_MSG_FROM_CUSTOMER.equals(event);
}
public boolean isAccountOnlineEvent() {
return EVENT_KF_ACCOUNT_ONLINE.equals(event);
}
public boolean isAccountOfflineEvent() {
return EVENT_KF_ACCOUNT_OFFLINE.equals(event);
}
}

View File

@ -0,0 +1,58 @@
package com.wecom.robot.dto;
import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
@Data
public class WxSendMessageRequest {
private String touser;
@JSONField(name = "open_kfid")
private String openKfid;
private String msgtype;
private TextContent text;
private ImageContent image;
private LinkContent link;
@Data
public static class TextContent {
private String content;
}
@Data
public static class ImageContent {
private String mediaId;
}
@Data
public static class LinkContent {
private String title;
private String desc;
private String url;
private String thumbMediaId;
}
public static WxSendMessageRequest text(String touser, String openKfid, String content) {
WxSendMessageRequest request = new WxSendMessageRequest();
request.setTouser(touser);
request.setOpenKfid(openKfid);
request.setMsgtype("text");
TextContent textContent = new TextContent();
textContent.setContent(content);
request.setText(textContent);
return request;
}
public static WxSendMessageRequest image(String touser, String openKfid, String mediaId) {
WxSendMessageRequest request = new WxSendMessageRequest();
request.setTouser(touser);
request.setOpenKfid(openKfid);
request.setMsgtype("image");
ImageContent imageContent = new ImageContent();
imageContent.setMediaId(mediaId);
request.setImage(imageContent);
return request;
}
}

View File

@ -0,0 +1,34 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("kf_account")
public class KfAccount implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "kf_id", type = IdType.INPUT)
private String kfId;
private String name;
private String avatar;
private String status;
private String bindManualId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static final String STATUS_ONLINE = "online";
public static final String STATUS_OFFLINE = "offline";
}

View File

@ -0,0 +1,39 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("message")
public class Message implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "msg_id", type = IdType.ASSIGN_ID)
private String msgId;
private String sessionId;
private String senderType;
private String senderId;
private String content;
private String msgType;
private LocalDateTime createdAt;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private String rawData;
public static final String SENDER_TYPE_CUSTOMER = "customer";
public static final String SENDER_TYPE_AI = "ai";
public static final String SENDER_TYPE_MANUAL = "manual";
}

View File

@ -0,0 +1,42 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("session")
public class Session implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "session_id", type = IdType.ASSIGN_ID)
private String sessionId;
private String customerId;
private String kfId;
private String status;
private Integer wxServiceState;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String manualCsId;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private String metadata;
public static final String STATUS_AI = "AI";
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_MANUAL = "MANUAL";
public static final String STATUS_CLOSED = "CLOSED";
}

View File

@ -0,0 +1,29 @@
package com.wecom.robot.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("transfer_log")
public class TransferLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String sessionId;
private String triggerReason;
private LocalDateTime triggerTime;
private LocalDateTime acceptedTime;
private String acceptedCsId;
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.KfAccount;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface KfAccountMapper extends BaseMapper<KfAccount> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.Message;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.Session;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SessionMapper extends BaseMapper<Session> {
}

View File

@ -0,0 +1,9 @@
package com.wecom.robot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.wecom.robot.entity.TransferLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TransferLogMapper extends BaseMapper<TransferLog> {
}

View File

@ -0,0 +1,155 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.config.AiConfig;
import com.wecom.robot.dto.ChatCompletionRequest;
import com.wecom.robot.dto.ChatCompletionResponse;
import com.wecom.robot.entity.Message;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private static final String SYSTEM_PROMPT = "你是ash超脑的客服请用简洁、友好的语言回答客户的问题。" +
"如果不确定答案,请诚实告知客户。" +
"回答要准确、专业,避免过于冗长,要尽量拟人化口语化,自然回答问题。";
private final AiConfig aiConfig;
private final RestTemplate restTemplate = new RestTemplate();
private double lastConfidence = 1.0;
private List<ChatCompletionRequest.Message> lastRequestMessages = new ArrayList<>();
private String lastRequestJson = "";
public String generateReply(String userMessage, List<Message> history) {
if (!aiConfig.isEnabled()) {
return "AI服务暂未开启请联系人工客服。";
}
try {
String provider = aiConfig.getProvider();
String apiUrl;
String apiKey;
String model;
if ("deepseek".equalsIgnoreCase(provider)) {
apiUrl = aiConfig.getDeepseek().getBaseUrl() + "/chat/completions";
apiKey = aiConfig.getDeepseek().getApiKey();
model = aiConfig.getDeepseek().getModel();
} else {
apiUrl = aiConfig.getOpenai().getBaseUrl() + "/chat/completions";
apiKey = aiConfig.getOpenai().getApiKey();
model = aiConfig.getOpenai().getModel();
}
List<ChatCompletionRequest.Message> messages = buildMessages(history, userMessage);
ChatCompletionRequest request = ChatCompletionRequest.create(model, messages);
lastRequestMessages = messages;
lastRequestJson = JSON.toJSONString(request, true);
log.info("===== AI请求上下文 =====");
log.info("Provider: {}, Model: {}", provider, model);
log.info("API URL: {}", apiUrl);
log.info("消息数量: {}", messages.size());
for (int i = 0; i < messages.size(); i++) {
ChatCompletionRequest.Message msg = messages.get(i);
log.info("[{}] {}: {}", i, msg.getRole(), msg.getContent());
}
log.info("===== 完整请求JSON =====");
log.info("\n{}", lastRequestJson);
log.info("========================");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);
HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, entity, String.class);
log.info("===== AI响应 =====");
log.info("Status: {}", response.getStatusCode());
log.info("Body: {}", response.getBody());
log.info("==================");
ChatCompletionResponse completionResponse = JSON.parseObject(response.getBody(), ChatCompletionResponse.class);
String reply = completionResponse.getContent();
lastConfidence = calculateConfidence(reply);
log.info("AI回复生成成功: confidence={}, reply={}", lastConfidence, reply);
return reply;
} catch (Exception e) {
log.error("AI回复生成失败", e);
lastConfidence = 0.0;
return "抱歉,我暂时无法回答您的问题,正在为您转接人工客服...";
}
}
public double getLastConfidence() {
return lastConfidence;
}
public List<ChatCompletionRequest.Message> getLastRequestMessages() {
return lastRequestMessages;
}
public String getLastRequestJson() {
return lastRequestJson;
}
public String getSystemPrompt() {
return SYSTEM_PROMPT;
}
public List<ChatCompletionRequest.Message> buildMessagesForTest(List<Message> history, String currentMessage) {
return buildMessages(history, currentMessage);
}
private List<ChatCompletionRequest.Message> buildMessages(List<Message> history, String currentMessage) {
List<ChatCompletionRequest.Message> messages = new ArrayList<>();
messages.add(new ChatCompletionRequest.Message("system", SYSTEM_PROMPT));
int startIndex = Math.max(0, history.size() - 10);
for (int i = startIndex; i < history.size(); i++) {
Message msg = history.get(i);
String role = Message.SENDER_TYPE_CUSTOMER.equals(msg.getSenderType()) ? "user" : "assistant";
messages.add(new ChatCompletionRequest.Message(role, msg.getContent()));
}
messages.add(new ChatCompletionRequest.Message("user", currentMessage));
return messages;
}
private double calculateConfidence(String reply) {
if (reply == null || reply.isEmpty()) {
return 0.0;
}
if (reply.contains("不确定") || reply.contains("不清楚") || reply.contains("无法回答")) {
return 0.4;
}
if (reply.contains("转接人工") || reply.contains("人工客服")) {
return 0.5;
}
if (reply.length() < 10) {
return 0.6;
}
return 0.85;
}
}

View File

@ -0,0 +1,393 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.wecom.robot.dto.ServiceStateResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageProcessService {
private final SessionManagerService sessionManagerService;
private final AiService aiService;
private final TransferService transferService;
private final WecomApiService wecomApiService;
private final WebSocketService webSocketService;
@Async
public void processKfMessageEvent(WxCallbackMessage event) {
String openKfId = event.getOpenKfId();
String token = event.getToken();
log.info("处理客户消息事件: openKfId={}, token={}", openKfId, token);
if (openKfId == null) {
log.warn("事件缺少openKfId");
return;
}
SyncMsgResponse syncResponse = wecomApiService.syncMessagesByToken(openKfId, token);
if (!syncResponse.isSuccess()) {
log.error("拉取消息失败: errcode={}, errmsg={}",
syncResponse.getErrcode(), syncResponse.getErrmsg());
return;
}
if (!syncResponse.hasMessages()) {
log.info("没有新消息");
return;
}
log.info("拉取到{}条消息", syncResponse.getMsgList().size());
for (SyncMsgResponse.MsgItem msgItem : syncResponse.getMsgList()) {
try {
processSyncedItem(msgItem);
} catch (Exception e) {
log.error("处理消息失败: msgId={}", msgItem.getMsgId(), e);
}
}
while (Boolean.TRUE.equals(syncResponse.getHasMore())) {
log.info("还有更多消息,继续拉取...");
syncResponse = wecomApiService.syncMessages(openKfId, null);
if (!syncResponse.isSuccess() || !syncResponse.hasMessages()) {
break;
}
for (SyncMsgResponse.MsgItem msgItem : syncResponse.getMsgList()) {
try {
processSyncedItem(msgItem);
} catch (Exception e) {
log.error("处理消息失败: msgId={}", msgItem.getMsgId(), e);
}
}
}
}
private void processSyncedItem(SyncMsgResponse.MsgItem msgItem) {
String customerId = msgItem.getExternalUserId();
String kfId = msgItem.getOpenKfId();
log.info("处理消息项: msgId={}, origin={}, msgType={}, customerId={}",
msgItem.getMsgId(), msgItem.getOrigin(), msgItem.getMsgType(), customerId);
if (msgItem.isEvent()) {
processEventMessage(msgItem);
return;
}
if (!msgItem.isFromCustomer()) {
log.debug("非客户消息,跳过处理: origin={}", msgItem.getOrigin());
return;
}
if (customerId == null || kfId == null) {
log.warn("消息缺少必要字段: customerId={}, kfId={}", customerId, kfId);
return;
}
String content = extractContent(msgItem);
String msgType = msgItem.getMsgType();
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
sessionManagerService.saveMessage(
msgItem.getMsgId(),
session.getSessionId(),
Message.SENDER_TYPE_CUSTOMER,
customerId,
content,
msgType,
msgItem.getOriginData()
);
ServiceStateResponse wxState = wecomApiService.getServiceState(kfId, customerId);
if (!wxState.isSuccess()) {
log.warn("获取微信会话状态失败: errcode={}, errmsg={}",
wxState.getErrcode(), wxState.getErrmsg());
}
log.info("微信会话状态: {} ({})", wxState.getStateDesc(), wxState.getServiceState());
sessionManagerService.updateWxServiceState(session.getSessionId(), wxState.getServiceState());
processByWxState(session, customerId, kfId, content, msgType, wxState);
}
private void processEventMessage(SyncMsgResponse.MsgItem msgItem) {
SyncMsgResponse.EventContent event = msgItem.getEvent();
if (event == null) {
return;
}
String eventType = event.getEventType();
String customerId = event.getExternalUserid();
String kfId = event.getOpenKfId();
log.info("处理事件消息: eventType={}, customerId={}, kfId={}", eventType, customerId, kfId);
switch (eventType) {
case SyncMsgResponse.EventContent.EVENT_ENTER_SESSION:
handleEnterSessionEvent(event, customerId, kfId);
break;
case SyncMsgResponse.EventContent.EVENT_SESSION_STATUS_CHANGE:
handleSessionStatusChangeEvent(event, customerId, kfId);
break;
case SyncMsgResponse.EventContent.EVENT_MSG_SEND_FAIL:
log.warn("消息发送失败: failMsgid={}, failType={}",
event.getFailMsgid(), event.getFailType());
break;
case SyncMsgResponse.EventContent.EVENT_USER_RECALL_MSG:
log.info("用户撤回消息: recallMsgid={}", event.getRecallMsgid());
break;
default:
log.info("其他事件类型: {}", eventType);
}
}
private void handleEnterSessionEvent(SyncMsgResponse.EventContent event,
String customerId, String kfId) {
log.info("用户进入会话: customerId={}, scene={}, sceneParam={}",
customerId, event.getScene(), event.getSceneParam());
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
String welcomeCode = event.getWelcomeCode();
if (welcomeCode != null && !welcomeCode.isEmpty()) {
String welcomeMsg = "您好,欢迎咨询!请问有什么可以帮您?";
wecomApiService.sendWelcomeMsg(welcomeCode, welcomeMsg);
sessionManagerService.saveMessage(
"welcome_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_AI,
"AI",
welcomeMsg,
"text",
null
);
}
}
private void handleSessionStatusChangeEvent(SyncMsgResponse.EventContent event,
String customerId, String kfId) {
Integer changeType = event.getChangeType();
String newServicerUserid = event.getNewServicerUserid();
String oldServicerUserid = event.getOldServicerUserid();
String msgCode = event.getMsgCode();
log.info("会话状态变更: changeType={}, oldServicer={}, newServicer={}",
changeType, oldServicerUserid, newServicerUserid);
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
switch (changeType) {
case SyncMsgResponse.EventContent.CHANGE_TYPE_FROM_POOL:
log.info("从接待池接入会话: servicer={}", newServicerUserid);
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_MANUAL);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_TRANSFER:
log.info("转接会话: oldServicer={}, newServicer={}", oldServicerUserid, newServicerUserid);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_END:
log.info("结束会话");
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_CLOSED);
break;
case SyncMsgResponse.EventContent.CHANGE_TYPE_REENTER:
log.info("重新接入已结束会话: servicer={}", newServicerUserid);
sessionManagerService.updateSessionStatus(session.getSessionId(), Session.STATUS_MANUAL);
sessionManagerService.updateServicer(session.getSessionId(), newServicerUserid);
break;
}
}
private void processByWxState(Session session, String customerId, String kfId,
String content, String msgType, ServiceStateResponse wxState) {
Integer state = wxState.getServiceState();
if (state == null) {
state = ServiceStateResponse.STATE_UNTREATED;
}
switch (state) {
case ServiceStateResponse.STATE_UNTREATED:
case ServiceStateResponse.STATE_AI:
processAiMessage(session, customerId, kfId, content);
break;
case ServiceStateResponse.STATE_POOL:
notifyPendingSession(session, customerId, kfId, content, msgType);
break;
case ServiceStateResponse.STATE_MANUAL:
pushToManualCs(session, customerId, kfId, content, msgType, wxState.getServicerUserid());
break;
case ServiceStateResponse.STATE_CLOSED:
Session newSession = sessionManagerService.getOrCreateSession(customerId, kfId);
processAiMessage(newSession, customerId, kfId, content);
break;
default:
log.warn("未知的微信会话状态: {}", state);
processAiMessage(session, customerId, kfId, content);
}
}
private void processAiMessage(Session session, String customerId, String kfId, String content) {
List<Message> history = sessionManagerService.getSessionMessages(session.getSessionId());
String reply = aiService.generateReply(content, history);
double confidence = aiService.getLastConfidence();
int messageCount = sessionManagerService.getMessageCount(session.getSessionId());
boolean shouldTransfer = transferService.shouldTransferToManual(
content,
confidence,
messageCount,
session.getCreatedAt()
);
if (shouldTransfer) {
String reason = transferService.getTransferReason(content, confidence, messageCount);
sessionManagerService.transferToManual(session.getSessionId(), reason);
reply = reply + "\n\n正在为您转接人工客服请稍候...";
wecomApiService.sendTextMessage(customerId, kfId, reply);
boolean transferred = wecomApiService.transferToPool(kfId, customerId);
if (transferred) {
log.info("已将会话转入待接入池: customerId={}, kfId={}", customerId, kfId);
sessionManagerService.updateWxServiceState(session.getSessionId(), ServiceStateResponse.STATE_POOL);
}
webSocketService.notifyNewPendingSession(session.getSessionId());
} else {
wecomApiService.sendTextMessage(customerId, kfId, reply);
sessionManagerService.saveMessage(
"ai_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_AI,
"AI",
reply,
"text",
null
);
}
}
private void notifyPendingSession(Session session, String customerId, String kfId,
String content, String msgType) {
WxCallbackMessage notifyMsg = new WxCallbackMessage();
notifyMsg.setExternalUserId(customerId);
notifyMsg.setOpenKfId(kfId);
notifyMsg.setContent(content);
notifyMsg.setMsgType(msgType);
webSocketService.notifyNewMessage(session.getSessionId(), notifyMsg);
}
private void pushToManualCs(Session session, String customerId, String kfId,
String content, String msgType, String servicerUserid) {
WxCallbackMessage pushMsg = new WxCallbackMessage();
pushMsg.setExternalUserId(customerId);
pushMsg.setOpenKfId(kfId);
pushMsg.setContent(content);
pushMsg.setMsgType(msgType);
pushMsg.setServicerUserid(servicerUserid);
webSocketService.pushMessageToCs(session.getSessionId(), pushMsg);
}
private String extractContent(SyncMsgResponse.MsgItem msgItem) {
String msgType = msgItem.getMsgType();
switch (msgType) {
case "text":
return msgItem.getTextContent();
case "image":
return "[图片]";
case "voice":
return "[语音]";
case "video":
return "[视频]";
case "file":
return "[文件]";
case "location":
SyncMsgResponse.LocationContent loc = msgItem.getLocation();
if (loc != null) {
return "[位置] " + loc.getName() + " " + loc.getAddress();
}
return "[位置]";
case "link":
SyncMsgResponse.LinkContent link = msgItem.getLink();
if (link != null) {
return "[链接] " + link.getTitle();
}
return "[链接]";
case "business_card":
return "[名片]";
case "miniprogram":
return "[小程序]";
case "msgmenu":
return "[菜单消息]";
default:
return "[" + msgType + "]";
}
}
@Async
public void processMessage(WxCallbackMessage message) {
log.info("直接处理消息(测试用): msgType={}", message.getMsgType());
String customerId = message.getExternalUserId();
String kfId = message.getOpenKfId();
if (customerId == null || kfId == null) {
log.warn("消息缺少必要字段: customerId={}, kfId={}", customerId, kfId);
return;
}
Session session = sessionManagerService.getOrCreateSession(customerId, kfId);
String status = sessionManagerService.getSessionStatus(session.getSessionId());
sessionManagerService.saveMessage(
message.getMsgId() != null ? message.getMsgId() : "test_" + System.currentTimeMillis(),
session.getSessionId(),
Message.SENDER_TYPE_CUSTOMER,
customerId,
message.getContent(),
message.getMsgType() != null ? message.getMsgType() : "text",
JSON.toJSONString(message.getRawData())
);
List<Message> history = sessionManagerService.getSessionMessages(session.getSessionId());
switch (status) {
case Session.STATUS_AI:
processAiMessage(session, customerId, kfId, message.getContent());
break;
case Session.STATUS_PENDING:
notifyPendingSession(session, customerId, kfId, message.getContent(), message.getMsgType());
break;
case Session.STATUS_MANUAL:
pushToManualCs(session, customerId, kfId, message.getContent(), message.getMsgType(), null);
break;
case Session.STATUS_CLOSED:
Session newSession = sessionManagerService.getOrCreateSession(customerId, kfId);
processAiMessage(newSession, customerId, kfId, message.getContent());
break;
default:
log.warn("未知的会话状态: {}", status);
}
}
}

View File

@ -0,0 +1,220 @@
package com.wecom.robot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wecom.robot.entity.Message;
import com.wecom.robot.entity.Session;
import com.wecom.robot.entity.TransferLog;
import com.wecom.robot.mapper.MessageMapper;
import com.wecom.robot.mapper.SessionMapper;
import com.wecom.robot.mapper.TransferLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class SessionManagerService {
private static final String SESSION_STATUS_KEY_PREFIX = "session:status:";
private static final String SESSION_MESSAGE_COUNT_KEY_PREFIX = "session:msg_count:";
private final SessionMapper sessionMapper;
private final MessageMapper messageMapper;
private final TransferLogMapper transferLogMapper;
private final StringRedisTemplate redisTemplate;
public Session getOrCreateSession(String customerId, String kfId) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.eq(Session::getCustomerId, customerId)
.eq(Session::getKfId, kfId)
.ne(Session::getStatus, Session.STATUS_CLOSED)
.orderByDesc(Session::getCreatedAt)
.last("LIMIT 1");
Session session = sessionMapper.selectOne(query);
if (session == null) {
session = new Session();
session.setSessionId(generateSessionId(customerId, kfId));
session.setCustomerId(customerId);
session.setKfId(kfId);
session.setStatus(Session.STATUS_AI);
session.setWxServiceState(0);
session.setCreatedAt(LocalDateTime.now());
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.insert(session);
cacheSessionStatus(session.getSessionId(), Session.STATUS_AI);
}
return session;
}
public Session getSession(String sessionId) {
return sessionMapper.selectById(sessionId);
}
public String getSessionStatus(String sessionId) {
String cachedStatus = redisTemplate.opsForValue().get(SESSION_STATUS_KEY_PREFIX + sessionId);
if (cachedStatus != null) {
return cachedStatus;
}
Session session = sessionMapper.selectById(sessionId);
if (session != null) {
cacheSessionStatus(sessionId, session.getStatus());
return session.getStatus();
}
return null;
}
public void updateSessionStatus(String sessionId, String status) {
Session session = new Session();
session.setSessionId(sessionId);
session.setStatus(status);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
cacheSessionStatus(sessionId, status);
}
public void updateWxServiceState(String sessionId, Integer wxServiceState) {
Session session = new Session();
session.setSessionId(sessionId);
session.setWxServiceState(wxServiceState);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
log.info("更新微信会话状态: sessionId={}, wxServiceState={}", sessionId, wxServiceState);
}
public void updateServicer(String sessionId, String servicerUserid) {
Session session = new Session();
session.setSessionId(sessionId);
session.setManualCsId(servicerUserid);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
log.info("更新接待人员: sessionId={}, servicerUserid={}", sessionId, servicerUserid);
}
public void assignManualCs(String sessionId, String csId) {
Session session = new Session();
session.setSessionId(sessionId);
session.setStatus(Session.STATUS_MANUAL);
session.setManualCsId(csId);
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
cacheSessionStatus(sessionId, Session.STATUS_MANUAL);
}
@Transactional
public void transferToManual(String sessionId, String reason) {
updateSessionStatus(sessionId, Session.STATUS_PENDING);
TransferLog transferLog = new TransferLog();
transferLog.setSessionId(sessionId);
transferLog.setTriggerReason(reason);
transferLog.setTriggerTime(LocalDateTime.now());
transferLogMapper.insert(transferLog);
log.info("会话转人工: sessionId={}, reason={}", sessionId, reason);
}
public void acceptTransfer(String sessionId, String csId) {
assignManualCs(sessionId, csId);
LambdaQueryWrapper<TransferLog> query = new LambdaQueryWrapper<>();
query.eq(TransferLog::getSessionId, sessionId)
.isNull(TransferLog::getAcceptedTime)
.orderByDesc(TransferLog::getTriggerTime)
.last("LIMIT 1");
TransferLog transferLog = transferLogMapper.selectOne(query);
if (transferLog != null) {
transferLog.setAcceptedTime(LocalDateTime.now());
transferLog.setAcceptedCsId(csId);
transferLogMapper.updateById(transferLog);
}
log.info("客服接入会话: sessionId={}, csId={}", sessionId, csId);
}
public void closeSession(String sessionId) {
updateSessionStatus(sessionId, Session.STATUS_CLOSED);
redisTemplate.delete(SESSION_STATUS_KEY_PREFIX + sessionId);
redisTemplate.delete(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
log.info("会话已关闭: sessionId={}", sessionId);
}
public void saveMessage(String msgId, String sessionId, String senderType, String senderId,
String content, String msgType, String rawData) {
Message message = new Message();
message.setMsgId(msgId);
message.setSessionId(sessionId);
message.setSenderType(senderType);
message.setSenderId(senderId);
message.setContent(content);
message.setMsgType(msgType);
message.setCreatedAt(LocalDateTime.now());
message.setRawData(rawData);
messageMapper.insert(message);
incrementMessageCount(sessionId);
}
public List<Message> getSessionMessages(String sessionId) {
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId)
.orderByAsc(Message::getCreatedAt);
return messageMapper.selectList(query);
}
public int getMessageCount(String sessionId) {
String count = redisTemplate.opsForValue().get(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
if (count != null) {
return Integer.parseInt(count);
}
LambdaQueryWrapper<Message> query = new LambdaQueryWrapper<>();
query.eq(Message::getSessionId, sessionId);
long dbCount = messageMapper.selectCount(query);
redisTemplate.opsForValue().set(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId, String.valueOf(dbCount));
return (int) dbCount;
}
public List<Session> getSessionsByKfId(String kfId, String status, int limit) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.eq(Session::getKfId, kfId);
if (status != null && !status.isEmpty() && !"all".equals(status)) {
query.eq(Session::getStatus, status);
}
query.orderByDesc(Session::getUpdatedAt);
query.last("LIMIT " + limit);
return sessionMapper.selectList(query);
}
public List<Session> getAllSessions(int limit) {
LambdaQueryWrapper<Session> query = new LambdaQueryWrapper<>();
query.orderByDesc(Session::getUpdatedAt);
query.last("LIMIT " + limit);
return sessionMapper.selectList(query);
}
private void cacheSessionStatus(String sessionId, String status) {
redisTemplate.opsForValue().set(SESSION_STATUS_KEY_PREFIX + sessionId, status, 24, TimeUnit.HOURS);
}
private void incrementMessageCount(String sessionId) {
redisTemplate.opsForValue().increment(SESSION_MESSAGE_COUNT_KEY_PREFIX + sessionId);
}
private String generateSessionId(String customerId, String kfId) {
return kfId + "_" + customerId + "_" + System.currentTimeMillis();
}
}

View File

@ -0,0 +1,87 @@
package com.wecom.robot.service;
import com.wecom.robot.config.TransferConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class TransferService {
private final TransferConfig transferConfig;
public boolean shouldTransferToManual(String message, double confidence, int messageCount, LocalDateTime sessionCreatedAt) {
if (containsKeywords(message)) {
log.info("触发转人工: 关键词匹配");
return true;
}
if (confidence < transferConfig.getConfidenceThreshold()) {
log.info("触发转人工: AI置信度过低 confidence={}", confidence);
return true;
}
if (messageCount >= transferConfig.getMaxMessageRounds()) {
log.info("触发转人工: 消息轮次过多 count={}", messageCount);
return true;
}
if (sessionCreatedAt != null) {
long duration = System.currentTimeMillis() -
sessionCreatedAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
if (duration >= transferConfig.getMaxSessionDuration()) {
log.info("触发转人工: 会话时长超限 duration={}ms", duration);
return true;
}
}
return false;
}
public String getTransferReason(String message, double confidence, int messageCount) {
List<String> keywords = transferConfig.getKeywords();
if (keywords != null) {
for (String keyword : keywords) {
if (message != null && message.contains(keyword)) {
return "关键词触发: " + keyword;
}
}
}
if (confidence < transferConfig.getConfidenceThreshold()) {
return "AI置信度过低: " + confidence;
}
if (messageCount >= transferConfig.getMaxMessageRounds()) {
return "消息轮次过多: " + messageCount;
}
return "其他原因";
}
private boolean containsKeywords(String message) {
if (message == null) {
return false;
}
List<String> keywords = transferConfig.getKeywords();
if (keywords == null) {
return false;
}
for (String keyword : keywords) {
if (message.contains(keyword)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,71 @@
package com.wecom.robot.service;
import com.wecom.robot.dto.WxCallbackMessage;
import com.wecom.robot.websocket.CsWebSocketHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebSocketService {
private final CsWebSocketHandler webSocketHandler;
public void notifyNewPendingSession(String sessionId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "new_pending_session");
message.put("sessionId", sessionId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.broadcastToAll(message);
log.info("通知新待接入会话: sessionId={}", sessionId);
}
public void notifyNewMessage(String sessionId, WxCallbackMessage wxMessage) {
Map<String, Object> message = new HashMap<>();
message.put("type", "new_message");
message.put("sessionId", sessionId);
message.put("content", wxMessage.getContent());
message.put("msgType", wxMessage.getMsgType());
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
}
public void pushMessageToCs(String sessionId, WxCallbackMessage wxMessage) {
Map<String, Object> message = new HashMap<>();
message.put("type", "customer_message");
message.put("sessionId", sessionId);
message.put("content", wxMessage.getContent());
message.put("msgType", wxMessage.getMsgType());
message.put("customerId", wxMessage.getExternalUserId());
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
log.info("推送客户消息给客服: sessionId={}", sessionId);
}
public void notifySessionAccepted(String sessionId, String csId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "session_accepted");
message.put("sessionId", sessionId);
message.put("csId", csId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToCs(csId, message);
}
public void notifySessionClosed(String sessionId) {
Map<String, Object> message = new HashMap<>();
message.put("type", "session_closed");
message.put("sessionId", sessionId);
message.put("timestamp", System.currentTimeMillis());
webSocketHandler.sendMessageToSession(sessionId, message);
}
}

View File

@ -0,0 +1,256 @@
package com.wecom.robot.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.wecom.robot.config.WecomConfig;
import com.wecom.robot.dto.ServiceStateResponse;
import com.wecom.robot.dto.SyncMsgResponse;
import com.wecom.robot.dto.WxSendMessageRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class WecomApiService {
private static final String GET_ACCESS_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpId}&corpsecret={secret}";
private static final String SEND_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={accessToken}";
private static final String GET_KF_LIST_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/account/list?access_token={accessToken}";
private static final String SYNC_MESSAGE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token={accessToken}";
private static final String GET_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/get?access_token={accessToken}";
private static final String TRANS_SERVICE_STATE_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/service_state/trans?access_token={accessToken}";
private static final String SEND_EVENT_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token={accessToken}";
private static final String REDIS_TOKEN_KEY = "wecom:access_token";
private static final String REDIS_TOKEN_LOCK_KEY = "wecom:access_token_lock";
private static final String REDIS_CURSOR_KEY_PREFIX = "wecom:cursor:";
private final WecomConfig wecomConfig;
private final StringRedisTemplate redisTemplate;
private final RestTemplate restTemplate = new RestTemplate();
public String getAccessToken() {
String cachedToken = redisTemplate.opsForValue().get(REDIS_TOKEN_KEY);
if (cachedToken != null) {
return cachedToken;
}
Boolean locked = redisTemplate.opsForValue().setIfAbsent(REDIS_TOKEN_LOCK_KEY, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
String url = GET_ACCESS_TOKEN_URL
.replace("{corpId}", wecomConfig.getCorpId())
.replace("{secret}", wecomConfig.getSecret());
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("获取access_token失败: {}", json);
throw new RuntimeException("获取access_token失败: " + json.getString("errmsg"));
}
String accessToken = json.getString("access_token");
long expiresIn = json.getLongValue("expires_in");
redisTemplate.opsForValue().set(REDIS_TOKEN_KEY, accessToken, expiresIn - 300, TimeUnit.SECONDS);
return accessToken;
} finally {
redisTemplate.delete(REDIS_TOKEN_LOCK_KEY);
}
} else {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getAccessToken();
}
}
public boolean sendMessage(WxSendMessageRequest request) {
String accessToken = getAccessToken();
String url = SEND_MESSAGE_URL.replace("{accessToken}", accessToken);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(request), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("发送消息失败: {}", json);
return false;
}
log.info("消息发送成功: msgId={}", json.getString("msgid"));
return true;
}
public boolean sendTextMessage(String touser, String openKfid, String content) {
WxSendMessageRequest request = WxSendMessageRequest.text(touser, openKfid, content);
return sendMessage(request);
}
public JSONObject getKfAccountList(int offset, int limit) {
String accessToken = getAccessToken();
String url = GET_KF_LIST_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("offset", offset);
body.put("limit", limit);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
return JSON.parseObject(response.getBody());
}
public SyncMsgResponse syncMessages(String openKfid, String token) {
String accessToken = getAccessToken();
String url = SYNC_MESSAGE_URL.replace("{accessToken}", accessToken);
String cursor = getCursor(openKfid);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
if (cursor != null && !cursor.isEmpty()) {
body.put("cursor", cursor);
}
if (token != null && !token.isEmpty()) {
body.put("token", token);
}
body.put("limit", 1000);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("sync_msg响应: {}", response.getBody());
SyncMsgResponse syncResponse = JSON.parseObject(response.getBody(), SyncMsgResponse.class);
if (syncResponse.isSuccess() && syncResponse.getNextCursor() != null) {
saveCursor(openKfid, syncResponse.getNextCursor());
}
return syncResponse;
}
public SyncMsgResponse syncMessagesByToken(String openKfid, String token) {
return syncMessages(openKfid, token);
}
public ServiceStateResponse getServiceState(String openKfid, String externalUserid) {
String accessToken = getAccessToken();
String url = GET_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
body.put("external_userid", externalUserid);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("获取会话状态响应: {}", response.getBody());
return JSON.parseObject(response.getBody(), ServiceStateResponse.class);
}
public JSONObject transServiceState(String openKfid, String externalUserid,
int serviceState, String servicerUserid) {
String accessToken = getAccessToken();
String url = TRANS_SERVICE_STATE_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("open_kfid", openKfid);
body.put("external_userid", externalUserid);
body.put("service_state", serviceState);
if (servicerUserid != null && !servicerUserid.isEmpty()) {
body.put("servicer_userid", servicerUserid);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
log.info("变更会话状态响应: {}", response.getBody());
return JSON.parseObject(response.getBody());
}
public boolean transferToPool(String openKfid, String externalUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_POOL, null);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
public boolean transferToManual(String openKfid, String externalUserid, String servicerUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_MANUAL, servicerUserid);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
public boolean endSession(String openKfid, String externalUserid) {
JSONObject result = transServiceState(openKfid, externalUserid,
ServiceStateResponse.STATE_CLOSED, null);
return result.getInteger("errcode") == null || result.getInteger("errcode") == 0;
}
public boolean sendWelcomeMsg(String code, String content) {
String accessToken = getAccessToken();
String url = SEND_EVENT_MSG_URL.replace("{accessToken}", accessToken);
JSONObject body = new JSONObject();
body.put("code", code);
body.put("msgtype", "text");
JSONObject text = new JSONObject();
text.put("content", content);
body.put("text", text);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> entity = new HttpEntity<>(body.toJSONString(), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
JSONObject json = JSON.parseObject(response.getBody());
if (json.getInteger("errcode") != null && json.getInteger("errcode") != 0) {
log.error("发送欢迎语失败: {}", json);
return false;
}
log.info("发送欢迎语成功");
return true;
}
private String getCursor(String openKfid) {
return redisTemplate.opsForValue().get(REDIS_CURSOR_KEY_PREFIX + openKfid);
}
private void saveCursor(String openKfid, String cursor) {
redisTemplate.opsForValue().set(REDIS_CURSOR_KEY_PREFIX + openKfid, cursor);
}
public void clearCursor(String openKfid) {
redisTemplate.delete(REDIS_CURSOR_KEY_PREFIX + openKfid);
}
}

View File

@ -0,0 +1,50 @@
package com.wecom.robot.util;
@SuppressWarnings("serial")
public class AesException extends Exception {
public final static int OK = 0;
public final static int ValidateSignatureError = -40001;
public final static int ParseXmlError = -40002;
public final static int ComputeSignatureError = -40003;
public final static int IllegalAesKey = -40004;
public final static int ValidateCorpidError = -40005;
public final static int EncryptAESError = -40006;
public final static int DecryptAESError = -40007;
public final static int IllegalBuffer = -40008;
private int code;
private static String getMessage(int code) {
switch (code) {
case ValidateSignatureError:
return "签名验证错误";
case ParseXmlError:
return "xml解析失败";
case ComputeSignatureError:
return "sha加密生成签名失败";
case IllegalAesKey:
return "SymmetricKey非法";
case ValidateCorpidError:
return "corpid校验失败";
case EncryptAESError:
return "aes加密失败";
case DecryptAESError:
return "aes解密失败";
case IllegalBuffer:
return "解密后得到的buffer非法";
default:
return null;
}
}
public int getCode() {
return code;
}
AesException(int code) {
super(getMessage(code));
this.code = code;
}
}

View File

@ -0,0 +1,26 @@
package com.wecom.robot.util;
import java.util.ArrayList;
class ByteGroup {
ArrayList<Byte> byteContainer = new ArrayList<Byte>();
public byte[] toBytes() {
byte[] bytes = new byte[byteContainer.size()];
for (int i = 0; i < byteContainer.size(); i++) {
bytes[i] = byteContainer.get(i);
}
return bytes;
}
public ByteGroup addBytes(byte[] bytes) {
for (byte b : bytes) {
byteContainer.add(b);
}
return this;
}
public int size() {
return byteContainer.size();
}
}

View File

@ -0,0 +1,67 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.nio.charset.Charset;
import java.util.Arrays;
/**
* 提供基于PKCS7算法的加解密接口.
*/
class PKCS7Encoder {
static Charset CHARSET = Charset.forName("utf-8");
static int BLOCK_SIZE = 32;
/**
* 获得对明文进行补位填充的字节.
*
* @param count 需要进行填充补位操作的明文字节个数
* @return 补齐用的字节数组
*/
static byte[] encode(int count) {
// 计算需要填充的位数
int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE);
if (amountToPad == 0) {
amountToPad = BLOCK_SIZE;
}
// 获得补位所用的字符
char padChr = chr(amountToPad);
String tmp = new String();
for (int index = 0; index < amountToPad; index++) {
tmp += padChr;
}
return tmp.getBytes(CHARSET);
}
/**
* 删除解密后明文的补位字符
*
* @param decrypted 解密后的明文
* @return 删除补位字符后的明文
*/
static byte[] decode(byte[] decrypted) {
int pad = (int) decrypted[decrypted.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
}
/**
* 将数字转化成ASCII码对应的字符用于对明文进行补码
*
* @param a 需要转化的数字
* @return 转化得到的字符
*/
static char chr(int a) {
byte target = (byte) (a & 0xFF);
return (char) target;
}
}

View File

@ -0,0 +1,61 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.security.MessageDigest;
import java.util.Arrays;
/**
* SHA1 class
*
* 计算消息签名接口.
*/
class SHA1 {
/**
* 用SHA1算法生成安全签名
* @param token 票据
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param encrypt 密文
* @return 安全签名
* @throws AesException
*/
public static String getSHA1(String token, String timestamp, String nonce, String encrypt) throws AesException
{
try {
String[] array = new String[] { token, timestamp, nonce, encrypt };
StringBuffer sb = new StringBuffer();
// 字符串排序
Arrays.sort(array);
for (int i = 0; i < 4; i++) {
sb.append(array[i]);
}
String str = sb.toString();
// SHA1签名生成
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(str.getBytes());
byte[] digest = md.digest();
StringBuffer hexstr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexstr.append(0);
}
hexstr.append(shaHex);
}
return hexstr.toString();
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ComputeSignatureError);
}
}
}

View File

@ -0,0 +1,70 @@
package com.wecom.robot.util;
import java.io.StringReader;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import com.wecom.robot.util.WXBizMsgCrypt;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilderFactory;
public class Sample {
public static void main(String[] args) throws Exception {
String sToken = "QDG6eK";
String sCorpID = "wx5823bf96d3bd56c7";
String sEncodingAESKey = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C";
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(sToken, sEncodingAESKey, sCorpID);
String sVerifyMsgSig = "5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3";
String sVerifyTimeStamp = "1409659589";
String sVerifyNonce = "263014780";
String sVerifyEchoStr = "P9nAzCzyDtyTWESHep1vC5X9xho/qYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp+4RPcs8TgAE7OaBO+FZXvnaqQ==";
String sEchoStr;
try {
sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,
sVerifyNonce, sVerifyEchoStr);
System.out.println("verifyurl echostr: " + sEchoStr);
} catch (Exception e) {
e.printStackTrace();
}
String sReqMsgSig = "477715d11cdb4164915debcba66cb864d751f3e6";
String sReqTimeStamp = "1409659813";
String sReqNonce = "1372623149";
String sReqData = "<xml><ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName><Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt><AgentID><![CDATA[218]]></AgentID></xml>";
try {
String sMsg = wxcpt.DecryptMsg(sReqMsgSig, sReqTimeStamp, sReqNonce, sReqData);
System.out.println("after decrypt msg: " + sMsg);
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(sMsg);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Content");
String Content = nodelist1.item(0).getTextContent();
System.out.println("Content" + Content);
} catch (Exception e) {
e.printStackTrace();
}
String sRespData = "<xml><ToUserName><![CDATA[mycreate]]></ToUserName><FromUserName><![CDATA[wx5823bf96d3bd56c7]]></FromUserName><CreateTime>1348831860</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[this is a test]]></Content><MsgId>1234567890123456</MsgId><AgentID>128</AgentID></xml>";
try{
String sEncryptMsg = wxcpt.EncryptMsg(sRespData, sReqTimeStamp, sReqNonce);
System.out.println("after encrypt sEncrytMsg: " + sEncryptMsg);
}
catch(Exception e)
{
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,289 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
/**
* 针对org.apache.commons.codec.binary.Base64
* 需要导入架包commons-codec-1.9或commons-codec-1.8等其他版本
* 官方下载地址http://commons.apache.org/proper/commons-codec/download_codec.cgi
*/
package com.wecom.robot.util;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Random;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
/**
* 提供接收和推送给企业微信消息的加解密接口(UTF8编码的字符串).
* <ol>
* <li>第三方回复加密消息给企业微信</li>
* <li>第三方收到企业微信发送的消息验证消息的安全性并对消息进行解密</li>
* </ol>
* 说明异常java.security.InvalidKeyException:illegal Key Size的解决方案
* <ol>
* <li>在官方网站下载JCE无限制权限策略文件JDK7的下载地址
* http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html</li>
* <li>下载后解压可以看到local_policy.jar和US_export_policy.jar以及readme.txt</li>
* <li>如果安装了JRE将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件</li>
* <li>如果安装了JDK将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件</li>
* </ol>
*/
public class WXBizMsgCrypt {
static Charset CHARSET = Charset.forName("utf-8");
Base64 base64 = new Base64();
byte[] aesKey;
String token;
String receiveid;
/**
* 构造函数
* @param token 企业微信后台开发者设置的token
* @param encodingAesKey 企业微信后台开发者设置的EncodingAESKey
* @param receiveid, 不同场景含义不同详见文档
*
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public WXBizMsgCrypt(String token, String encodingAesKey, String receiveid) throws AesException {
if (encodingAesKey.length() != 43) {
throw new AesException(AesException.IllegalAesKey);
}
this.token = token;
this.receiveid = receiveid;
aesKey = Base64.decodeBase64(encodingAesKey + "=");
}
// 生成4个字节的网络字节序
byte[] getNetworkBytesOrder(int sourceNumber) {
byte[] orderBytes = new byte[4];
orderBytes[3] = (byte) (sourceNumber & 0xFF);
orderBytes[2] = (byte) (sourceNumber >> 8 & 0xFF);
orderBytes[1] = (byte) (sourceNumber >> 16 & 0xFF);
orderBytes[0] = (byte) (sourceNumber >> 24 & 0xFF);
return orderBytes;
}
// 还原4个字节的网络字节序
int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
// 随机生成16位字符串
String getRandomStr() {
String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
Random random = new Random();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 16; i++) {
int number = random.nextInt(base.length());
sb.append(base.charAt(number));
}
return sb.toString();
}
/**
* 对明文进行加密.
*
* @param text 需要加密的明文
* @return 加密后base64编码的字符串
* @throws AesException aes加密失败
*/
String encrypt(String randomStr, String text) throws AesException {
ByteGroup byteCollector = new ByteGroup();
byte[] randomStrBytes = randomStr.getBytes(CHARSET);
byte[] textBytes = text.getBytes(CHARSET);
byte[] networkBytesOrder = getNetworkBytesOrder(textBytes.length);
byte[] receiveidBytes = receiveid.getBytes(CHARSET);
// randomStr + networkBytesOrder + text + receiveid
byteCollector.addBytes(randomStrBytes);
byteCollector.addBytes(networkBytesOrder);
byteCollector.addBytes(textBytes);
byteCollector.addBytes(receiveidBytes);
// ... + pad: 使用自定义的填充方式对明文进行补位填充
byte[] padBytes = PKCS7Encoder.encode(byteCollector.size());
byteCollector.addBytes(padBytes);
// 获得最终的字节流, 未加密
byte[] unencrypted = byteCollector.toBytes();
try {
// 设置加密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
// 加密
byte[] encrypted = cipher.doFinal(unencrypted);
// 使用BASE64对加密后的字符串进行编码
String base64Encrypted = base64.encodeToString(encrypted);
return base64Encrypted;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.EncryptAESError);
}
}
/**
* 对密文进行解密.
*
* @param text 需要解密的密文
* @return 解密得到的明文
* @throws AesException aes解密失败
*/
String decrypt(String text) throws AesException {
byte[] original;
try {
// 设置解密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);
// 使用BASE64对密文进行解码
byte[] encrypted = Base64.decodeBase64(text);
// 解密
original = cipher.doFinal(encrypted);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.DecryptAESError);
}
String xmlContent, from_receiveid;
try {
// 去除补位字符
byte[] bytes = PKCS7Encoder.decode(original);
// 分离16位随机字符串,网络字节序和receiveid
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
from_receiveid = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length),
CHARSET);
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.IllegalBuffer);
}
// receiveid不相同的情况
if (!from_receiveid.equals(receiveid)) {
throw new AesException(AesException.ValidateCorpidError);
}
return xmlContent;
}
/**
* 将企业微信回复用户的消息加密打包.
* <ol>
* <li>对要发送的消息进行AES-CBC加密</li>
* <li>生成安全签名</li>
* <li>将消息密文和安全签名打包成xml格式</li>
* </ol>
*
* @param replyMsg 企业微信待回复用户的消息xml格式的字符串
* @param timeStamp 时间戳可以自己生成也可以用URL参数的timestamp
* @param nonce 随机串可以自己生成也可以用URL参数的nonce
*
* @return 加密后的可以直接回复用户的密文包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String EncryptMsg(String replyMsg, String timeStamp, String nonce) throws AesException {
// 加密
String encrypt = encrypt(getRandomStr(), replyMsg);
// 生成安全签名
if (timeStamp == "") {
timeStamp = Long.toString(System.currentTimeMillis());
}
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt);
// System.out.println("发送给平台的签名是: " + signature[1].toString());
// 生成发送的xml
String result = XMLParse.generate(encrypt, signature, timeStamp, nonce);
return result;
}
/**
* 检验消息的真实性并且获取解密后的明文.
* <ol>
* <li>利用收到的密文生成安全签名进行签名验证</li>
* <li>若验证通过则提取xml中的加密消息</li>
* <li>对消息进行解密</li>
* </ol>
*
* @param msgSignature 签名串对应URL参数的msg_signature
* @param timeStamp 时间戳对应URL参数的timestamp
* @param nonce 随机串对应URL参数的nonce
* @param postData 密文对应POST请求的数据
*
* @return 解密后的原文
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String DecryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws AesException {
// 密钥公众账号的app secret
// 提取密文
Object[] encrypt = XMLParse.extract(postData);
// 验证安全签名
String signature = SHA1.getSHA1(token, timeStamp, nonce, encrypt[1].toString());
// 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名" + msg_sign);
// System.out.println("第三方校验签名:" + signature);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
// 解密
String result = decrypt(encrypt[1].toString());
return result;
}
/**
* 验证URL
* @param msgSignature 签名串对应URL参数的msg_signature
* @param timeStamp 时间戳对应URL参数的timestamp
* @param nonce 随机串对应URL参数的nonce
* @param echoStr 随机串对应URL参数的echostr
*
* @return 解密之后的echostr
* @throws AesException 执行失败请查看该异常的错误码和具体的错误信息
*/
public String VerifyURL(String msgSignature, String timeStamp, String nonce, String echoStr)
throws AesException {
String signature = SHA1.getSHA1(token, timeStamp, nonce, echoStr);
if (!signature.equals(msgSignature)) {
throw new AesException(AesException.ValidateSignatureError);
}
String result = decrypt(echoStr);
return result;
}
}

View File

@ -0,0 +1,104 @@
/**
* 对企业微信发送给企业后台的消息加解密示例代码.
*
* @copyright Copyright (c) 1998-2014 Tencent Inc.
*/
// ------------------------------------------------------------------------
package com.wecom.robot.util;
import java.io.StringReader;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
/**
* XMLParse class
*
* 提供提取消息格式中的密文及生成回复消息格式的接口.
*/
class XMLParse {
/**
* 提取出xml数据包中的加密消息
* @param xmltext 待提取的xml字符串
* @return 提取出的加密消息字符串
* @throws AesException
*/
public static Object[] extract(String xmltext) throws AesException {
Object[] result = new Object[3];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
String FEATURE = null;
// This is the PRIMARY defense. If DTDs (doctypes) are disallowed, almost all XML entity attacks are prevented
// Xerces 2 only - http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl
FEATURE = "http://apache.org/xml/features/disallow-doctype-decl";
dbf.setFeature(FEATURE, true);
// If you can't completely disable DTDs, then at least do the following:
// Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-general-entities
// Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-general-entities
// JDK7+ - http://xml.org/sax/features/external-general-entities
FEATURE = "http://xml.org/sax/features/external-general-entities";
dbf.setFeature(FEATURE, false);
// Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-parameter-entities
// Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities
// JDK7+ - http://xml.org/sax/features/external-parameter-entities
FEATURE = "http://xml.org/sax/features/external-parameter-entities";
dbf.setFeature(FEATURE, false);
// Disable external DTDs as well
FEATURE = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
dbf.setFeature(FEATURE, false);
// and these as well, per Timothy Morgan's 2014 paper: "XML Schema, DTD, and Entity Attacks"
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
// And, per Timothy Morgan: "If for some reason support for inline DOCTYPEs are a requirement, then
// ensure the entity settings are disabled (as shown above) and beware that SSRF attacks
// (http://cwe.mitre.org/data/definitions/918.html) and denial
// of service attacks (such as billion laughs or decompression bombs via "jar:") are a risk."
// remaining parser logic
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(xmltext);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
return result;
} catch (Exception e) {
e.printStackTrace();
throw new AesException(AesException.ParseXmlError);
}
}
/**
* 生成xml消息
* @param encrypt 加密后的消息密文
* @param signature 安全签名
* @param timestamp 时间戳
* @param nonce 随机字符串
* @return 生成的xml字符串
*/
public static String generate(String encrypt, String signature, String timestamp, String nonce) {
String format = "<xml>\n" + "<Encrypt><![CDATA[%1$s]]></Encrypt>\n"
+ "<MsgSignature><![CDATA[%2$s]]></MsgSignature>\n"
+ "<TimeStamp>%3$s</TimeStamp>\n" + "<Nonce><![CDATA[%4$s]]></Nonce>\n" + "</xml>";
return String.format(format, encrypt, signature, timestamp, nonce);
}
}

View File

@ -0,0 +1,65 @@
package com.wecom.robot.util;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Map;
public class XmlUtil {
public static Map<String, String> parseXml(String xmlStr) {
Map<String, String> map = new HashMap<>();
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new ByteArrayInputStream(xmlStr.getBytes("UTF-8")));
Element root = document.getDocumentElement();
NodeList nodeList = root.getChildNodes();
for (int i = 0; i < nodeList.getLength(); i++) {
org.w3c.dom.Node node = nodeList.item(i);
if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
map.put(node.getNodeName(), node.getTextContent());
}
}
} catch (Exception e) {
throw new RuntimeException("XML解析失败", e);
}
return map;
}
public static String mapToXml(Map<String, String> map) {
StringBuilder sb = new StringBuilder();
sb.append("<xml>");
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (isNumeric(value)) {
sb.append("<").append(key).append(">").append(value).append("</").append(key).append(">");
} else {
sb.append("<").append(key).append("><![CDATA[").append(value).append("]]></").append(key).append(">");
}
}
sb.append("</xml>");
return sb.toString();
}
private static boolean isNumeric(String str) {
if (str == null || str.isEmpty()) {
return false;
}
for (char c : str.toCharArray()) {
if (!Character.isDigit(c)) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,115 @@
package com.wecom.robot.websocket;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Component
public class CsWebSocketHandler implements WebSocketHandler {
private static final Map<String, WebSocketSession> csSessions = new ConcurrentHashMap<>();
private static final Map<String, String> sessionToCsMap = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String csId = extractCsId(session);
if (csId != null) {
csSessions.put(csId, session);
log.info("客服WebSocket连接建立: csId={}, sessionId={}", csId, session.getId());
}
}
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
if (message instanceof TextMessage) {
String payload = ((TextMessage) message).getPayload();
log.debug("收到WebSocket消息: {}", payload);
Map<String, Object> msgMap = JSON.parseObject(payload, Map.class);
String type = (String) msgMap.get("type");
if ("bind_session".equals(type)) {
String sessionId = (String) msgMap.get("sessionId");
String csId = extractCsId(session);
if (sessionId != null && csId != null) {
sessionToCsMap.put(sessionId, csId);
log.info("绑定会话: sessionId={}, csId={}", sessionId, csId);
}
}
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("WebSocket传输错误: sessionId={}", session.getId(), exception);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String csId = extractCsId(session);
if (csId != null) {
csSessions.remove(csId);
sessionToCsMap.entrySet().removeIf(entry -> csId.equals(entry.getValue()));
log.info("客服WebSocket连接关闭: csId={}, status={}", csId, status);
}
}
@Override
public boolean supportsPartialMessages() {
return false;
}
public void sendMessageToCs(String csId, Object message) {
WebSocketSession session = csSessions.get(csId);
if (session != null && session.isOpen()) {
try {
String json = JSON.toJSONString(message);
session.sendMessage(new TextMessage(json));
log.debug("发送消息给客服: csId={}, message={}", csId, json);
} catch (IOException e) {
log.error("发送WebSocket消息失败: csId={}", csId, e);
}
} else {
log.warn("客服不在线: csId={}", csId);
}
}
public void broadcastToAll(Object message) {
String json = JSON.toJSONString(message);
TextMessage textMessage = new TextMessage(json);
csSessions.values().forEach(session -> {
if (session.isOpen()) {
try {
session.sendMessage(textMessage);
} catch (IOException e) {
log.error("广播消息失败: sessionId={}", session.getId(), e);
}
}
});
}
public void sendMessageToSession(String sessionId, Object message) {
String csId = sessionToCsMap.get(sessionId);
if (csId != null) {
sendMessageToCs(csId, message);
} else {
log.warn("会话未绑定客服: sessionId={}", sessionId);
}
}
private String extractCsId(WebSocketSession session) {
String path = session.getUri().getPath();
String[] parts = path.split("/");
if (parts.length >= 4) {
return parts[3];
}
return null;
}
}

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 10000
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: debug
org.springframework.web: info

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://host.docker.internal:3316/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: host.docker.internal
port: 6379
password: jiong1114
database: 0
timeout: 10000
lettuce:
pool:
max-active: 16
max-wait: -1
max-idle: 8
min-idle: 2
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: info
org.springframework.web: warn

View File

@ -0,0 +1,30 @@
server:
port: 8080
spring:
application:
name: wecom-robot
profiles:
active: dev
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.wecom.robot.entity
configuration:
map-underscore-to-camel-case: true
wecom:
kf:
callback-url: /wecom/callback
transfer:
keywords:
- 人工
- 转人工
- 投诉
- 客服
- 人工客服
confidence-threshold: 0.6
max-fail-rounds: 3
max-session-duration: 1800000
max-message-rounds: 50

View File

@ -0,0 +1,77 @@
-- 创建数据库
CREATE DATABASE IF NOT EXISTS wecom_robot DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE wecom_robot;
-- 会话表
CREATE TABLE IF NOT EXISTS `session` (
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`customer_id` VARCHAR(64) NOT NULL COMMENT '客户ID (external_userid)',
`kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID (open_kfid)',
`status` VARCHAR(20) NOT NULL DEFAULT 'AI' COMMENT '状态: AI/PENDING/MANUAL/CLOSED',
`wx_service_state` INT DEFAULT 0 COMMENT '微信会话状态: 0-未处理/1-智能助手/2-待接入池/3-人工接待/4-已结束',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`manual_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '人工客服ID',
`metadata` TEXT DEFAULT NULL COMMENT '扩展信息JSON',
PRIMARY KEY (`session_id`),
INDEX `idx_customer_id` (`customer_id`),
INDEX `idx_kf_id` (`kf_id`),
INDEX `idx_status` (`status`),
INDEX `idx_updated_at` (`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会话表';
-- 消息表
CREATE TABLE IF NOT EXISTS `message` (
`msg_id` VARCHAR(128) NOT NULL COMMENT '消息ID',
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`sender_type` VARCHAR(20) NOT NULL COMMENT '发送者类型: customer/ai/manual',
`sender_id` VARCHAR(64) NOT NULL COMMENT '发送者标识',
`content` TEXT NOT NULL COMMENT '消息内容',
`msg_type` VARCHAR(20) NOT NULL DEFAULT 'text' COMMENT '消息类型: text/image/link等',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`raw_data` TEXT DEFAULT NULL COMMENT '原始消息数据JSON',
PRIMARY KEY (`msg_id`),
INDEX `idx_session_id` (`session_id`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
-- 客服账号表
CREATE TABLE IF NOT EXISTS `kf_account` (
`kf_id` VARCHAR(64) NOT NULL COMMENT '客服账号ID',
`name` VARCHAR(100) DEFAULT NULL COMMENT '客服昵称',
`avatar` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`status` VARCHAR(20) NOT NULL DEFAULT 'offline' COMMENT '状态: online/offline',
`bind_manual_id` VARCHAR(64) DEFAULT NULL COMMENT '绑定的企业微信员工ID',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`kf_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客服账号表';
-- 转人工记录表
CREATE TABLE IF NOT EXISTS `transfer_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`session_id` VARCHAR(128) NOT NULL COMMENT '会话ID',
`trigger_reason` VARCHAR(200) DEFAULT NULL COMMENT '触发原因',
`trigger_time` DATETIME NOT NULL COMMENT '触发时间',
`accepted_time` DATETIME DEFAULT NULL COMMENT '客服接入时间',
`accepted_cs_id` VARCHAR(64) DEFAULT NULL COMMENT '接入的客服ID',
PRIMARY KEY (`id`),
INDEX `idx_session_id` (`session_id`),
INDEX `idx_trigger_time` (`trigger_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转人工记录表';
-- 快捷回复表 (可选)
CREATE TABLE IF NOT EXISTS `quick_reply` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`cs_id` VARCHAR(64) DEFAULT NULL COMMENT '客服ID为空表示公共',
`category` VARCHAR(50) DEFAULT NULL COMMENT '分类',
`content` VARCHAR(500) NOT NULL COMMENT '回复内容',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
INDEX `idx_cs_id` (`cs_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快捷回复表';
-- 如果表已存在,添加新字段
ALTER TABLE `session` ADD COLUMN IF NOT EXISTS `wx_service_state` INT DEFAULT 0 COMMENT '微信会话状态';

View File

@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>聊天记录查询</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f5f5;
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #1890ff;
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
margin-bottom: 8px;
}
.header p {
opacity: 0.8;
font-size: 14px;
}
.filters {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-weight: 500;
color: #333;
}
select, input {
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
min-width: 200px;
}
select:focus, input:focus {
outline: none;
border-color: #1890ff;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #1890ff;
color: white;
}
.btn-primary:hover {
background: #40a9ff;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #d9d9d9;
}
.main-content {
display: grid;
grid-template-columns: 350px 1fr;
gap: 20px;
}
.session-list {
background: white;
border-radius: 8px;
overflow: hidden;
}
.session-list-header {
padding: 15px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
}
.session-item {
padding: 15px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
}
.session-item:hover {
background: #f5f5f5;
}
.session-item.active {
background: #e6f7ff;
border-left: 3px solid #1890ff;
}
.session-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.session-customer-id {
font-weight: 500;
color: #333;
font-size: 14px;
}
.session-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.status-AI { background: #e6f7ff; color: #1890ff; }
.status-PENDING { background: #fff7e6; color: #fa8c16; }
.status-MANUAL { background: #f6ffed; color: #52c41a; }
.status-CLOSED { background: #f5f5f5; color: #999; }
.session-meta {
font-size: 12px;
color: #999;
}
.chat-panel {
background: white;
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 600px;
}
.chat-header {
padding: 15px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header-info {
font-size: 14px;
color: #666;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #fafafa;
}
.message {
margin-bottom: 16px;
display: flex;
flex-direction: column;
}
.message.customer {
align-items: flex-start;
}
.message.ai, .message.manual {
align-items: flex-end;
}
.message-sender {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.message-content {
max-width: 70%;
padding: 10px 15px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.message.customer .message-content {
background: white;
border: 1px solid #e8e8e8;
}
.message.ai .message-content {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.message.manual .message-content {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.message-time {
font-size: 11px;
color: #bbb;
margin-top: 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 16px;
opacity: 0.5;
}
.loading {
text-align: center;
padding: 20px;
color: #999;
}
.no-sessions {
text-align: center;
padding: 40px;
color: #999;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>聊天记录查询</h1>
<p>查看各客服账号的历史聊天记录</p>
</div>
<div class="filters">
<div class="filter-group">
<label>客服账号:</label>
<select id="kfAccountSelect">
<option value="">请选择客服账号</option>
</select>
</div>
<div class="filter-group">
<label>会话状态:</label>
<select id="statusSelect">
<option value="all">全部</option>
<option value="AI">AI接待中</option>
<option value="PENDING">待接入</option>
<option value="MANUAL">人工接待中</option>
<option value="CLOSED">已结束</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadSessions()">查询会话</button>
<button class="btn btn-secondary" onclick="refreshKfAccounts()">刷新账号</button>
</div>
<div class="main-content">
<div class="session-list">
<div class="session-list-header">
会话列表 (<span id="sessionCount">0</span>)
</div>
<div id="sessionListContainer">
<div class="no-sessions">请选择客服账号并查询</div>
</div>
</div>
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-info" id="chatHeaderInfo">请选择会话查看聊天记录</div>
</div>
<div class="chat-messages" id="chatMessagesContainer">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
<p>选择左侧会话查看聊天记录</p>
</div>
</div>
</div>
</div>
</div>
<script>
let currentSessionId = null;
async function refreshKfAccounts() {
try {
const response = await fetch('/chat-history/api/kf-accounts');
const result = await response.json();
if (result.code === 0) {
const select = document.getElementById('kfAccountSelect');
select.innerHTML = '<option value="">请选择客服账号</option>';
result.data.forEach(account => {
const option = document.createElement('option');
option.value = account.openKfId;
option.textContent = account.name || account.openKfId;
select.appendChild(option);
});
if (result.data.length === 0) {
alert('未获取到客服账号,请检查配置');
}
} else {
alert('获取客服账号失败: ' + result.message);
}
} catch (error) {
console.error('获取客服账号失败:', error);
alert('获取客服账号失败,请检查网络连接');
}
}
async function loadSessions() {
const kfId = document.getElementById('kfAccountSelect').value;
const status = document.getElementById('statusSelect').value;
if (!kfId) {
alert('请选择客服账号');
return;
}
const container = document.getElementById('sessionListContainer');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await fetch(`/chat-history/api/sessions?openKfId=${encodeURIComponent(kfId)}&status=${status}`);
const result = await response.json();
if (result.code === 0) {
document.getElementById('sessionCount').textContent = result.data.length;
renderSessionList(result.data);
} else {
container.innerHTML = `<div class="no-sessions">查询失败: ${result.message}</div>`;
}
} catch (error) {
console.error('加载会话列表失败:', error);
container.innerHTML = '<div class="no-sessions">加载失败,请重试</div>';
}
}
function renderSessionList(sessions) {
const container = document.getElementById('sessionListContainer');
if (sessions.length === 0) {
container.innerHTML = '<div class="no-sessions">暂无会话记录</div>';
return;
}
container.innerHTML = sessions.map(session => `
<div class="session-item" data-session-id="${session.sessionId}" onclick="selectSession('${session.sessionId}')">
<div class="session-item-header">
<span class="session-customer-id">${session.customerId.substring(0, 15)}...</span>
<span class="session-status status-${session.status}">${getStatusText(session.status)}</span>
</div>
<div class="session-meta">
消息: ${session.messageCount} 条 | ${formatTime(session.updatedAt)}
</div>
</div>
`).join('');
}
function getStatusText(status) {
const map = {
'AI': 'AI接待',
'PENDING': '待接入',
'MANUAL': '人工接待',
'CLOSED': '已结束'
};
return map[status] || status;
}
function formatTime(timeStr) {
if (!timeStr) return '-';
const date = new Date(timeStr);
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
}
async function selectSession(sessionId) {
currentSessionId = sessionId;
document.querySelectorAll('.session-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-session-id="${sessionId}"]`).classList.add('active');
const container = document.getElementById('chatMessagesContainer');
container.innerHTML = '<div class="loading">加载中...</div>';
try {
const response = await fetch(`/chat-history/api/messages?sessionId=${encodeURIComponent(sessionId)}`);
const result = await response.json();
if (result.code === 0) {
document.getElementById('chatHeaderInfo').textContent =
`会话ID: ${sessionId} | 消息数: ${result.data.length}`;
renderMessages(result.data);
} else {
container.innerHTML = `<div class="no-sessions">加载失败: ${result.message}</div>`;
}
} catch (error) {
console.error('加载消息失败:', error);
container.innerHTML = '<div class="no-sessions">加载失败,请重试</div>';
}
}
function renderMessages(messages) {
const container = document.getElementById('chatMessagesContainer');
if (messages.length === 0) {
container.innerHTML = '<div class="empty-state"><p>暂无消息记录</p></div>';
return;
}
container.innerHTML = messages.map(msg => {
const senderName = getSenderName(msg.senderType, msg.senderId);
return `
<div class="message ${msg.senderType}">
<div class="message-sender">${senderName}</div>
<div class="message-content">${escapeHtml(msg.content)}</div>
<div class="message-time">${formatTime(msg.createdAt)}</div>
</div>
`;
}).join('');
container.scrollTop = container.scrollHeight;
}
function getSenderName(senderType, senderId) {
switch (senderType) {
case 'customer': return '客户';
case 'ai': return 'AI助手';
case 'manual': return `客服(${senderId || '未知'})`;
default: return senderType;
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\n/g, '<br>');
}
document.addEventListener('DOMContentLoaded', function() {
refreshKfAccounts();
});
</script>
</body>
</html>

View File

@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客户模拟端</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.phone-frame {
width: 375px;
height: 700px;
background: #fff;
border-radius: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
overflow: hidden;
display: flex;
flex-direction: column;
border: 8px solid #333;
}
.phone-header {
background: #ededed;
padding: 15px;
text-align: center;
border-bottom: 1px solid #d9d9d9;
position: relative;
}
.phone-header .status-bar {
position: absolute;
top: 5px;
left: 15px;
right: 15px;
display: flex;
justify-content: space-between;
font-size: 11px;
color: #333;
}
.phone-header .title {
margin-top: 15px;
font-size: 16px;
font-weight: 500;
}
.phone-header .subtitle {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.chat-area {
flex: 1;
overflow-y: auto;
padding: 15px;
background: #f5f5f5;
}
.message {
margin-bottom: 15px;
display: flex;
flex-direction: column;
}
.message.sent {
align-items: flex-end;
}
.message.received {
align-items: flex-start;
}
.message-bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
position: relative;
}
.message.sent .message-bubble {
background: #95ec69;
border-radius: 8px 0 8px 8px;
}
.message.received .message-bubble {
background: #fff;
border-radius: 0 8px 8px 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.message-time {
font-size: 10px;
color: #999;
margin-top: 4px;
}
.sender-name {
font-size: 11px;
color: #999;
margin-bottom: 3px;
}
.typing-indicator {
display: none;
align-items: center;
padding: 10px 14px;
background: #fff;
border-radius: 0 8px 8px 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
width: fit-content;
}
.typing-indicator.show {
display: flex;
}
.typing-indicator span {
width: 6px;
height: 6px;
background: #999;
border-radius: 50%;
margin: 0 2px;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
.input-area {
background: #f7f7f7;
padding: 10px;
border-top: 1px solid #e0e0e0;
}
.input-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.input-row textarea {
flex: 1;
padding: 10px;
border: none;
border-radius: 20px;
background: #fff;
resize: none;
height: 40px;
max-height: 100px;
font-size: 14px;
outline: none;
}
.input-row button {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #07c160;
color: white;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.input-row button:hover {
background: #06ad56;
}
.input-row button:disabled {
background: #ccc;
}
.quick-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.quick-action {
padding: 6px 12px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 15px;
font-size: 12px;
cursor: pointer;
}
.quick-action:hover {
background: #f5f5f5;
}
.settings-panel {
position: absolute;
top: 10px;
right: 10px;
background: #fff;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
z-index: 100;
display: none;
}
.settings-panel.show {
display: block;
}
.settings-panel h4 {
margin-bottom: 10px;
font-size: 14px;
}
.settings-panel input {
width: 100%;
padding: 8px;
margin-bottom: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 12px;
}
.settings-panel button {
width: 100%;
padding: 8px;
background: #07c160;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.settings-btn {
position: absolute;
top: 10px;
right: 10px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 14px;
}
.transfer-notice {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 10px;
margin: 10px 0;
font-size: 12px;
color: #856404;
text-align: center;
}
.system-message {
text-align: center;
font-size: 11px;
color: #999;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="phone-frame">
<div class="phone-header">
<div class="status-bar">
<span id="currentTime">12:00</span>
<span>📶 🔋</span>
</div>
<div class="title">智能客服</div>
<div class="subtitle" id="statusText">AI在线</div>
</div>
<div class="chat-area" id="chatArea">
<div class="system-message">会话已开始</div>
<div class="message received">
<div class="sender-name">客服</div>
<div class="message-bubble">您好!我是智能客服,有什么可以帮您的吗?</div>
<div class="message-time">刚刚</div>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="messageInput" placeholder="输入消息..." rows="1"></textarea>
<button onclick="sendMessage()" id="sendBtn"></button>
</div>
<div class="quick-actions">
<button class="quick-action" onclick="quickSend('你好')">你好</button>
<button class="quick-action" onclick="quickSend('转人工')">转人工</button>
<button class="quick-action" onclick="quickSend('投诉')">投诉</button>
</div>
</div>
<button class="settings-btn" onclick="toggleSettings()">⚙️</button>
<div class="settings-panel" id="settingsPanel">
<h4>测试设置</h4>
<input type="text" id="customerId" placeholder="客户ID" value="customer_001">
<input type="text" id="kfId" placeholder="客服账号ID" value="kf_001">
<button onclick="saveSettings()">保存设置</button>
<button onclick="clearChat()" style="margin-top: 8px; background: #ff4d4f;">清空聊天</button>
</div>
</div>
<script>
const baseUrl = window.location.origin;
let customerId = 'customer_001';
let kfId = 'kf_001';
let sessionStatus = 'AI';
function updateTime() {
const now = new Date();
document.getElementById('currentTime').textContent =
now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
setInterval(updateTime, 1000);
updateTime();
function toggleSettings() {
document.getElementById('settingsPanel').classList.toggle('show');
}
function saveSettings() {
customerId = document.getElementById('customerId').value || 'customer_001';
kfId = document.getElementById('kfId').value || 'kf_001';
toggleSettings();
addSystemMessage('设置已更新');
}
function clearChat() {
document.getElementById('chatArea').innerHTML = '<div class="system-message">会话已重置</div>';
sessionStatus = 'AI';
document.getElementById('statusText').textContent = 'AI在线';
toggleSettings();
}
function addSystemMessage(text) {
const chatArea = document.getElementById('chatArea');
const msg = document.createElement('div');
msg.className = 'system-message';
msg.textContent = text;
chatArea.appendChild(msg);
chatArea.scrollTop = chatArea.scrollHeight;
}
function addMessage(content, isSent, senderName = '') {
const chatArea = document.getElementById('chatArea');
const msg = document.createElement('div');
msg.className = 'message ' + (isSent ? 'sent' : 'received');
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
msg.innerHTML = `
${senderName ? '<div class="sender-name">' + senderName + '</div>' : ''}
<div class="message-bubble">${content}</div>
<div class="message-time">${time}</div>
`;
chatArea.appendChild(msg);
chatArea.scrollTop = chatArea.scrollHeight;
}
function showTyping() {
const chatArea = document.getElementById('chatArea');
const typing = document.createElement('div');
typing.className = 'message received';
typing.id = 'typingIndicator';
typing.innerHTML = `
<div class="typing-indicator show">
<span></span><span></span><span></span>
</div>
`;
chatArea.appendChild(typing);
chatArea.scrollTop = chatArea.scrollHeight;
}
function hideTyping() {
const typing = document.getElementById('typingIndicator');
if (typing) typing.remove();
}
async function sendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content) return;
addMessage(content, true);
input.value = '';
showTyping();
try {
const response = await fetch(baseUrl + '/test/send-message?' +
'customerId=' + encodeURIComponent(customerId) +
'&kfId=' + encodeURIComponent(kfId) +
'&content=' + encodeURIComponent(content), {
method: 'POST'
});
const result = await response.json();
setTimeout(() => {
hideTyping();
if (result.code === 200) {
if (content.includes('人工') || content.includes('转人工') || content.includes('投诉')) {
sessionStatus = 'PENDING';
document.getElementById('statusText').textContent = '等待人工接入...';
addTransferNotice();
}
}
}, 1000 + Math.random() * 1000);
} catch (error) {
hideTyping();
console.error('发送失败:', error);
addMessage('消息发送失败,请重试', false, '系统');
}
}
function addTransferNotice() {
const chatArea = document.getElementById('chatArea');
const notice = document.createElement('div');
notice.className = 'transfer-notice';
notice.textContent = '正在为您转接人工客服,请稍候...';
chatArea.appendChild(notice);
chatArea.scrollTop = chatArea.scrollHeight;
}
function quickSend(text) {
document.getElementById('messageInput').value = text;
sendMessage();
}
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
document.getElementById('messageInput').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 100) + 'px';
});
setInterval(async () => {
try {
const response = await fetch(baseUrl + '/api/sessions?status=MANUAL');
const result = await response.json();
if (result.code === 200) {
const mySession = result.data.find(s =>
s.customerId === customerId && s.status === 'MANUAL'
);
if (mySession && sessionStatus !== 'MANUAL') {
sessionStatus = 'MANUAL';
document.getElementById('statusText').textContent = '人工客服接待中';
addSystemMessage('人工客服已接入');
}
}
} catch (e) {}
}, 3000);
</script>
</body>
</html>

View File

@ -0,0 +1,638 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>人工客服工作台</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
height: 100vh;
display: flex;
}
.sidebar {
width: 300px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 15px;
background: #1890ff;
color: white;
font-size: 16px;
font-weight: bold;
}
.session-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
}
.session-tab {
flex: 1;
padding: 10px;
text-align: center;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.session-tab.active {
border-bottom-color: #1890ff;
color: #1890ff;
}
.session-list {
flex: 1;
overflow-y: auto;
}
.session-item {
padding: 12px 15px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.session-item:hover {
background: #f5f5f5;
}
.session-item.active {
background: #e6f7ff;
}
.session-item .customer-id {
font-weight: bold;
margin-bottom: 5px;
}
.session-item .last-msg {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item .time {
font-size: 11px;
color: #999;
margin-top: 3px;
}
.status-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
margin-left: 5px;
}
.status-pending {
background: #fff7e6;
color: #fa8c16;
}
.status-manual {
background: #e6f7ff;
color: #1890ff;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
padding: 15px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
padding: 20px;
overflow-y: auto;
background: #f9f9f9;
}
.message {
margin-bottom: 15px;
display: flex;
}
.message.customer {
justify-content: flex-start;
}
.message.ai, .message.manual {
justify-content: flex-end;
}
.message-content {
max-width: 60%;
padding: 10px 15px;
border-radius: 8px;
position: relative;
}
.message.customer .message-content {
background: #fff;
border: 1px solid #e0e0e0;
}
.message.ai .message-content {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.message.manual .message-content {
background: #f6ffed;
border: 1px solid #b7eb8f;
}
.message-sender {
font-size: 11px;
color: #999;
margin-bottom: 3px;
}
.message-time {
font-size: 10px;
color: #bbb;
margin-top: 3px;
}
.chat-input {
padding: 15px;
background: #fff;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 10px;
}
.chat-input textarea {
flex: 1;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
resize: none;
height: 60px;
}
.chat-input button {
padding: 10px 20px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.chat-input button:hover {
background: #40a9ff;
}
.chat-input button:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
.connection-status {
padding: 5px 10px;
font-size: 12px;
border-radius: 4px;
}
.connected {
background: #f6ffed;
color: #52c41a;
}
.disconnected {
background: #fff2f0;
color: #ff4d4f;
}
.actions {
display: flex;
gap: 10px;
}
.actions button {
padding: 5px 10px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
}
.actions button:hover {
border-color: #1890ff;
color: #1890ff;
}
.test-panel {
position: fixed;
right: 20px;
bottom: 20px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 8px;
padding: 15px;
width: 300px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.test-panel h4 {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.test-panel input, .test-panel textarea {
width: 100%;
padding: 8px;
margin-bottom: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.test-panel button {
width: 100%;
padding: 8px;
background: #52c41a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.test-panel button:hover {
background: #73d13d;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="sidebar-header">
客服工作台 <span id="csId">CS_001</span>
</div>
<div class="session-tabs">
<div class="session-tab active" data-status="PENDING" onclick="switchTab('PENDING')">
待接入 (<span id="pendingCount">0</span>)
</div>
<div class="session-tab" data-status="MANUAL" onclick="switchTab('MANUAL')">
进行中 (<span id="manualCount">0</span>)
</div>
</div>
<div class="session-list" id="sessionList">
</div>
</div>
<div class="main-content">
<div id="chatArea" style="display: none; height: 100%; flex-direction: column;">
<div class="chat-header">
<div>
<strong id="currentCustomer">-</strong>
<span class="status-badge" id="currentStatus">-</span>
</div>
<div class="actions">
<button onclick="acceptSession()" id="acceptBtn">接入会话</button>
<button onclick="closeSession()" id="closeBtn">结束会话</button>
</div>
</div>
<div class="chat-messages" id="chatMessages">
</div>
<div class="chat-input">
<textarea id="messageInput" placeholder="输入消息..."></textarea>
<button onclick="sendMessage()" id="sendBtn" disabled>发送</button>
</div>
</div>
<div id="emptyState" class="empty-state">
<div style="text-align: center;">
<p>请从左侧选择一个会话</p>
<p style="margin-top: 10px; font-size: 12px;">WebSocket: <span id="wsStatus" class="connection-status disconnected">未连接</span></p>
</div>
</div>
</div>
<div class="test-panel">
<h4>🧪 模拟客户消息</h4>
<input type="text" id="testCustomerId" placeholder="客户ID" value="test_customer_001">
<input type="text" id="testKfId" placeholder="客服账号ID" value="test_kf_001">
<textarea id="testContent" placeholder="消息内容"></textarea>
<button onclick="sendTestMessage()">发送测试消息</button>
<button onclick="triggerTransfer()" style="margin-top: 5px; background: #fa8c16;">触发转人工</button>
</div>
<script>
let ws = null;
let currentSessionId = null;
let currentStatus = null;
let csId = 'CS_001';
const baseUrl = window.location.origin;
function connectWebSocket() {
const wsUrl = baseUrl.replace('http', 'ws') + '/ws/cs/' + csId;
ws = new WebSocket(wsUrl);
ws.onopen = function() {
document.getElementById('wsStatus').className = 'connection-status connected';
document.getElementById('wsStatus').textContent = '已连接';
console.log('WebSocket已连接');
};
ws.onclose = function() {
document.getElementById('wsStatus').className = 'connection-status disconnected';
document.getElementById('wsStatus').textContent = '已断开';
console.log('WebSocket已断开');
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
console.log('收到消息:', data);
handleWebSocketMessage(data);
};
ws.onerror = function(error) {
console.error('WebSocket错误:', error);
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'new_pending_session':
alert('有新的待接入会话!');
loadSessions();
break;
case 'new_message':
case 'customer_message':
if (currentSessionId === data.sessionId) {
addMessage('customer', data.content, data.timestamp);
}
loadSessions();
break;
case 'session_accepted':
if (currentSessionId === data.sessionId) {
currentStatus = 'MANUAL';
updateChatHeader();
document.getElementById('sendBtn').disabled = false;
document.getElementById('acceptBtn').disabled = true;
}
break;
case 'session_closed':
if (currentSessionId === data.sessionId) {
alert('会话已结束');
currentSessionId = null;
showEmptyState();
}
loadSessions();
break;
}
}
function switchTab(status) {
document.querySelectorAll('.session-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.status === status) {
tab.classList.add('active');
}
});
loadSessions(status);
}
async function loadSessions(status = 'PENDING') {
try {
const response = await fetch(baseUrl + '/api/sessions?status=' + status);
const result = await response.json();
if (result.code === 200) {
renderSessionList(result.data, status);
if (status === 'PENDING') {
document.getElementById('pendingCount').textContent = result.data.length;
} else {
document.getElementById('manualCount').textContent = result.data.length;
}
}
} catch (error) {
console.error('加载会话列表失败:', error);
}
}
function renderSessionList(sessions, status) {
const list = document.getElementById('sessionList');
list.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = 'session-item' + (currentSessionId === session.sessionId ? ' active' : '');
item.onclick = () => selectSession(session);
const time = session.lastMessageTime ? new Date(session.lastMessageTime).toLocaleString() : '-';
item.innerHTML = `
<div class="customer-id">
${session.customerId}
<span class="status-badge status-${session.status.toLowerCase()}">${session.status}</span>
</div>
<div class="last-msg">${session.lastMessage || '暂无消息'}</div>
<div class="time">${time}</div>
`;
list.appendChild(item);
});
if (sessions.length === 0) {
list.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">暂无会话</div>';
}
}
async function selectSession(session) {
currentSessionId = session.sessionId;
currentStatus = session.status;
document.querySelectorAll('.session-item').forEach(item => {
item.classList.remove('active');
});
event.currentTarget.classList.add('active');
document.getElementById('emptyState').style.display = 'none';
document.getElementById('chatArea').style.display = 'flex';
updateChatHeader();
await loadHistory();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'bind_session',
sessionId: currentSessionId
}));
}
}
function updateChatHeader() {
document.getElementById('currentCustomer').textContent = currentSessionId;
const statusBadge = document.getElementById('currentStatus');
statusBadge.textContent = currentStatus;
statusBadge.className = 'status-badge status-' + currentStatus.toLowerCase();
document.getElementById('acceptBtn').disabled = currentStatus !== 'PENDING';
document.getElementById('sendBtn').disabled = currentStatus !== 'MANUAL';
document.getElementById('closeBtn').disabled = currentStatus !== 'MANUAL';
}
async function loadHistory() {
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/history');
const result = await response.json();
if (result.code === 200) {
const container = document.getElementById('chatMessages');
container.innerHTML = '';
result.data.forEach(msg => {
addMessage(msg.senderType, msg.content, msg.createdAt);
});
container.scrollTop = container.scrollHeight;
}
} catch (error) {
console.error('加载历史消息失败:', error);
}
}
function addMessage(senderType, content, timestamp) {
const container = document.getElementById('chatMessages');
const msg = document.createElement('div');
msg.className = 'message ' + senderType;
const senderName = senderType === 'customer' ? '客户' :
senderType === 'ai' ? 'AI客服' : '人工客服';
const time = timestamp ? new Date(timestamp).toLocaleString() : new Date().toLocaleString();
msg.innerHTML = `
<div class="message-content">
<div class="message-sender">${senderName}</div>
<div>${content}</div>
<div class="message-time">${time}</div>
</div>
`;
container.appendChild(msg);
container.scrollTop = container.scrollHeight;
}
async function acceptSession() {
if (!currentSessionId) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ csId: csId })
});
const result = await response.json();
if (result.code === 200) {
currentStatus = 'MANUAL';
updateChatHeader();
loadSessions('PENDING');
loadSessions('MANUAL');
} else {
alert('接入失败: ' + result.message);
}
} catch (error) {
console.error('接入会话失败:', error);
}
}
async function sendMessage() {
if (!currentSessionId || currentStatus !== 'MANUAL') return;
const content = document.getElementById('messageInput').value.trim();
if (!content) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content, msgType: 'text' })
});
const result = await response.json();
if (result.code === 200) {
addMessage('manual', content);
document.getElementById('messageInput').value = '';
} else {
alert('发送失败: ' + result.message);
}
} catch (error) {
console.error('发送消息失败:', error);
}
}
async function closeSession() {
if (!currentSessionId) return;
try {
const response = await fetch(baseUrl + '/api/sessions/' + currentSessionId + '/close', {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
currentSessionId = null;
showEmptyState();
loadSessions('PENDING');
loadSessions('MANUAL');
}
} catch (error) {
console.error('结束会话失败:', error);
}
}
function showEmptyState() {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('chatArea').style.display = 'none';
}
async function sendTestMessage() {
const customerId = document.getElementById('testCustomerId').value;
const kfId = document.getElementById('testKfId').value;
const content = document.getElementById('testContent').value;
if (!content) {
alert('请输入消息内容');
return;
}
try {
const response = await fetch(baseUrl + '/test/send-message?customerId=' + customerId + '&kfId=' + kfId + '&content=' + encodeURIComponent(content), {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
alert('消息已发送!');
document.getElementById('testContent').value = '';
setTimeout(() => loadSessions('PENDING'), 500);
setTimeout(() => loadSessions('MANUAL'), 500);
}
} catch (error) {
console.error('发送测试消息失败:', error);
}
}
async function triggerTransfer() {
const customerId = document.getElementById('testCustomerId').value;
const kfId = document.getElementById('testKfId').value;
try {
const response = await fetch(baseUrl + '/test/trigger-transfer?customerId=' + customerId + '&kfId=' + kfId, {
method: 'POST'
});
const result = await response.json();
if (result.code === 200) {
alert('已触发转人工!');
setTimeout(() => loadSessions('PENDING'), 500);
}
} catch (error) {
console.error('触发转人工失败:', error);
}
}
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
connectWebSocket();
loadSessions('PENDING');
loadSessions('MANUAL');
</script>
</body>
</html>

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 10000
lettuce:
pool:
max-active: 8
max-wait: -1
max-idle: 8
min-idle: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: debug
org.springframework.web: info

View File

@ -0,0 +1,49 @@
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://host.docker.internal:3316/wecom_robot?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: jiong1114
redis:
host: host.docker.internal
port: 6379
password: jiong1114
database: 0
timeout: 10000
lettuce:
pool:
max-active: 16
max-wait: -1
max-idle: 8
min-idle: 2
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
wecom:
corp-id: ww29e81e73b1f4c6fd
agent-id: 1000006
secret: vltAfKVAH1bqo6WjB99rJaH6iQSXDyx3uf3hbbA8F-M
token: 2wuT6pE
encoding-aes-key: l0boKM2eqcGT3xV2O03y6VXx9U5l25u0tWQsgF3aNPT
ai:
enabled: true
provider: deepseek
deepseek:
api-key: sk-6cdd32d6d49d4d399b479d99e02d1672
base-url: https://api.deepseek.com/v1
model: deepseek-chat
openai:
api-key: your_openai_api_key
base-url: https://api.openai.com/v1
model: gpt-3.5-turbo
logging:
level:
com.wecom.robot: info
org.springframework.web: warn

View File

@ -0,0 +1,30 @@
server:
port: 8080
spring:
application:
name: wecom-robot
profiles:
active: dev
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.wecom.robot.entity
configuration:
map-underscore-to-camel-case: true
wecom:
kf:
callback-url: /wecom/callback
transfer:
keywords:
- 人工
- 转人工
- 投诉
- 客服
- 人工客服
confidence-threshold: 0.6
max-fail-rounds: 3
max-session-duration: 1800000
max-message-rounds: 50

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More