Ver código fonte

fix: HTTP 401跳转登录页 + feat: 订单详情页支付分查看详情

- 修复接口返回HTTP 401状态码时不触发跳转登录页的问题(request.ts)
- 订单详情页新增"支付详情"行,点击查看详情调起微信支付分订单详情页
- 后端新增 /payscore/detail-sign 接口,HMAC-SHA256签名生成

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 1 semana atrás
pai
commit
778b8de94f

+ 19 - 0
haha-miniapp/src/main/java/com/haha/miniapp/controller/PayScoreController.java

@@ -30,6 +30,25 @@ public class PayScoreController {
 
     private final PayScoreService payScoreService;
 
+    /**
+     * 获取微信支付分订单详情页签名参数
+     * 供前端调用 wx.openBusinessView({ businessType: 'wxpayScoreDetail' }) 拉起订单详情
+     */
+    @PostMapping("/detail-sign")
+    public Result<Map<String, Object>> getPayscoreDetailSign(@RequestBody Map<String, Object> params) {
+        try {
+            if (!params.containsKey("outTradeNo")) {
+                return com.haha.common.constant.ResponseEnum.PARAM_FORMAT_ERROR.toResult("缺少必填参数 outTradeNo");
+            }
+            String outTradeNo = params.get("outTradeNo").toString();
+            Map<String, Object> data = payScoreService.getPayscoreDetailSign(outTradeNo);
+            return Result.success(data);
+        } catch (Exception e) {
+            log.error("获取支付分详情签名失败", e);
+            return Result.error("获取签名失败:" + e.getMessage());
+        }
+    }
+
     /**
      * 预创建支付分服务订单(需确认模式,开门前调用)
      * 返回 package 参数供前端调用 wx.openBusinessView 拉起确认页

+ 21 - 0
haha-mp/src/api/payscore.ts

@@ -90,3 +90,24 @@ export const queryPayscoreRefund = (orderId: number, outRefundNo: string): Promi
 export const queryPayscoreByOutNo = (outOrderNo: string): Promise<{ outOrderNo: string; state: string }> => {
   return get<{ outOrderNo: string; state: string }>(API_PATHS.queryPayscoreByOutNo(outOrderNo), undefined, { silent: true });
 };
+
+/**
+ * 获取微信支付分订单详情签名参数(用于 wx.openBusinessView)
+ */
+export interface PayscoreDetailSignRequest {
+  outTradeNo: string;
+}
+
+export interface PayscoreDetailSignResponse {
+  mchId: string;
+  serviceId: string;
+  outOrderNo: string;
+  timestamp: string;
+  nonceStr: string;
+  signType: string;
+  sign: string;
+}
+
+export const getPayscoreDetailSign = (params: PayscoreDetailSignRequest): Promise<PayscoreDetailSignResponse> => {
+  return post<PayscoreDetailSignResponse>(API_PATHS.getPayscoreDetailSign, params);
+};

+ 45 - 0
haha-mp/src/pages/orderDetail/orderDetail.vue

@@ -129,6 +129,13 @@
             <text class="info-label">支付方式</text>
             <text class="info-value">微信</text>
           </view>
+          <view v-if="order.outTradeNo" class="info-row">
+            <text class="info-label">支付详情</text>
+            <view class="info-link" @click="viewPayDetail">
+              <text class="link-text">查看详情</text>
+              <text class="link-arrow">></text>
+            </view>
+          </view>
           <view class="info-row" v-if="order.storeName">
             <text class="info-label">门店</text>
             <text class="info-value">{{ order.storeName }}</text>
@@ -170,6 +177,7 @@ import { ref, onMounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { getOrderDetail } from '../../api/order';
 import type { OrderInfo } from '../../api/order';
+import { getPayscoreDetailSign } from '../../api/payscore';
 import { checkAuth } from '../../utils/auth';
 import { getStatusText, canRefund } from '../../utils/order';
 import { logger } from '../../utils/logger';
@@ -263,6 +271,43 @@ const copyText = (text: string) => {
 const viewVideo = (url: string) => {
   uni.showToast({ title: '视频播放功能开发中', icon: 'none' });
 };
+
+const viewPayDetail = async () => {
+  if (!order.value?.outTradeNo) return;
+
+  try {
+    uni.showLoading({ title: '加载中...' });
+    const signData = await getPayscoreDetailSign({ outTradeNo: order.value.outTradeNo });
+    uni.hideLoading();
+
+    wx.openBusinessView({
+      businessType: 'wxpayScoreDetail',
+      extraData: {
+        mch_id: signData.mchId,
+        service_id: signData.serviceId,
+        out_order_no: signData.outOrderNo,
+        timestamp: signData.timestamp,
+        nonce_str: signData.nonceStr,
+        sign_type: signData.signType,
+        sign: signData.sign,
+      },
+      success: () => {
+        logger.log('[订单详情] 支付分订单详情页返回');
+      },
+      fail: (err: any) => {
+        logger.error('[订单详情] openBusinessView fail:', err);
+        const errMsg = err?.errMsg || '';
+        if (!errMsg.includes('cancel')) {
+          uni.showToast({ title: '打开支付详情失败', icon: 'none' });
+        }
+      },
+    });
+  } catch (error: any) {
+    uni.hideLoading();
+    logger.error('[订单详情] 获取支付分签名失败:', error);
+    uni.showToast({ title: error?.message || '获取支付详情失败', icon: 'none' });
+  }
+};
 </script>
 
 <style scoped lang="scss">

+ 1 - 0
haha-mp/src/utils/config.ts

@@ -54,6 +54,7 @@ export const API_PATHS = {
   refundPayscoreOrder: (orderId: number) => `/payscore/refund/${orderId}`,
   queryPayscoreRefund: (orderId: number, outRefundNo: string) => `/payscore/refund/${orderId}/${outRefundNo}`,
   queryPayscoreByOutNo: (outOrderNo: string) => `/payscore/query-out-no/${outOrderNo}`,
+  getPayscoreDetailSign: '/payscore/detail-sign',
   
   // 优惠券相关
   getMyCoupons: '/coupon/my',

+ 4 - 0
haha-mp/src/utils/request.ts

@@ -86,6 +86,10 @@ export const request = <T = any>(config: RequestConfig): Promise<T> => {
 
         // 处理HTTP状态码
         if (res.statusCode !== 200) {
+          // HTTP 401 同样触发未授权处理
+          if (res.statusCode === 401) {
+            handleUnauthorized();
+          }
           const errorMsg = `请求失败: HTTP ${res.statusCode}`;
           if (!silent) {
             uni.showToast({ title: errorMsg, icon: 'none' });

+ 8 - 0
haha-service/src/main/java/com/haha/service/payment/payscore/PayScoreService.java

@@ -230,4 +230,12 @@ public interface PayScoreService {
      * @return 包含 orderId、outOrderNo、package 的 Map
      */
     Map<String, Object> preCreatePayScoreOrder(Long userId, String deviceId, String openId);
+
+    /**
+     * 获取微信支付分订单详情签名参数(用于 wx.openBusinessView)
+     *
+     * @param outTradeNo 商户订单号
+     * @return 包含 mchId、serviceId、outOrderNo、timestamp、nonceStr、signType、sign 的 Map
+     */
+    Map<String, Object> getPayscoreDetailSign(String outTradeNo);
 }

+ 59 - 4
haha-service/src/main/java/com/haha/service/payment/payscore/impl/PayScoreServiceImpl.java

@@ -23,13 +23,14 @@ import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -903,6 +904,60 @@ public class PayScoreServiceImpl implements PayScoreService {
         }
     }
 
+    @Override
+    public Map<String, Object> getPayscoreDetailSign(String outTradeNo) {
+        if (wxPayConfig == null) {
+            throw new IllegalStateException("微信支付未配置");
+        }
+
+        String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
+        String nonceStr = generateNonceStr(32);
+        String signType = "HMAC-SHA256";
+
+        // 按 key 字典序拼接: mch_id\nout_order_no\nservice_id\nsign_type\ntimestamp\nnonce_str\n
+        String signMessage = "mch_id=" + wxPayConfig.getMchId() + "&"
+                + "out_order_no=" + outTradeNo + "&"
+                + "service_id=" + wxPayConfig.getServiceId() + "&"
+                + "sign_type=" + signType + "&"
+                + "timestamp=" + timestamp + "&"
+                + "nonce_str=" + nonceStr;
+
+        String sign;
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            byte[] keyBytes = wxPayConfig.getV3ApiKey().getBytes(StandardCharsets.UTF_8);
+            SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "HmacSHA256");
+            mac.init(secretKey);
+            byte[] signBytes = mac.doFinal(signMessage.getBytes(StandardCharsets.UTF_8));
+            sign = Base64.getEncoder().encodeToString(signBytes);
+        } catch (Exception e) {
+            log.error("[支付分服务] 生成openBusinessView签名失败", e);
+            throw new RuntimeException("生成签名失败", e);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("mchId", wxPayConfig.getMchId());
+        data.put("serviceId", wxPayConfig.getServiceId());
+        data.put("outOrderNo", outTradeNo);
+        data.put("timestamp", timestamp);
+        data.put("nonceStr", nonceStr);
+        data.put("signType", signType);
+        data.put("sign", sign);
+
+        log.info("[支付分服务] 生成支付分详情页签名 - outTradeNo: {}", outTradeNo);
+        return data;
+    }
+
+    private static String generateNonceStr(int length) {
+        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+        SecureRandom random = new SecureRandom();
+        StringBuilder sb = new StringBuilder(length);
+        for (int i = 0; i < length; i++) {
+            sb.append(chars.charAt(random.nextInt(chars.length())));
+        }
+        return sb.toString();
+    }
+
     private void storePreAuthToRedis(String deviceId, Long userId, PayScoreResult result) {
         if (stringRedisTemplate == null) {
             return;