commit 72d84e262239744cc38576fe5cb707f5cd145d55 Author: MerCry Date: Mon Feb 23 09:45:23 2026 +0800 初始化提交 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..a1e8972 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..63e9001 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..5a2f139 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..67e1e61 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/lib/commons-codec-1.9.jar b/lib/commons-codec-1.9.jar new file mode 100644 index 0000000..ef35f1c Binary files /dev/null and b/lib/commons-codec-1.9.jar differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..cc711f9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + com.wecom + wecom-robot + 1.0.0 + jar + + wecom-robot + 企业微信智能客服系统 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 1.8 + 3.5.3.1 + 5.8.22 + 2.0.40 + ${project.basedir} + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-validation + + + + com.mysql + mysql-connector-j + runtime + + + + com.baomidou + mybatis-plus-boot-starter + ${mybatis-plus.version} + + + + cn.hutool + hutool-all + ${hutool.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + commons-codec + commons-codec + 1.9 + system + ${project.basedir}/lib/commons-codec-1.9.jar + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/wecom/robot/WecomRobotApplication.java b/src/main/java/com/wecom/robot/WecomRobotApplication.java new file mode 100644 index 0000000..3e16b98 --- /dev/null +++ b/src/main/java/com/wecom/robot/WecomRobotApplication.java @@ -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); + } +} diff --git a/src/main/java/com/wecom/robot/config/AiConfig.java b/src/main/java/com/wecom/robot/config/AiConfig.java new file mode 100644 index 0000000..ada95c4 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/AiConfig.java @@ -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; + } +} diff --git a/src/main/java/com/wecom/robot/config/TransferConfig.java b/src/main/java/com/wecom/robot/config/TransferConfig.java new file mode 100644 index 0000000..432ad1f --- /dev/null +++ b/src/main/java/com/wecom/robot/config/TransferConfig.java @@ -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 keywords; + private double confidenceThreshold; + private int maxFailRounds; + private long maxSessionDuration; + private int maxMessageRounds; +} diff --git a/src/main/java/com/wecom/robot/config/WebSocketConfig.java b/src/main/java/com/wecom/robot/config/WebSocketConfig.java new file mode 100644 index 0000000..97520ec --- /dev/null +++ b/src/main/java/com/wecom/robot/config/WebSocketConfig.java @@ -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("*"); + } +} diff --git a/src/main/java/com/wecom/robot/config/WecomConfig.java b/src/main/java/com/wecom/robot/config/WecomConfig.java new file mode 100644 index 0000000..199ef63 --- /dev/null +++ b/src/main/java/com/wecom/robot/config/WecomConfig.java @@ -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; + } +} diff --git a/src/main/java/com/wecom/robot/controller/ChatHistoryController.java b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java new file mode 100644 index 0000000..07dbe44 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/ChatHistoryController.java @@ -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>> getKfAccounts() { + try { + JSONObject result = wecomApiService.getKfAccountList(0, 100); + List> 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 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>> getSessions( + @RequestParam String openKfId, + @RequestParam(required = false) String status, + @RequestParam(required = false, defaultValue = "50") int limit) { + try { + List sessions = sessionManagerService.getSessionsByKfId(openKfId, status, limit); + List> result = new ArrayList<>(); + + for (Session session : sessions) { + Map 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>> getMessages(@RequestParam String sessionId) { + try { + List messages = sessionManagerService.getSessionMessages(sessionId); + List> result = new ArrayList<>(); + + for (Message msg : messages) { + Map 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> getSessionDetail(@PathVariable String sessionId) { + try { + Session session = sessionManagerService.getSession(sessionId); + if (session == null) { + return ApiResponse.error("会话不存在"); + } + + Map result = new HashMap<>(); + result.put("session", session); + + List 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()); + } + } +} diff --git a/src/main/java/com/wecom/robot/controller/DebugController.java b/src/main/java/com/wecom/robot/controller/DebugController.java new file mode 100644 index 0000000..70da513 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/DebugController.java @@ -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> getConfig() { + Map 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> testDecrypt( + @RequestParam String msgSignature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestBody String encryptedXml) { + + Map 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> testVerifyUrl( + @RequestParam String msgSignature, + @RequestParam String timestamp, + @RequestParam String nonce, + @RequestParam String echostr) { + + Map 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> getLastAiContext() { + Map result = new HashMap<>(); + + result.put("systemPrompt", aiService.getSystemPrompt()); + result.put("lastRequestJson", aiService.getLastRequestJson()); + result.put("lastConfidence", aiService.getLastConfidence()); + + List messages = aiService.getLastRequestMessages(); + result.put("messageCount", messages.size()); + + List> messageList = new java.util.ArrayList<>(); + for (ChatCompletionRequest.Message msg : messages) { + Map 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> getSessionAiContext( + @PathVariable String sessionId, + @RequestParam(required = false, defaultValue = "测试消息") String testMessage) { + + Map result = new HashMap<>(); + + result.put("sessionId", sessionId); + result.put("systemPrompt", aiService.getSystemPrompt()); + + List history = sessionManagerService.getSessionMessages(sessionId); + result.put("historyCount", history.size()); + + List> historyList = new java.util.ArrayList<>(); + for (Message msg : history) { + Map 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 contextMessages = aiService.buildMessagesForTest(history, testMessage); + result.put("contextMessageCount", contextMessages.size()); + + List> contextList = new java.util.ArrayList<>(); + for (ChatCompletionRequest.Message msg : contextMessages) { + Map 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(); + } +} diff --git a/src/main/java/com/wecom/robot/controller/SessionController.java b/src/main/java/com/wecom/robot/controller/SessionController.java new file mode 100644 index 0000000..f1e453f --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/SessionController.java @@ -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> getSessions( + @RequestParam(required = false) String status, + @RequestParam(required = false) String csId) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + + if (status != null) { + query.eq(Session::getStatus, status); + } + + if (csId != null) { + query.eq(Session::getManualCsId, csId); + } + + query.orderByDesc(Session::getUpdatedAt); + + List sessions = sessionMapper.selectList(query); + + List 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 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 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> getSessionHistory(@PathVariable String sessionId) { + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(Message::getSessionId, sessionId) + .orderByAsc(Message::getCreatedAt); + + List messages = messageMapper.selectList(query); + + List 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 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 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 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); + } +} diff --git a/src/main/java/com/wecom/robot/controller/TestController.java b/src/main/java/com/wecom/robot/controller/TestController.java new file mode 100644 index 0000000..e80d707 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/TestController.java @@ -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> 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 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 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> 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 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 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> 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 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 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 clearCursor( + @RequestParam String kfId) { + + log.info("清除cursor: kfId={}", kfId); + wecomApiService.clearCursor(kfId); + + return ApiResponse.success(null); + } +} diff --git a/src/main/java/com/wecom/robot/controller/WecomCallbackController.java b/src/main/java/com/wecom/robot/controller/WecomCallbackController.java new file mode 100644 index 0000000..5d18473 --- /dev/null +++ b/src/main/java/com/wecom/robot/controller/WecomCallbackController.java @@ -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 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); + } + } +} diff --git a/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java b/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java new file mode 100644 index 0000000..7cf1efc --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/AcceptSessionRequest.java @@ -0,0 +1,9 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class AcceptSessionRequest { + + private String csId; +} diff --git a/src/main/java/com/wecom/robot/dto/ApiResponse.java b/src/main/java/com/wecom/robot/dto/ApiResponse.java new file mode 100644 index 0000000..3868657 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ApiResponse.java @@ -0,0 +1,30 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class ApiResponse { + + private int code; + private String message; + private T data; + + public static ApiResponse success(T data) { + ApiResponse response = new ApiResponse<>(); + response.setCode(200); + response.setMessage("success"); + response.setData(data); + return response; + } + + public static ApiResponse error(int code, String message) { + ApiResponse response = new ApiResponse<>(); + response.setCode(code); + response.setMessage(message); + return response; + } + + public static ApiResponse error(String message) { + return error(500, message); + } +} diff --git a/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java b/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java new file mode 100644 index 0000000..4796eb2 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ChatCompletionRequest.java @@ -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 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 messages) { + ChatCompletionRequest request = new ChatCompletionRequest(); + request.setModel(model); + request.setMessages(messages); + request.setTemperature(0.7); + request.setMaxTokens(2000); + return request; + } +} diff --git a/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java b/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java new file mode 100644 index 0000000..c9bd67c --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ChatCompletionResponse.java @@ -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 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; + } +} diff --git a/src/main/java/com/wecom/robot/dto/MessageInfo.java b/src/main/java/com/wecom/robot/dto/MessageInfo.java new file mode 100644 index 0000000..9ad7a8e --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/MessageInfo.java @@ -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; +} diff --git a/src/main/java/com/wecom/robot/dto/SendMessageRequest.java b/src/main/java/com/wecom/robot/dto/SendMessageRequest.java new file mode 100644 index 0000000..65f653c --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SendMessageRequest.java @@ -0,0 +1,10 @@ +package com.wecom.robot.dto; + +import lombok.Data; + +@Data +public class SendMessageRequest { + + private String content; + private String msgType; +} diff --git a/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java b/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java new file mode 100644 index 0000000..111a670 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/ServiceStateResponse.java @@ -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 + ")"; + } + } +} diff --git a/src/main/java/com/wecom/robot/dto/SessionInfo.java b/src/main/java/com/wecom/robot/dto/SessionInfo.java new file mode 100644 index 0000000..425c092 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SessionInfo.java @@ -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; +} diff --git a/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java b/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java new file mode 100644 index 0000000..387bb4e --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/SyncMsgResponse.java @@ -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 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 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(); + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxAccessToken.java b/src/main/java/com/wecom/robot/dto/WxAccessToken.java new file mode 100644 index 0000000..89cda3f --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxAccessToken.java @@ -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; + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java b/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java new file mode 100644 index 0000000..b0e459d --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxCallbackMessage.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java b/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java new file mode 100644 index 0000000..fbfa517 --- /dev/null +++ b/src/main/java/com/wecom/robot/dto/WxSendMessageRequest.java @@ -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; + } +} diff --git a/src/main/java/com/wecom/robot/entity/KfAccount.java b/src/main/java/com/wecom/robot/entity/KfAccount.java new file mode 100644 index 0000000..4d8a7b5 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/KfAccount.java @@ -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"; +} diff --git a/src/main/java/com/wecom/robot/entity/Message.java b/src/main/java/com/wecom/robot/entity/Message.java new file mode 100644 index 0000000..fb5c366 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/Message.java @@ -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"; +} diff --git a/src/main/java/com/wecom/robot/entity/Session.java b/src/main/java/com/wecom/robot/entity/Session.java new file mode 100644 index 0000000..ef8fcd4 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/Session.java @@ -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"; +} diff --git a/src/main/java/com/wecom/robot/entity/TransferLog.java b/src/main/java/com/wecom/robot/entity/TransferLog.java new file mode 100644 index 0000000..2678de4 --- /dev/null +++ b/src/main/java/com/wecom/robot/entity/TransferLog.java @@ -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; +} diff --git a/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java b/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java new file mode 100644 index 0000000..e001772 --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/KfAccountMapper.java @@ -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 { +} diff --git a/src/main/java/com/wecom/robot/mapper/MessageMapper.java b/src/main/java/com/wecom/robot/mapper/MessageMapper.java new file mode 100644 index 0000000..eb50e9e --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/MessageMapper.java @@ -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 { +} diff --git a/src/main/java/com/wecom/robot/mapper/SessionMapper.java b/src/main/java/com/wecom/robot/mapper/SessionMapper.java new file mode 100644 index 0000000..69e391a --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/SessionMapper.java @@ -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 { +} diff --git a/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java b/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java new file mode 100644 index 0000000..98b2bef --- /dev/null +++ b/src/main/java/com/wecom/robot/mapper/TransferLogMapper.java @@ -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 { +} diff --git a/src/main/java/com/wecom/robot/service/AiService.java b/src/main/java/com/wecom/robot/service/AiService.java new file mode 100644 index 0000000..cbab38b --- /dev/null +++ b/src/main/java/com/wecom/robot/service/AiService.java @@ -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 lastRequestMessages = new ArrayList<>(); + private String lastRequestJson = ""; + + public String generateReply(String userMessage, List 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 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 entity = new HttpEntity<>(JSON.toJSONString(request), headers); + ResponseEntity 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 getLastRequestMessages() { + return lastRequestMessages; + } + + public String getLastRequestJson() { + return lastRequestJson; + } + + public String getSystemPrompt() { + return SYSTEM_PROMPT; + } + + public List buildMessagesForTest(List history, String currentMessage) { + return buildMessages(history, currentMessage); + } + + private List buildMessages(List history, String currentMessage) { + List 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; + } +} diff --git a/src/main/java/com/wecom/robot/service/MessageProcessService.java b/src/main/java/com/wecom/robot/service/MessageProcessService.java new file mode 100644 index 0000000..c081bcf --- /dev/null +++ b/src/main/java/com/wecom/robot/service/MessageProcessService.java @@ -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 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 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); + } + } +} diff --git a/src/main/java/com/wecom/robot/service/SessionManagerService.java b/src/main/java/com/wecom/robot/service/SessionManagerService.java new file mode 100644 index 0000000..5364de4 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/SessionManagerService.java @@ -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 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 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 getSessionMessages(String sessionId) { + LambdaQueryWrapper 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 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 getSessionsByKfId(String kfId, String status, int limit) { + LambdaQueryWrapper 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 getAllSessions(int limit) { + LambdaQueryWrapper 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(); + } +} diff --git a/src/main/java/com/wecom/robot/service/TransferService.java b/src/main/java/com/wecom/robot/service/TransferService.java new file mode 100644 index 0000000..d7d059c --- /dev/null +++ b/src/main/java/com/wecom/robot/service/TransferService.java @@ -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 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 keywords = transferConfig.getKeywords(); + if (keywords == null) { + return false; + } + + for (String keyword : keywords) { + if (message.contains(keyword)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/com/wecom/robot/service/WebSocketService.java b/src/main/java/com/wecom/robot/service/WebSocketService.java new file mode 100644 index 0000000..d98854b --- /dev/null +++ b/src/main/java/com/wecom/robot/service/WebSocketService.java @@ -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 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 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 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 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 message = new HashMap<>(); + message.put("type", "session_closed"); + message.put("sessionId", sessionId); + message.put("timestamp", System.currentTimeMillis()); + + webSocketHandler.sendMessageToSession(sessionId, message); + } +} diff --git a/src/main/java/com/wecom/robot/service/WecomApiService.java b/src/main/java/com/wecom/robot/service/WecomApiService.java new file mode 100644 index 0000000..c163cb2 --- /dev/null +++ b/src/main/java/com/wecom/robot/service/WecomApiService.java @@ -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 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 entity = new HttpEntity<>(JSON.toJSONString(request), headers); + ResponseEntity 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 entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity 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 entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity 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 entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity 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 entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity 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 entity = new HttpEntity<>(body.toJSONString(), headers); + ResponseEntity 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); + } +} diff --git a/src/main/java/com/wecom/robot/util/AesException.java b/src/main/java/com/wecom/robot/util/AesException.java new file mode 100644 index 0000000..afbdbe6 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/AesException.java @@ -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; + } + +} diff --git a/src/main/java/com/wecom/robot/util/ByteGroup.java b/src/main/java/com/wecom/robot/util/ByteGroup.java new file mode 100644 index 0000000..8ca51a2 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/ByteGroup.java @@ -0,0 +1,26 @@ +package com.wecom.robot.util; + +import java.util.ArrayList; + +class ByteGroup { + ArrayList byteContainer = new ArrayList(); + + 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(); + } +} diff --git a/src/main/java/com/wecom/robot/util/PKCS7Encoder.java b/src/main/java/com/wecom/robot/util/PKCS7Encoder.java new file mode 100644 index 0000000..6a46a7b --- /dev/null +++ b/src/main/java/com/wecom/robot/util/PKCS7Encoder.java @@ -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; + } + +} diff --git a/src/main/java/com/wecom/robot/util/SHA1.java b/src/main/java/com/wecom/robot/util/SHA1.java new file mode 100644 index 0000000..7f3dea2 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/SHA1.java @@ -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); + } + } +} diff --git a/src/main/java/com/wecom/robot/util/Sample.java b/src/main/java/com/wecom/robot/util/Sample.java new file mode 100644 index 0000000..35074ad --- /dev/null +++ b/src/main/java/com/wecom/robot/util/Sample.java @@ -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 = ""; + + 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 = "13488318601234567890123456128"; + try{ + String sEncryptMsg = wxcpt.EncryptMsg(sRespData, sReqTimeStamp, sReqNonce); + System.out.println("after encrypt sEncrytMsg: " + sEncryptMsg); + } + catch(Exception e) + { + e.printStackTrace(); + } + + } +} diff --git a/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java b/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java new file mode 100644 index 0000000..7745955 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/WXBizMsgCrypt.java @@ -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编码的字符串). + *
    + *
  1. 第三方回复加密消息给企业微信
  2. + *
  3. 第三方收到企业微信发送的消息,验证消息的安全性,并对消息进行解密。
  4. + *
+ * 说明:异常java.security.InvalidKeyException:illegal Key Size的解决方案 + *
    + *
  1. 在官方网站下载JCE无限制权限策略文件(JDK7的下载地址: + * http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
  2. + *
  3. 下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt
  4. + *
  5. 如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件
  6. + *
  7. 如果安装了JDK,将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件
  8. + *
+ */ +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; + + } + + /** + * 将企业微信回复用户的消息加密打包. + *
    + *
  1. 对要发送的消息进行AES-CBC加密
  2. + *
  3. 生成安全签名
  4. + *
  5. 将消息密文和安全签名打包成xml格式
  6. + *
+ * + * @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; + } + + /** + * 检验消息的真实性,并且获取解密后的明文. + *
    + *
  1. 利用收到的密文生成安全签名,进行签名验证
  2. + *
  3. 若验证通过,则提取xml中的加密消息
  4. + *
  5. 对消息进行解密
  6. + *
+ * + * @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; + } + +} \ No newline at end of file diff --git a/src/main/java/com/wecom/robot/util/XMLParse.java b/src/main/java/com/wecom/robot/util/XMLParse.java new file mode 100644 index 0000000..34f0daf --- /dev/null +++ b/src/main/java/com/wecom/robot/util/XMLParse.java @@ -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 = "\n" + "\n" + + "\n" + + "%3$s\n" + "\n" + ""; + return String.format(format, encrypt, signature, timestamp, nonce); + + } +} diff --git a/src/main/java/com/wecom/robot/util/XmlUtil.java b/src/main/java/com/wecom/robot/util/XmlUtil.java new file mode 100644 index 0000000..0789de3 --- /dev/null +++ b/src/main/java/com/wecom/robot/util/XmlUtil.java @@ -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 parseXml(String xmlStr) { + Map 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 map) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (isNumeric(value)) { + sb.append("<").append(key).append(">").append(value).append(""); + } else { + sb.append("<").append(key).append(">"); + } + } + sb.append(""); + 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; + } +} diff --git a/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java b/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java new file mode 100644 index 0000000..720a796 --- /dev/null +++ b/src/main/java/com/wecom/robot/websocket/CsWebSocketHandler.java @@ -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 csSessions = new ConcurrentHashMap<>(); + private static final Map 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 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; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..fb60211 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..d5f72e8 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..5314849 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/db/init.sql b/src/main/resources/db/init.sql new file mode 100644 index 0000000..8ce7c8e --- /dev/null +++ b/src/main/resources/db/init.sql @@ -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 '微信会话状态'; diff --git a/src/main/resources/static/chat-history.html b/src/main/resources/static/chat-history.html new file mode 100644 index 0000000..3912e0c --- /dev/null +++ b/src/main/resources/static/chat-history.html @@ -0,0 +1,455 @@ + + + + + + 聊天记录查询 + + + +
+
+

聊天记录查询

+

查看各客服账号的历史聊天记录

+
+ +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ 会话列表 (0) +
+
+
请选择客服账号并查询
+
+
+ +
+
+
请选择会话查看聊天记录
+
+
+
+ + + +

选择左侧会话查看聊天记录

+
+
+
+
+
+ + + + diff --git a/src/main/resources/static/customer.html b/src/main/resources/static/customer.html new file mode 100644 index 0000000..34c509c --- /dev/null +++ b/src/main/resources/static/customer.html @@ -0,0 +1,458 @@ + + + + + + 客户模拟端 + + + +
+
+
+ 12:00 + 📶 🔋 +
+
智能客服
+
AI在线
+
+ +
+
会话已开始
+
+
客服
+
您好!我是智能客服,有什么可以帮您的吗?
+
刚刚
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+

测试设置

+ + + + +
+
+ + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..d4e2c5f --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,638 @@ + + + + + + 人工客服工作台 + + + + + +
+ +
+
+

请从左侧选择一个会话

+

WebSocket: 未连接

+
+
+
+ +
+

🧪 模拟客户消息

+ + + + + +
+ + + + diff --git a/target/classes/application-dev.yml b/target/classes/application-dev.yml new file mode 100644 index 0000000..fb60211 --- /dev/null +++ b/target/classes/application-dev.yml @@ -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 diff --git a/target/classes/application-prod.yml b/target/classes/application-prod.yml new file mode 100644 index 0000000..d5f72e8 --- /dev/null +++ b/target/classes/application-prod.yml @@ -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 diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 0000000..5314849 --- /dev/null +++ b/target/classes/application.yml @@ -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 diff --git a/target/classes/com/wecom/robot/WecomRobotApplication.class b/target/classes/com/wecom/robot/WecomRobotApplication.class new file mode 100644 index 0000000..0c23894 Binary files /dev/null and b/target/classes/com/wecom/robot/WecomRobotApplication.class differ diff --git a/target/classes/com/wecom/robot/config/AiConfig$DeepSeekConfig.class b/target/classes/com/wecom/robot/config/AiConfig$DeepSeekConfig.class new file mode 100644 index 0000000..eb64c54 Binary files /dev/null and b/target/classes/com/wecom/robot/config/AiConfig$DeepSeekConfig.class differ diff --git a/target/classes/com/wecom/robot/config/AiConfig$OpenAiConfig.class b/target/classes/com/wecom/robot/config/AiConfig$OpenAiConfig.class new file mode 100644 index 0000000..b15f1b2 Binary files /dev/null and b/target/classes/com/wecom/robot/config/AiConfig$OpenAiConfig.class differ diff --git a/target/classes/com/wecom/robot/config/AiConfig.class b/target/classes/com/wecom/robot/config/AiConfig.class new file mode 100644 index 0000000..d2d34fc Binary files /dev/null and b/target/classes/com/wecom/robot/config/AiConfig.class differ diff --git a/target/classes/com/wecom/robot/config/TransferConfig.class b/target/classes/com/wecom/robot/config/TransferConfig.class new file mode 100644 index 0000000..d4cd377 Binary files /dev/null and b/target/classes/com/wecom/robot/config/TransferConfig.class differ diff --git a/target/classes/com/wecom/robot/config/WebSocketConfig.class b/target/classes/com/wecom/robot/config/WebSocketConfig.class new file mode 100644 index 0000000..1aec11b Binary files /dev/null and b/target/classes/com/wecom/robot/config/WebSocketConfig.class differ diff --git a/target/classes/com/wecom/robot/config/WecomConfig$KfConfig.class b/target/classes/com/wecom/robot/config/WecomConfig$KfConfig.class new file mode 100644 index 0000000..29b701a Binary files /dev/null and b/target/classes/com/wecom/robot/config/WecomConfig$KfConfig.class differ diff --git a/target/classes/com/wecom/robot/config/WecomConfig.class b/target/classes/com/wecom/robot/config/WecomConfig.class new file mode 100644 index 0000000..6151d5e Binary files /dev/null and b/target/classes/com/wecom/robot/config/WecomConfig.class differ diff --git a/target/classes/com/wecom/robot/controller/ChatHistoryController.class b/target/classes/com/wecom/robot/controller/ChatHistoryController.class new file mode 100644 index 0000000..669bb49 Binary files /dev/null and b/target/classes/com/wecom/robot/controller/ChatHistoryController.class differ diff --git a/target/classes/com/wecom/robot/controller/DebugController.class b/target/classes/com/wecom/robot/controller/DebugController.class new file mode 100644 index 0000000..6f06e2e Binary files /dev/null and b/target/classes/com/wecom/robot/controller/DebugController.class differ diff --git a/target/classes/com/wecom/robot/controller/SessionController.class b/target/classes/com/wecom/robot/controller/SessionController.class new file mode 100644 index 0000000..45ef165 Binary files /dev/null and b/target/classes/com/wecom/robot/controller/SessionController.class differ diff --git a/target/classes/com/wecom/robot/controller/TestController.class b/target/classes/com/wecom/robot/controller/TestController.class new file mode 100644 index 0000000..2e3fb08 Binary files /dev/null and b/target/classes/com/wecom/robot/controller/TestController.class differ diff --git a/target/classes/com/wecom/robot/controller/WecomCallbackController.class b/target/classes/com/wecom/robot/controller/WecomCallbackController.class new file mode 100644 index 0000000..e69c22c Binary files /dev/null and b/target/classes/com/wecom/robot/controller/WecomCallbackController.class differ diff --git a/target/classes/com/wecom/robot/dto/AcceptSessionRequest.class b/target/classes/com/wecom/robot/dto/AcceptSessionRequest.class new file mode 100644 index 0000000..c5f51b8 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/AcceptSessionRequest.class differ diff --git a/target/classes/com/wecom/robot/dto/ApiResponse.class b/target/classes/com/wecom/robot/dto/ApiResponse.class new file mode 100644 index 0000000..831d22a Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ApiResponse.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionRequest$Message.class b/target/classes/com/wecom/robot/dto/ChatCompletionRequest$Message.class new file mode 100644 index 0000000..0ffd94b Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionRequest$Message.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionRequest.class b/target/classes/com/wecom/robot/dto/ChatCompletionRequest.class new file mode 100644 index 0000000..2a146a8 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionRequest.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Choice.class b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Choice.class new file mode 100644 index 0000000..b6f6659 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Choice.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Message.class b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Message.class new file mode 100644 index 0000000..e96ba61 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Message.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Usage.class b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Usage.class new file mode 100644 index 0000000..4b3b909 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionResponse$Usage.class differ diff --git a/target/classes/com/wecom/robot/dto/ChatCompletionResponse.class b/target/classes/com/wecom/robot/dto/ChatCompletionResponse.class new file mode 100644 index 0000000..74a8a25 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ChatCompletionResponse.class differ diff --git a/target/classes/com/wecom/robot/dto/MessageInfo.class b/target/classes/com/wecom/robot/dto/MessageInfo.class new file mode 100644 index 0000000..d2c6447 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/MessageInfo.class differ diff --git a/target/classes/com/wecom/robot/dto/SendMessageRequest.class b/target/classes/com/wecom/robot/dto/SendMessageRequest.class new file mode 100644 index 0000000..3729793 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SendMessageRequest.class differ diff --git a/target/classes/com/wecom/robot/dto/ServiceStateResponse.class b/target/classes/com/wecom/robot/dto/ServiceStateResponse.class new file mode 100644 index 0000000..30a9171 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/ServiceStateResponse.class differ diff --git a/target/classes/com/wecom/robot/dto/SessionInfo.class b/target/classes/com/wecom/robot/dto/SessionInfo.class new file mode 100644 index 0000000..29e960c Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SessionInfo.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$BusinessCardContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$BusinessCardContent.class new file mode 100644 index 0000000..d512a76 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$BusinessCardContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$EventContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$EventContent.class new file mode 100644 index 0000000..55fca34 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$EventContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$FileContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$FileContent.class new file mode 100644 index 0000000..07eefbf Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$FileContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$ImageContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$ImageContent.class new file mode 100644 index 0000000..5770324 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$ImageContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$LinkContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$LinkContent.class new file mode 100644 index 0000000..f549022 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$LinkContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$LocationContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$LocationContent.class new file mode 100644 index 0000000..d023099 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$LocationContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuClick.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuClick.class new file mode 100644 index 0000000..0b75631 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuClick.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuItem.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuItem.class new file mode 100644 index 0000000..cbb57e6 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuItem.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuMiniprogram.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuMiniprogram.class new file mode 100644 index 0000000..e3c2b7e Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuMiniprogram.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuView.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuView.class new file mode 100644 index 0000000..8afa36d Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MenuView.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MiniprogramContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MiniprogramContent.class new file mode 100644 index 0000000..15f571e Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MiniprogramContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgItem.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgItem.class new file mode 100644 index 0000000..af7dee7 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgItem.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgMenuContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgMenuContent.class new file mode 100644 index 0000000..4ec5552 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$MsgMenuContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$TextContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$TextContent.class new file mode 100644 index 0000000..a004cbf Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$TextContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$VideoContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$VideoContent.class new file mode 100644 index 0000000..fa5e796 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$VideoContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$VoiceContent.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$VoiceContent.class new file mode 100644 index 0000000..5a3942d Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$VoiceContent.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse$WechatChannels.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse$WechatChannels.class new file mode 100644 index 0000000..8bdc885 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse$WechatChannels.class differ diff --git a/target/classes/com/wecom/robot/dto/SyncMsgResponse.class b/target/classes/com/wecom/robot/dto/SyncMsgResponse.class new file mode 100644 index 0000000..c56a257 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/SyncMsgResponse.class differ diff --git a/target/classes/com/wecom/robot/dto/WxAccessToken.class b/target/classes/com/wecom/robot/dto/WxAccessToken.class new file mode 100644 index 0000000..67499b1 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxAccessToken.class differ diff --git a/target/classes/com/wecom/robot/dto/WxCallbackMessage.class b/target/classes/com/wecom/robot/dto/WxCallbackMessage.class new file mode 100644 index 0000000..572bec5 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxCallbackMessage.class differ diff --git a/target/classes/com/wecom/robot/dto/WxSendMessageRequest$ImageContent.class b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$ImageContent.class new file mode 100644 index 0000000..6357a0c Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$ImageContent.class differ diff --git a/target/classes/com/wecom/robot/dto/WxSendMessageRequest$LinkContent.class b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$LinkContent.class new file mode 100644 index 0000000..0d8b949 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$LinkContent.class differ diff --git a/target/classes/com/wecom/robot/dto/WxSendMessageRequest$TextContent.class b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$TextContent.class new file mode 100644 index 0000000..06d6650 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxSendMessageRequest$TextContent.class differ diff --git a/target/classes/com/wecom/robot/dto/WxSendMessageRequest.class b/target/classes/com/wecom/robot/dto/WxSendMessageRequest.class new file mode 100644 index 0000000..9566578 Binary files /dev/null and b/target/classes/com/wecom/robot/dto/WxSendMessageRequest.class differ diff --git a/target/classes/com/wecom/robot/entity/KfAccount.class b/target/classes/com/wecom/robot/entity/KfAccount.class new file mode 100644 index 0000000..393c3bb Binary files /dev/null and b/target/classes/com/wecom/robot/entity/KfAccount.class differ diff --git a/target/classes/com/wecom/robot/entity/Message.class b/target/classes/com/wecom/robot/entity/Message.class new file mode 100644 index 0000000..4b63902 Binary files /dev/null and b/target/classes/com/wecom/robot/entity/Message.class differ diff --git a/target/classes/com/wecom/robot/entity/Session.class b/target/classes/com/wecom/robot/entity/Session.class new file mode 100644 index 0000000..018ca05 Binary files /dev/null and b/target/classes/com/wecom/robot/entity/Session.class differ diff --git a/target/classes/com/wecom/robot/entity/TransferLog.class b/target/classes/com/wecom/robot/entity/TransferLog.class new file mode 100644 index 0000000..d6a9c76 Binary files /dev/null and b/target/classes/com/wecom/robot/entity/TransferLog.class differ diff --git a/target/classes/com/wecom/robot/mapper/KfAccountMapper.class b/target/classes/com/wecom/robot/mapper/KfAccountMapper.class new file mode 100644 index 0000000..21fb185 Binary files /dev/null and b/target/classes/com/wecom/robot/mapper/KfAccountMapper.class differ diff --git a/target/classes/com/wecom/robot/mapper/MessageMapper.class b/target/classes/com/wecom/robot/mapper/MessageMapper.class new file mode 100644 index 0000000..a501825 Binary files /dev/null and b/target/classes/com/wecom/robot/mapper/MessageMapper.class differ diff --git a/target/classes/com/wecom/robot/mapper/SessionMapper.class b/target/classes/com/wecom/robot/mapper/SessionMapper.class new file mode 100644 index 0000000..5b077e4 Binary files /dev/null and b/target/classes/com/wecom/robot/mapper/SessionMapper.class differ diff --git a/target/classes/com/wecom/robot/mapper/TransferLogMapper.class b/target/classes/com/wecom/robot/mapper/TransferLogMapper.class new file mode 100644 index 0000000..fa64fa7 Binary files /dev/null and b/target/classes/com/wecom/robot/mapper/TransferLogMapper.class differ diff --git a/target/classes/com/wecom/robot/service/AiService.class b/target/classes/com/wecom/robot/service/AiService.class new file mode 100644 index 0000000..3ba90f3 Binary files /dev/null and b/target/classes/com/wecom/robot/service/AiService.class differ diff --git a/target/classes/com/wecom/robot/service/MessageProcessService.class b/target/classes/com/wecom/robot/service/MessageProcessService.class new file mode 100644 index 0000000..7654a45 Binary files /dev/null and b/target/classes/com/wecom/robot/service/MessageProcessService.class differ diff --git a/target/classes/com/wecom/robot/service/SessionManagerService.class b/target/classes/com/wecom/robot/service/SessionManagerService.class new file mode 100644 index 0000000..f3836af Binary files /dev/null and b/target/classes/com/wecom/robot/service/SessionManagerService.class differ diff --git a/target/classes/com/wecom/robot/service/TransferService.class b/target/classes/com/wecom/robot/service/TransferService.class new file mode 100644 index 0000000..8a4aa4c Binary files /dev/null and b/target/classes/com/wecom/robot/service/TransferService.class differ diff --git a/target/classes/com/wecom/robot/service/WebSocketService.class b/target/classes/com/wecom/robot/service/WebSocketService.class new file mode 100644 index 0000000..ff4cdc8 Binary files /dev/null and b/target/classes/com/wecom/robot/service/WebSocketService.class differ diff --git a/target/classes/com/wecom/robot/service/WecomApiService.class b/target/classes/com/wecom/robot/service/WecomApiService.class new file mode 100644 index 0000000..b0b4e60 Binary files /dev/null and b/target/classes/com/wecom/robot/service/WecomApiService.class differ diff --git a/target/classes/com/wecom/robot/util/AesException.class b/target/classes/com/wecom/robot/util/AesException.class new file mode 100644 index 0000000..675df0b Binary files /dev/null and b/target/classes/com/wecom/robot/util/AesException.class differ diff --git a/target/classes/com/wecom/robot/util/ByteGroup.class b/target/classes/com/wecom/robot/util/ByteGroup.class new file mode 100644 index 0000000..feac60b Binary files /dev/null and b/target/classes/com/wecom/robot/util/ByteGroup.class differ diff --git a/target/classes/com/wecom/robot/util/PKCS7Encoder.class b/target/classes/com/wecom/robot/util/PKCS7Encoder.class new file mode 100644 index 0000000..2e7d311 Binary files /dev/null and b/target/classes/com/wecom/robot/util/PKCS7Encoder.class differ diff --git a/target/classes/com/wecom/robot/util/SHA1.class b/target/classes/com/wecom/robot/util/SHA1.class new file mode 100644 index 0000000..3125173 Binary files /dev/null and b/target/classes/com/wecom/robot/util/SHA1.class differ diff --git a/target/classes/com/wecom/robot/util/Sample.class b/target/classes/com/wecom/robot/util/Sample.class new file mode 100644 index 0000000..5e8e4f0 Binary files /dev/null and b/target/classes/com/wecom/robot/util/Sample.class differ diff --git a/target/classes/com/wecom/robot/util/WXBizMsgCrypt.class b/target/classes/com/wecom/robot/util/WXBizMsgCrypt.class new file mode 100644 index 0000000..42527bd Binary files /dev/null and b/target/classes/com/wecom/robot/util/WXBizMsgCrypt.class differ diff --git a/target/classes/com/wecom/robot/util/XMLParse.class b/target/classes/com/wecom/robot/util/XMLParse.class new file mode 100644 index 0000000..00452c9 Binary files /dev/null and b/target/classes/com/wecom/robot/util/XMLParse.class differ diff --git a/target/classes/com/wecom/robot/util/XmlUtil.class b/target/classes/com/wecom/robot/util/XmlUtil.class new file mode 100644 index 0000000..080f29f Binary files /dev/null and b/target/classes/com/wecom/robot/util/XmlUtil.class differ diff --git a/target/classes/com/wecom/robot/websocket/CsWebSocketHandler.class b/target/classes/com/wecom/robot/websocket/CsWebSocketHandler.class new file mode 100644 index 0000000..271cb4b Binary files /dev/null and b/target/classes/com/wecom/robot/websocket/CsWebSocketHandler.class differ diff --git a/target/classes/db/init.sql b/target/classes/db/init.sql new file mode 100644 index 0000000..8ce7c8e --- /dev/null +++ b/target/classes/db/init.sql @@ -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 '微信会话状态'; diff --git a/target/classes/static/chat-history.html b/target/classes/static/chat-history.html new file mode 100644 index 0000000..3912e0c --- /dev/null +++ b/target/classes/static/chat-history.html @@ -0,0 +1,455 @@ + + + + + + 聊天记录查询 + + + +
+
+

聊天记录查询

+

查看各客服账号的历史聊天记录

+
+ +
+
+ + +
+
+ + +
+ + +
+ +
+
+
+ 会话列表 (0) +
+
+
请选择客服账号并查询
+
+
+ +
+
+
请选择会话查看聊天记录
+
+
+
+ + + +

选择左侧会话查看聊天记录

+
+
+
+
+
+ + + + diff --git a/target/classes/static/customer.html b/target/classes/static/customer.html new file mode 100644 index 0000000..34c509c --- /dev/null +++ b/target/classes/static/customer.html @@ -0,0 +1,458 @@ + + + + + + 客户模拟端 + + + +
+
+
+ 12:00 + 📶 🔋 +
+
智能客服
+
AI在线
+
+ +
+
会话已开始
+
+
客服
+
您好!我是智能客服,有什么可以帮您的吗?
+
刚刚
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + +
+

测试设置

+ + + + +
+
+ + + + diff --git a/target/classes/static/index.html b/target/classes/static/index.html new file mode 100644 index 0000000..d4e2c5f --- /dev/null +++ b/target/classes/static/index.html @@ -0,0 +1,638 @@ + + + + + + 人工客服工作台 + + + + + +
+ +
+
+

请从左侧选择一个会话

+

WebSocket: 未连接

+
+
+
+ +
+

🧪 模拟客户消息

+ + + + + +
+ + + + diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..e5c90b8 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=wecom-robot +groupId=com.wecom +version=1.0.0 diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..414a402 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,46 @@ +com\wecom\robot\dto\WxAccessToken.class +com\wecom\robot\dto\ChatCompletionResponse$Choice.class +com\wecom\robot\dto\WxCallbackMessage.class +com\wecom\robot\websocket\CsWebSocketHandler.class +com\wecom\robot\dto\AcceptSessionRequest.class +com\wecom\robot\dto\SyncMsgResponse$MsgItem.class +com\wecom\robot\dto\WxSendMessageRequest$TextContent.class +com\wecom\robot\controller\WecomCallbackController.class +com\wecom\robot\dto\WxSendMessageRequest$ImageContent.class +com\wecom\robot\config\AiConfig$DeepSeekConfig.class +com\wecom\robot\mapper\TransferLogMapper.class +com\wecom\robot\service\WecomApiService.class +com\wecom\robot\dto\SyncMsgResponse.class +com\wecom\robot\dto\SessionInfo.class +com\wecom\robot\config\WecomConfig$KfConfig.class +com\wecom\robot\dto\WxSendMessageRequest$LinkContent.class +com\wecom\robot\mapper\KfAccountMapper.class +com\wecom\robot\service\MessageProcessService.class +com\wecom\robot\dto\ApiResponse.class +com\wecom\robot\mapper\MessageMapper.class +com\wecom\robot\config\AiConfig$OpenAiConfig.class +com\wecom\robot\dto\ChatCompletionResponse$Usage.class +com\wecom\robot\dto\MessageInfo.class +com\wecom\robot\WecomRobotApplication.class +com\wecom\robot\dto\ChatCompletionResponse.class +com\wecom\robot\config\WecomConfig.class +com\wecom\robot\dto\ChatCompletionRequest.class +com\wecom\robot\dto\ChatCompletionResponse$Message.class +com\wecom\robot\service\AiService.class +com\wecom\robot\util\XmlUtil.class +com\wecom\robot\service\WebSocketService.class +com\wecom\robot\config\TransferConfig.class +com\wecom\robot\dto\ChatCompletionRequest$Message.class +com\wecom\robot\dto\SendMessageRequest.class +com\wecom\robot\service\TransferService.class +com\wecom\robot\controller\SessionController.class +com\wecom\robot\entity\Session.class +com\wecom\robot\controller\TestController.class +com\wecom\robot\entity\Message.class +com\wecom\robot\entity\TransferLog.class +com\wecom\robot\service\SessionManagerService.class +com\wecom\robot\dto\WxSendMessageRequest.class +com\wecom\robot\entity\KfAccount.class +com\wecom\robot\config\AiConfig.class +com\wecom\robot\mapper\SessionMapper.class +com\wecom\robot\config\WebSocketConfig.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..4e6a6dd --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,45 @@ +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\entity\Session.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\WebSocketService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\controller\WecomCallbackController.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\PKCS7Encoder.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\Sample.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\config\AiConfig.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\controller\DebugController.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\ApiResponse.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\XMLParse.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\SessionInfo.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\SyncMsgResponse.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\entity\TransferLog.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\SendMessageRequest.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\websocket\CsWebSocketHandler.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\mapper\MessageMapper.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\mapper\SessionMapper.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\WecomRobotApplication.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\XmlUtil.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\ChatCompletionRequest.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\AcceptSessionRequest.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\controller\SessionController.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\SessionManagerService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\AesException.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\WXBizMsgCrypt.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\config\WebSocketConfig.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\TransferService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\MessageInfo.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\MessageProcessService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\ChatCompletionResponse.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\AiService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\ByteGroup.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\WxCallbackMessage.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\entity\Message.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\service\WecomApiService.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\ServiceStateResponse.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\config\TransferConfig.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\entity\KfAccount.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\mapper\TransferLogMapper.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\mapper\KfAccountMapper.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\controller\TestController.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\controller\ChatHistoryController.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\util\SHA1.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\config\WecomConfig.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\WxAccessToken.java +E:\AiProject\wecom-robot\src\main\java\com\wecom\robot\dto\WxSendMessageRequest.java diff --git a/target/wecom-robot-1.0.0.jar b/target/wecom-robot-1.0.0.jar new file mode 100644 index 0000000..2dcfe0d Binary files /dev/null and b/target/wecom-robot-1.0.0.jar differ diff --git a/target/wecom-robot-1.0.0.jar.original b/target/wecom-robot-1.0.0.jar.original new file mode 100644 index 0000000..1fce2b3 Binary files /dev/null and b/target/wecom-robot-1.0.0.jar.original differ diff --git a/企业微信mcp.txt b/企业微信mcp.txt new file mode 100644 index 0000000..558d301 --- /dev/null +++ b/企业微信mcp.txt @@ -0,0 +1,329 @@ +1. 项目背景与目标 +1.1 背景 +随着企业服务量的增长,传统人工客服面临响应慢、人力成本高、无法7×24小时服务的问题。同时,企业微信已成为企业与客户沟通的主要渠道。为了提升客户体验、降低客服成本,需要构建一套AI优先、人机协同的智能客服系统,使每个客服账号既能由AI自动接待,又能在必要时无缝切换至人工服务。 + +1.2 目标 +AI自动接待:客户向指定客服账号发送消息时,由AI大模型结合企业知识库自动生成回复。 + +人工无缝接管:在特定条件下(如AI无法回答、客户要求转人工),系统自动将会话转给真人客服,且客户无感知(同一客服身份、聊天记录连续)。 + +统一客服身份:所有消息(AI或人工)均以同一个客服账号的身份发出,客户始终感觉与同一位“客服”对话。 + +提升效率:减少人工重复劳动,缩短客户等待时间,提高问题解决率。 + +2. 用户角色 +角色 描述 核心需求 +客户 通过企业微信与客服沟通的终端用户 快速获得准确答复;需要人工时能自然转接,不重复描述问题。 +AI 服务 系统后端自动处理消息的模块 理解客户意图,调用知识库,生成合规回复;识别转人工场景。 +人工客服 企业微信中的真人客服 查看AI对话历史,快速介入会话,使用工作台回复客户。 +系统管理员 负责系统配置与维护 配置转人工规则、知识库、客服账号;监控会话质量和AI效果。 +3. 功能需求 +3.1 客服账号管理 +支持在企业微信后台创建多个客服账号(通过“微信客服”功能)。 + +系统能够获取所有客服账号列表及在线状态。 + +支持将客服账号与人工客服账号绑定(可选,用于后续绩效统计)。 + +3.2 消息接收与处理 +系统需暴露一个公网可访问的回调接口,用于接收企业微信推送的客户消息。 + +对接收到的消息进行解密和验签(使用企业微信提供的加解密库)。 + +解析消息内容、客户ID、客服账号ID、消息ID、时间戳等字段。 + +将消息存入数据库,并触发后续处理流程。 + +3.3 AI 自动回复 +根据会话当前状态(AI处理中)调用AI大模型生成回复。 + +若开启了企业知识库,需先通过RAG检索相关文档,作为上下文补充给大模型。 + +支持多种回复格式:文本、图片、图文链接等(根据业务需要)。 + +生成的回复内容需经过安全合规检查(如敏感词过滤)。 + +通过企业微信API,以当前客服账号的身份发送消息给客户。 + +3.4 转人工策略 +支持配置多种转人工触发条件(可组合): + +关键词触发:客户消息包含“人工”、“转人工”、“投诉”等预设词。 + +AI置信度阈值:AI生成的回复置信度低于设定值(如0.6)。 + +多轮失败:连续N轮对话AI未能解决问题(如客户重复提问)。 + +会话时长/轮次:超过设定时长或消息轮次后自动转人工。 + +手动触发:客户在菜单点击“联系人工客服”(需企业微信菜单支持)。 + +触发转人工后,系统立即停止AI自动回复,将会话状态置为“待人工接入”,并将会话推送到人工客服队列。 + +3.5 人工客服工作台 +会话列表:显示待接入、进行中的会话,包含客户昵称、最新消息时间、转人工原因等。 + +聊天窗口: + +展示当前客户与AI的完整历史聊天记录(客户消息+AI回复)。 + +支持人工输入文本、选择快捷回复、发送图片/图文。 + +发送的消息仍以同一客服账号身份发出,客户无感知。 + +支持查看客户资料(如姓名、企业、标签),可从企业微信或CRM同步。 + +转接/结束会话:人工客服可将会话转给其他同事,或主动结束会话。 + +辅助功能:快捷回复库、知识库搜索、订单查询工具(可选)。 + +3.6 会话状态管理 +会话的生命周期由以下状态机管理: + +text +[初始] -> AI处理中 + | 触发转人工条件 + v + 待人工接入 + | 客服点击接入 + v + 人工处理中 + | 客服结束会话/客户超时 + v + 已结束 +系统需维护每个会话的当前状态,并根据状态决定消息路由: + +AI处理中:消息进入AI处理模块。 + +待人工接入:消息不处理,仅推送到工作台待接入列表,或暂存等待人工。 + +人工处理中:消息直接推送到工作台,由人工回复。 + +3.7 上下文传递 +转人工时,需将完整的对话历史(包括客户消息、AI回复、AI检索到的知识片段)传递给人工客服工作台。 + +人工客服接入后,工作台默认展示全部历史,确保人工快速了解前情。 + +若客户在人工处理期间继续发消息,工作台实时刷新显示新消息。 + +3.8 数据统计与监控 +记录关键指标:AI回复率、转人工率、人工平均响应时间、会话满意度等。 + +支持管理员查看每个客服账号的接待量、AI处理占比。 + +支持导出报表。 + +4. 非功能需求 +需求分类 具体要求 +性能 - AI回复平均耗时 < 2秒(不含网络延迟) +- 人工消息推送延迟 < 1秒 +- 系统支持并发会话数 ≥ 1000 +可用性 - 系统可用性 ≥ 99.9% +- 关键模块(消息接收、发送)支持多副本部署,无单点故障 +安全性 - 企业微信消息加密传输,需正确实现加解密规范 +- API调用需携带合法Token,防止重放攻击 +- 敏感数据(如客户手机号)在数据库加密存储 +可扩展性 - AI模型可替换(如支持不同大模型API) +- 知识库支持动态更新,无需重启服务 +- 人工工作台可对接企业现有CRM系统 +合规性 - 符合企业微信开发者协议 +- 消息内容需过滤违法违规信息 +5. 技术架构 +5.1 整体架构图 +text ++-------------------+ +-------------------+ +-------------------+ +| | | | | | +| 企业微信客户端 | <------> | 企业微信服务端 | <------> | 自建 AI 客服系统 | +| (客户/客服) | | (API & 回调) | | (核心后端) | +| | | | | | ++-------------------+ +-------------------+ +-------------------+ + | + v + +------------------------+ + | AI 服务模块 | + | - 大模型 API | + | - 知识库 (RAG) | + | - 转人工判断逻辑 | + +------------------------+ + | + v + +------------------------+ + | 人工客服工作台 | + | - Web 界面 | + | - 消息推送 (WebSocket) | + | - 快捷回复库 | + +------------------------+ +5.2 核心模块说明 +模块 职责 关键技术 +消息接收服务 接收企业微信回调请求,验签解密,将消息写入消息队列 HTTP Server (Spring Boot/Node.js),企业微信加解密库 +会话管理器 维护会话状态,根据状态将消息路由给AI或推送到人工工作台 Redis (存储会话状态),消息队列 (Kafka/RabbitMQ) +AI 处理器 调用大模型生成回复,集成知识库检索,判断是否转人工 大模型 API (OpenAI/DeepSeek),向量数据库 (Milvus),LangChain +消息发送服务 通过企业微信 API 发送消息给客户 HTTP Client,企业微信 Access Token 管理 +人工工作台后端 提供 WebSocket 推送,API 供工作台调用 WebSocket,RESTful API +人工工作台前端 客服使用的界面,实时接收消息,发送回复 React/Vue,WebSocket 客户端 +5.3 数据流转示例 +客户发消息 → 企业微信回调 → 消息接收服务 → 存入数据库 → 消息放入队列。 + +会话管理器从队列消费消息,查询 Redis 中会话状态: + +若为 AI 处理中,将消息发给 AI 处理器。 + +若为人工处理中,通过 WebSocket 推送给对应客服的工作台。 + +AI 处理器生成回复 → 判断是否转人工: + +若不转,将回复内容发给消息发送服务,并最终发给客户。 + +若转,更新会话状态为待人工接入,并将会话信息存入人工队列,同时将本次回复发给客户(AI 完成最后一句话)。 + +人工客服登录工作台,看到待接入列表,点击接入 → 会话状态变为人工处理中,后续消息直接推送。 + +6. 接口定义 +6.1 企业微信回调接口 +URL: POST /wecom/callback + +请求参数 (URL Query): msg_signature, timestamp, nonce, echostr (仅验证时) + +请求体: 加密的 XML 格式消息 + +处理逻辑: + +验证签名,解密消息体。 + +如果是普通消息,解析后存入数据库并推送到消息队列。 + +如果是事件(如客服接入),更新会话状态。 + +响应: 成功返回 "ok" 明文(需加密?实际上回调需返回明文 "ok" 或加密后的 "ok"?根据文档,接收消息时需返回加密后的 success 字符串。但简化起见,我们返回明文 "ok" 即可,但必须正确处理加解密。实际应使用官方 SDK 统一处理。) + +6.2 发送消息接口(调用企业微信 API) +企业微信 API: POST https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token=ACCESS_TOKEN + +请求体示例: + +json +{ + "touser": "客户ID", + "open_kfid": "客服账号ID", + "msgtype": "text", + "text": { + "content": "AI 生成的回复内容" + } +} +注意事项:需要管理 Access Token 的获取与刷新,支持多客服账号。 + +6.3 人工工作台 API +WebSocket 连接: ws://your-domain/ws/cs/{客服ID},用于实时接收新消息和状态更新。 + +REST API: + +GET /api/sessions?status=pending 获取待接入会话列表 + +POST /api/sessions/{sessionId}/accept 客服接入会话 + +POST /api/sessions/{sessionId}/message 人工发送消息 + +GET /api/sessions/{sessionId}/history 获取完整聊天记录 + +7. 数据模型 +7.1 会话表 (session) +字段 类型 说明 +session_id string 唯一会话ID,通常由企业微信客服API生成 +customer_id string 客户ID (企业微信的 external_userid) +kf_id string 客服账号ID ( open_kfid ) +status enum 'AI' / 'PENDING' / 'MANUAL' / 'CLOSED' +created_at datetime 会话创建时间 +updated_at datetime 最后更新时间 +manual_cs_id string 当前处理的人工客服ID(如果状态为MANUAL) +metadata json 扩展信息,如转人工原因、客户标签等 +7.2 消息表 (message) +字段 类型 说明 +msg_id string 企业微信消息ID(唯一) +session_id string 所属会话ID +sender_type enum 'customer' / 'ai' / 'manual' +sender_id string 发送者标识(客户ID、AI标识、客服ID) +content text 消息内容 +msg_type string 'text' / 'image' / 'link' 等 +created_at datetime 消息时间 +raw_data json 原始消息数据(用于调试) +7.3 客服账号表 (kf_account) +字段 类型 说明 +kf_id string 客服账号ID +name string 客服昵称 +avatar string 头像URL +status enum 'online' / 'offline'(人工客服在线状态) +bind_manual_id string 绑定的企业微信员工ID(可选) +7.4 转人工记录表 (transfer_log) +字段 类型 说明 +id int 自增ID +session_id string 会话ID +trigger_reason string 触发原因(关键词/AI置信度/多轮失败等) +trigger_time datetime 触发时间 +accepted_time datetime 客服接入时间(可选) +accepted_cs_id string 接入的客服ID(可选) +8. 人机切换流程(详细) +8.1 触发转人工 +当满足任一转人工条件时,系统执行以下操作: + +将会话状态从 AI 更新为 PENDING(待接入)。 +记录转人工原因到数据库。 +如果此时 AI 正在生成回复,等待其完成并发送(保证回复连贯),然后禁止后续 AI 调用。 +将会话信息推送到人工客服队列(可通过 WebSocket 通知所有在线客服有新会话待接入)。 +8.2 人工客服接入 +客服在工作台看到待接入会话,点击“接入”: + +后端校验该会话状态仍为 PENDING,将其更新为 MANUAL,并记录接入的客服ID。 +通过 WebSocket 通知该客服的工作台,打开聊天窗口并显示历史消息。 +如有必要,可向客户发送一条欢迎语(但建议不发送,保持无感),或让客服主动发送第一条消息。 +8.3 消息路由 +会话状态为 MANUAL 后,所有客户新消息通过回调进入系统,会话管理器根据状态直接推送到对应客服的 WebSocket 连接上,不再经过 AI。 + +客服在工作台发送的消息,调用发送消息接口以客服账号身份发出。 + +8.4 会话结束 +客服手动结束会话,或客户超过一定时间未回复(可配置),系统将会话状态置为 CLOSED。 + +结束后的会话不再接收消息(客户新消息将开启新会话)。 + +9. 验收标准 +9.1 功能验收 +客户发送消息,AI 能在 3 秒内回复。 + +当客户输入“人工”时,会话自动转为待人工,并出现在客服工作台。 + +客服接入后,能看到完整聊天记录,发送消息客户能收到且显示为同一客服身份。 + +人工回复后,客户继续发消息,消息能正确推送到该客服的工作台。 + +支持图片消息接收与发送(至少可转发图片)。 + +转人工条件可在后台配置并实时生效。 + +9.2 性能验收 +并发 500 个会话同时进行 AI 处理,系统 CPU 负载 < 70%。 + +消息从客户发出到 AI 回复平均耗时 < 2.5 秒(包含企业微信 API 延迟)。 + +WebSocket 推送延迟 < 500ms。 + +9.3 安全性验收 +所有回调接口正确验签,非法请求被拒绝。 + +Access Token 不泄露,定期刷新。 + +数据库敏感信息加密。 + +10. 附录 +10.1 参考文档 +企业微信微信客服 API 文档 + +企业微信回调消息加解密 + +MCP 协议介绍(可选,供参考) + +10.2 术语表 +术语 解释 +微信客服 企业微信提供的一种客服功能,支持通过 API 收发消息,身份为客服账号。 +open_kfid 客服账号的唯一标识。 +external_userid 企业微信客户的唯一标识。 +RAG 检索增强生成,一种结合检索和大模型生成的技术。