|
|
@@ -0,0 +1,194 @@
|
|
|
+package com.haha.miniapp.controller;
|
|
|
+
|
|
|
+import cn.dev33.satoken.annotation.SaIgnore;
|
|
|
+import cn.hutool.core.util.XmlUtil;
|
|
|
+import cn.hutool.crypto.digest.DigestUtil;
|
|
|
+import com.alibaba.fastjson2.JSON;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import jakarta.servlet.http.HttpServletRequest;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 微信客服消息回调
|
|
|
+ *
|
|
|
+ * 在微信公众平台 -> 功能 -> 客服 -> 客服消息回调配置中设置 URL 后,
|
|
|
+ * 用户向客服发送消息时,微信服务器会将消息推送到此接口。
|
|
|
+ *
|
|
|
+ * 配置项:
|
|
|
+ * - URL: {baseUrl}/callback/wechat/customer-service
|
|
|
+ * - Token: 与 wechat.miniapp.customer-service-token 一致
|
|
|
+ *
|
|
|
+ * 使用步骤:
|
|
|
+ * 1. 在微信公众平台开通「客服」功能
|
|
|
+ * 2. 添加客服人员
|
|
|
+ * 3. (可选)配置消息回调,用于接收用户消息实现自动回复
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@RequiredArgsConstructor
|
|
|
+@RestController
|
|
|
+@RequestMapping("/callback/wechat/customer-service")
|
|
|
+@SaIgnore
|
|
|
+public class WeChatCustomerController {
|
|
|
+
|
|
|
+ @Value("${wechat.miniapp.customer-service-token}")
|
|
|
+ private String token;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 验证消息的确来自微信服务器
|
|
|
+ *
|
|
|
+ * 微信服务器会发送 GET 请求到配置的 URL 进行验证,
|
|
|
+ * 需要将 signature 与本地计算的结果比对,匹配则原样返回 echostr。
|
|
|
+ */
|
|
|
+ @GetMapping
|
|
|
+ public String verify(HttpServletRequest request) {
|
|
|
+ String signature = request.getParameter("signature");
|
|
|
+ String timestamp = request.getParameter("timestamp");
|
|
|
+ String nonce = request.getParameter("nonce");
|
|
|
+ String echostr = request.getParameter("echostr");
|
|
|
+
|
|
|
+ log.info("[微信客服] 收到验证请求 - signature: {}, timestamp: {}, nonce: {}, echostr: {}",
|
|
|
+ signature, timestamp, nonce, echostr);
|
|
|
+
|
|
|
+ if (checkSignature(signature, timestamp, nonce)) {
|
|
|
+ log.info("[微信客服] 验证成功,返回 echostr: {}", echostr);
|
|
|
+ return echostr;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.warn("[微信客服] 验证失败 - signature: {}, timestamp: {}, nonce: {}", signature, timestamp, nonce);
|
|
|
+ return "invalid";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收用户发送的客服消息
|
|
|
+ *
|
|
|
+ * 用户在客服会话中发送消息后,微信服务器会推送 XML 格式的消息到此接口。
|
|
|
+ * 消息类型包括:text, image, voice, video, miniprogrampage 等。
|
|
|
+ *
|
|
|
+ * 如果需要自动回复,返回 XML 格式的响应消息。
|
|
|
+ * 不需要自动回复时,返回空字符串或 "success"。
|
|
|
+ */
|
|
|
+ @PostMapping(produces = "application/xml;charset=UTF-8")
|
|
|
+ public String handleMessage(HttpServletRequest request) {
|
|
|
+ try {
|
|
|
+ String signature = request.getParameter("signature");
|
|
|
+ String timestamp = request.getParameter("timestamp");
|
|
|
+ String nonce = request.getParameter("nonce");
|
|
|
+
|
|
|
+ if (!checkSignature(signature, timestamp, nonce)) {
|
|
|
+ log.warn("[微信客服] 消息签名验证失败");
|
|
|
+ return "invalid";
|
|
|
+ }
|
|
|
+
|
|
|
+ byte[] bytes = request.getInputStream().readAllBytes();
|
|
|
+ String xml = new String(bytes, StandardCharsets.UTF_8);
|
|
|
+ log.info("[微信客服] 收到消息: {}", xml);
|
|
|
+
|
|
|
+ Map<String, Object> msgMap = XmlUtil.xmlToMap(xml);
|
|
|
+ String msgType = (String) msgMap.get("MsgType");
|
|
|
+ String fromUser = (String) msgMap.get("FromUserName");
|
|
|
+ String content = (String) msgMap.get("Content");
|
|
|
+
|
|
|
+ log.info("[微信客服] 用户 {} 发送了 {} 类型消息: {}", fromUser, msgType, content);
|
|
|
+
|
|
|
+ return handleAutoReply(msgMap);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("[微信客服] 处理消息异常", e);
|
|
|
+ return "success";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验签名
|
|
|
+ *
|
|
|
+ * 将 token、timestamp、nonce 按字典序排序后拼接成字符串,进行 SHA1 加密,
|
|
|
+ * 结果与 signature 比对。
|
|
|
+ */
|
|
|
+ private boolean checkSignature(String signature, String timestamp, String nonce) {
|
|
|
+ if (signature == null || timestamp == null || nonce == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String[] arr = {token, timestamp, nonce};
|
|
|
+ Arrays.sort(arr);
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (String s : arr) {
|
|
|
+ sb.append(s);
|
|
|
+ }
|
|
|
+ String calculated = DigestUtil.sha1Hex(sb.toString());
|
|
|
+ return calculated.equals(signature);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理自动回复
|
|
|
+ *
|
|
|
+ * 根据消息类型和内容,返回对应的自动回复 XML。
|
|
|
+ * 不需要自动回复时返回空字符串,微信不会回复任何消息。
|
|
|
+ */
|
|
|
+ private String handleAutoReply(Map<String, Object> msgMap) {
|
|
|
+ String msgType = (String) msgMap.get("MsgType");
|
|
|
+ String fromUser = (String) msgMap.get("FromUserName");
|
|
|
+ String toUser = (String) msgMap.get("ToUserName");
|
|
|
+
|
|
|
+ if ("text".equals(msgType)) {
|
|
|
+ String content = (String) msgMap.get("Content");
|
|
|
+ String reply = buildTextReply(content);
|
|
|
+ if (reply != null) {
|
|
|
+ return buildReplyXml(fromUser, toUser, reply);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return "success";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据用户消息内容构建自动回复文本
|
|
|
+ */
|
|
|
+ private String buildTextReply(String content) {
|
|
|
+ if (content == null) return null;
|
|
|
+
|
|
|
+ if (content.contains("人工") || content.contains("转人工")) {
|
|
|
+ return "正在为您转接人工客服,请稍候...";
|
|
|
+ }
|
|
|
+ if (content.contains("退款") || content.contains("退钱")) {
|
|
|
+ return "如需申请退款,请前往「我的订单」-「订单详情」-「申请退款」操作,或拨打客服热线 400-0755-315。";
|
|
|
+ }
|
|
|
+ if (content.contains("开门") || content.contains("门没开") || content.contains("打不开")) {
|
|
|
+ return "门打不开?请尝试以下方法:\n1. 检查手机蓝牙是否开启\n2. 重新扫码尝试\n3. 联系设备维护人员\n如仍无法解决,请拨打客服热线 400-0755-315。";
|
|
|
+ }
|
|
|
+ if (content.contains("扣款") || content.contains("多扣") || content.contains("扣错")) {
|
|
|
+ return "如对扣款有疑问,请前往「我的订单」查看订单详情,如有错误可申请退款。如有其他疑问,请拨打客服热线 400-0755-315。";
|
|
|
+ }
|
|
|
+ if (content.contains("客服") || content.contains("电话")) {
|
|
|
+ return "我们的客服热线:400-0755-315(工作日 9:00-18:00)。";
|
|
|
+ }
|
|
|
+ if (content.contains("你好") || content.contains("您好") || content.contains("hi") || content.contains("hello")) {
|
|
|
+ return "您好!欢迎使用 AI 零售柜。请问有什么可以帮您?\n\n常见问题:\n• 如何退款:前往「我的订单」申请\n• 门打不开:重新扫码或联系维护人员\n• 扣款疑问:查看订单详情或申请退款\n\n如需人工帮助,请回复「转人工」。";
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建回复 XML
|
|
|
+ */
|
|
|
+ private String buildReplyXml(String fromUser, String toUser, String content) {
|
|
|
+ return String.format(
|
|
|
+ "<xml>" +
|
|
|
+ "<ToUserName><![CDATA[%s]]></ToUserName>" +
|
|
|
+ "<FromUserName><![CDATA[%s]]></FromUserName>" +
|
|
|
+ "<CreateTime>%d</CreateTime>" +
|
|
|
+ "<MsgType><![CDATA[text]]></MsgType>" +
|
|
|
+ "<Content><![CDATA[%s]]></Content>" +
|
|
|
+ "</xml>",
|
|
|
+ fromUser, toUser, System.currentTimeMillis() / 1000, content
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|