소스 검색

微信客服接入

skyline 3 주 전
부모
커밋
7196e644e5

+ 1 - 0
haha-admin/src/main/resources/application.yml

@@ -53,6 +53,7 @@ spring:
 
 # MyBatis-Plus 配置
 mybatis-plus:
+  mapper-locations: classpath*:mapper/**/*.xml
   configuration:
     map-underscore-to-camel-case: true
   global-config:

+ 194 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/WeChatCustomerController.java

@@ -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
+        );
+    }
+}

+ 7 - 0
haha-miniapp/src/main/resources/application.yml

@@ -45,6 +45,7 @@ spring:
 
 # MyBatis-Plus 配置
 mybatis-plus:
+  mapper-locations: classpath*:mapper/**/*.xml
   configuration:
     map-underscore-to-camel-case: true
     log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
@@ -73,6 +74,12 @@ wechat:
   #   service-id: YOUR_PAY_SCORE_SERVICE_ID
   #   pay-score-notify-url: http://localhost:7077/api/payment/callback/wechat_payscore
   # 小程序配置
+  miniapp:
+    # 用户端小程序(haha-mp)
+    app-id: wxb1cbe4678e0175f2
+    secret: 26c00fd986c628799653aca98803e5a5
+    # 微信客服消息回调 Token(需与微信公众平台配置一致)
+    customer-service-token: haha_cs_token_2026
   admin-miniapp:
     app-id: wxb1cbe4678e0175f2 # todo 要替换
     secret: 26c00fd986c628799653aca98803e5a5 # todo 要替换

+ 114 - 0
haha-mp/src/components/CustomerServiceButton.vue

@@ -0,0 +1,114 @@
+<template>
+  <button
+    open-type="contact"
+    :send-message-title="computedTitle"
+    :send-message-path="computedPath"
+    :send-message-img="img"
+    :show-message-card="showMessageCard"
+    @contact="onContact"
+    class="contact-btn"
+    :class="[mode]"
+    hover-class="contact-btn-hover"
+  >
+    <slot />
+  </button>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  title?: string
+  path?: string
+  img?: string
+  showMessageCard?: boolean
+  mode?: 'inline' | 'menu-item' | 'floating' | 'link'
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  title: '',
+  path: '',
+  img: '',
+  showMessageCard: true,
+  mode: 'inline'
+})
+
+const emit = defineEmits<{
+  (e: 'contact', data: any): void
+}>()
+
+const computedTitle = `客服咨询 - ${props.title || 'AI零售柜'}`
+const computedPath = props.path || '/pages/index/index'
+
+const onContact = (e: any) => {
+  emit('contact', e.detail)
+}
+</script>
+
+<style lang="scss" scoped>
+.contact-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  margin: 0;
+  background: transparent;
+  border: none;
+  line-height: inherit;
+  font-size: inherit;
+  color: inherit;
+  font-weight: inherit;
+
+  &::after {
+    border: none;
+  }
+
+  &.inline {
+    display: inline-flex;
+  }
+
+  &.menu-item {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    padding: $spacing-md $spacing-lg;
+    border-radius: 0;
+    text-align: left;
+    min-height: 88rpx;
+    box-sizing: border-box;
+
+    &:active {
+      background: $color-bg-secondary;
+    }
+  }
+
+  &.floating {
+    position: fixed;
+    bottom: 120rpx;
+    left: 50%;
+    transform: translateX(-50%);
+    background: $color-primary;
+    color: $color-text-primary;
+    padding: $spacing-md $spacing-xl;
+    border-radius: $radius-xl;
+    font-size: 28rpx;
+    font-weight: 500;
+    box-shadow: $shadow-primary;
+    z-index: 100;
+    white-space: nowrap;
+
+    &:active {
+      transform: translateX(-50%) scale(0.97);
+    }
+  }
+
+  &.link {
+    display: inline;
+    color: $color-primary-dark;
+    text-decoration: underline;
+    font-size: 28rpx;
+  }
+}
+
+.contact-btn-hover {
+  opacity: 0.8;
+}
+</style>

+ 14 - 24
haha-mp/src/pages/index/index.vue

@@ -45,9 +45,16 @@
         <image class="info-icon payscore-icon" src="/static/icons/payscore.svg" mode="aspectFit"></image>
         <text class="info-text">微信支付分 550分及以上优享</text>
       </view>
-      <view class="info-item" @tap="callService">
-        <text class="info-icon">📞</text>
-        <text class="info-text">客服: {{ customerServicePhone }}</text>
+      <view class="info-item">
+        <CustomerServiceButton
+          mode="inline"
+          title="首页-联系客服"
+          :path="'/pages/index/index'"
+          @contact="onContactSuccess"
+        >
+          <text class="info-icon">💬</text>
+          <text class="info-text">在线客服</text>
+        </CustomerServiceButton>
       </view>
     </view>
   </view>
@@ -61,7 +68,7 @@ import { checkPayscoreEnabled } from '../../api/payscore'
 import { getCouponCount } from '../../api/coupon'
 import { isLoggedIn, getToken } from '../../utils/auth'
 import { logger } from '../../utils/logger'
-import { CUSTOMER_SERVICE_PHONE } from '../../utils/config'
+import CustomerServiceButton from '../../components/CustomerServiceButton.vue'
 
 // 页面显示时检查 token
 onShow(() => {
@@ -69,8 +76,6 @@ onShow(() => {
   logger.log('[首页 onShow] token 状态:', token ? '存在' : '不存在')
 })
 
-const customerServicePhone = CUSTOMER_SERVICE_PHONE
-
 // 处理支付分开通成功
 const onPayscoreEnabled = () => {
   logger.log('[首页] 用户已开通支付分')
@@ -248,24 +253,9 @@ const goToCouponCenter = () => {
   })
 }
 
-// 拨打客服电话
-const callService = () => {
-  uni.vibrateShort({ type: 'light' })
-  uni.makePhoneCall({
-    phoneNumber: '4000755315',
-    success: () => {
-      logger.log('[首页] 拨号成功')
-    },
-    fail: (err) => {
-      console.error('[首页] 拨号失败:', err)
-      if (err.errMsg.indexOf('cancel') === -1) {
-        uni.showToast({
-          title: '拨号失败',
-          icon: 'none'
-        })
-      }
-    }
-  })
+// 客服会话消息发送成功回调
+const onContactSuccess = () => {
+  logger.log('[首页] 客服会话已打开')
 }
 </script>
 

+ 26 - 21
haha-mp/src/pages/my/my.vue

@@ -67,31 +67,35 @@
     </view>
 
     <view class="menu-section">
-      <view class="section-title">帮助与支持</view>
-      <view class="menu-card">
-        <view class="menu-item" @click="goToFAQ">
-          <view class="menu-icon">
-            <image class="menu-svg-icon" src="/static/icons/faq.svg" mode="aspectFit"></image>
-          </view>
-          <view class="menu-text">常见问题</view>
-          <view class="menu-arrow"></view>
-        </view>
-        <view class="menu-item" @click="callService">
-          <view class="menu-icon">
-            <image class="menu-svg-icon" src="/static/icons/service.svg" mode="aspectFit"></image>
+        <view class="section-title">帮助与支持</view>
+        <view class="menu-card">
+          <view class="menu-item" @click="goToFAQ">
+            <view class="menu-icon">
+              <image class="menu-svg-icon" src="/static/icons/faq.svg" mode="aspectFit"></image>
+            </view>
+            <view class="menu-text">常见问题</view>
+            <view class="menu-arrow"></view>
           </view>
-          <view class="menu-text">联系客服</view>
-          <view class="service-phone">400-0755-315</view>
-        </view>
-        <view class="menu-item" @click="goToOfficialAccount">
-          <view class="menu-icon">
-            <image class="menu-svg-icon" src="/static/icons/official.svg" mode="aspectFit"></image>
+          <CustomerServiceButton
+            mode="menu-item"
+            title="个人中心-联系客服"
+            :path="'/pages/my/my'"
+          >
+            <view class="menu-icon">
+              <image class="menu-svg-icon" src="/static/icons/service.svg" mode="aspectFit"></image>
+            </view>
+            <view class="menu-text">联系客服</view>
+            <view class="service-phone">在线咨询</view>
+          </CustomerServiceButton>
+          <view class="menu-item" @click="goToOfficialAccount">
+            <view class="menu-icon">
+              <image class="menu-svg-icon" src="/static/icons/official.svg" mode="aspectFit"></image>
+            </view>
+            <view class="menu-text">公众号信息</view>
+            <view class="menu-arrow"></view>
           </view>
-          <view class="menu-text">公众号信息</view>
-          <view class="menu-arrow"></view>
         </view>
       </view>
-    </view>
 
     <!-- 退出登录 -->
     <view class="logout-section">
@@ -114,6 +118,7 @@ import { clearAuth, getUserInfo as getStoredUserInfo } from '../../utils/auth';
 import { logger } from '../../utils/logger';
 import { APP_VERSION } from '../../utils/config';
 import type { UserInfo } from '../../api/user';
+import CustomerServiceButton from '../../components/CustomerServiceButton.vue';
 
 const userInfo = ref<UserInfo | null>(null);
 const availableCouponCount = ref(0);

+ 9 - 2
haha-mp/src/pages/orderDetail/orderDetail.vue

@@ -120,9 +120,15 @@
       </view>
     </view>
 
-    <!-- 底部提示 -->
+    <!-- 联系客服 -->
     <view class="footer-note">
-      <text>如有疑问,请联系客服</text>
+      <CustomerServiceButton
+        mode="link"
+        :title="'订单 ' + (order?.orderNo || '')"
+        :path="'/pages/orderDetail/orderDetail?orderId=' + String(order?.id || '')"
+      >
+        <text>如有疑问,请联系客服</text>
+      </CustomerServiceButton>
     </view>
   </view>
 </template>
@@ -133,6 +139,7 @@ import { getOrderDetail } from '../../api/order';
 import type { OrderInfo } from '../../api/order';
 import { checkAuth } from '../../utils/auth';
 import { getStatusText, canRefund } from '../../utils/order';
+import CustomerServiceButton from '../../components/CustomerServiceButton.vue';
 
 const order = ref<OrderInfo | null>(null);
 const loading = ref(false);

+ 18 - 0
haha-mp/src/pages/refund/refund.vue

@@ -125,6 +125,17 @@
     
     <!-- 确认提交按钮 -->
     <button class="submit-btn" @click="submitRefund">确认提交</button>
+
+    <!-- 联系客服 -->
+    <view class="contact-section">
+      <CustomerServiceButton
+        mode="link"
+        :title="'退款咨询 - ' + (orderInfo?.orderNo || '')"
+        :path="'/pages/refund/refund?orderId=' + String(orderInfo?.id || '')"
+      >
+        退款遇到问题?联系客服
+      </CustomerServiceButton>
+    </view>
   </view>
 </template>
 
@@ -135,6 +146,7 @@ import { checkAuth } from '../../utils/auth';
 import { getOrderDetail } from '../../api/order';
 import type { OrderInfo } from '../../api/order';
 import { post } from '../../utils/request';
+import CustomerServiceButton from '../../components/CustomerServiceButton.vue';
 
 // 路由参数
 const orderId = ref<string>('');
@@ -897,4 +909,10 @@ radio-group {
   border-radius: 8rpx;
   font-weight: 500;
 }
+
+.contact-section {
+  text-align: center;
+  padding: 20rpx 0 60rpx;
+  font-size: 26rpx;
+}
 </style>

+ 25 - 2
haha-mp/src/pages/shopping/shopping.vue

@@ -24,7 +24,13 @@
           <text class="countdown-number">请尽快关门</text>
         </view>
       </view>
-      <view class="problem-link" @click="showProblem">遇到问题?(如门没开)</view>
+      <CustomerServiceButton
+        mode="link"
+        title="购物-门已开"
+        :path="'/pages/shopping/shopping'"
+      >
+        遇到问题?联系在线客服
+      </CustomerServiceButton>
     </view>
     
     <!-- 阶段二:购物完成 (门已关) -->
@@ -38,7 +44,16 @@
         </view>
       </view>
       <view class="status-title">购物已完成</view>
-      <view class="status-tip">订单稍后自动扣款,如有疑问请联系客服</view>
+      <view class="status-tip">
+        订单稍后自动扣款,
+        <CustomerServiceButton
+          mode="link"
+          title="购物-扣款疑问"
+          :path="'/pages/shopping/shopping'"
+        >
+          如有疑问请联系客服
+        </CustomerServiceButton>
+      </view>
       <view class="countdown-wrapper">
         <view class="countdown-return" v-if="returnCountdown > 0">
           <text class="countdown-return-text">{{ returnCountdown }}秒后返回首页</text>
@@ -89,6 +104,13 @@
       </view>
       <view class="status-title">出错了</view>
       <view class="status-tip">{{ errorMessage }}</view>
+      <CustomerServiceButton
+        mode="link"
+        title="购物-错误求助"
+        :path="'/pages/shopping/shopping'"
+      >
+        联系客服解决
+      </CustomerServiceButton>
       <button class="action-button" @click="goHome">
         <text class="button-text">返回首页</text>
       </button>
@@ -103,6 +125,7 @@ import type { RecognizeResultResponse } from '../../api/status';
 
 import { logger } from '../../utils/logger';
 import type { OrderProduct } from '../../api/order';
+import CustomerServiceButton from '../../components/CustomerServiceButton.vue';
 
 const doorStatus = ref<'opened' | 'closing' | 'closed' | 'error'>('opened');
 const countdown = ref(60);