Browse Source

补充 LINE Pay 商家扫码支付关键链路日志

- Controller:扫码入口留痕(商家/订单/付款码掩码),状态查询 debug 防轮询刷屏
- OfflineService:网关调用前上下文、returnCode 分类、PAID/金额不一致/人工核对迁移、复用流水;两个被吞的 catch(网关超时、对账查单失败)补 error 完整堆栈
- LinePayClient:网关请求/响应/耗时;不记请求体、请求头与敏感字段
- PaymentCreateGuard:等锁失败 warn,定位"系统繁忙"
- oneTimeKey 全程仅记后 4 位掩码(FR-OFF-014)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qmj 1 tuần trước cách đây
mục cha
commit
3cfc4b37f3

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderLinePayOfflineController.java

@@ -8,6 +8,8 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController;
 @RestController
 @RequestMapping("/system/orderShOprate/linePay/offline")
 public class PosOrderLinePayOfflineController {
+    private static final Logger log = LoggerFactory.getLogger(PosOrderLinePayOfflineController.class);
     private final LinePayOfflineService offlineService;
 
     public PosOrderLinePayOfflineController(LinePayOfflineService offlineService) {
@@ -38,6 +41,9 @@ public class PosOrderLinePayOfflineController {
             throw new ServiceException(MessageUtils.message("line.pay.offline.mycode.invalid"));
         }
         Long merchantId = Long.valueOf(new JwtUtil().getusid(token));
+        // 入口留痕:后续 ServiceException 全局处理器只记 i18n 消息,靠这行关联商家/订单/付款码
+        log.info("LINE Pay offline pay requested, merchantId={}, ddId={}, myCode={}",
+                merchantId, request.getDdId(), maskMyCode(request.getOneTimeKey()));
         return AjaxResult.success(offlineService.pay(merchantId, request.getDdId().trim(),
                 request.getOneTimeKey()));
     }
@@ -47,6 +53,16 @@ public class PosOrderLinePayOfflineController {
     @GetMapping("/status")
     public AjaxResult status(@RequestHeader String token, @RequestParam String ddId) {
         Long merchantId = Long.valueOf(new JwtUtil().getusid(token));
+        // 前端处理中每 5 秒轮询一次,debug 级避免日志刷屏
+        log.debug("LINE Pay offline status query, merchantId={}, ddId={}", merchantId, ddId);
         return AjaxResult.success(offlineService.status(merchantId, ddId));
     }
+
+    /** 付款码掩码:仅保留后 4 位;完整 oneTimeKey 属一次性凭证,禁止写入日志。 */
+    private static String maskMyCode(String oneTimeKey) {
+        if (oneTimeKey == null || oneTimeKey.length() < 4) {
+            return "****";
+        }
+        return "****" + oneTimeKey.substring(oneTimeKey.length() - 4);
+    }
 }

+ 76 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java

@@ -20,6 +20,8 @@ import com.ruoyi.system.service.IPosOrderLinePaymentService;
 import com.ruoyi.system.service.IPosOrderService;
 import com.ruoyi.system.service.IPosStoreService;
 import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.dao.DuplicateKeyException;
@@ -35,6 +37,7 @@ import java.util.UUID;
 /** Merchant-presented LINE Pay Offline My Code orchestration. */
 @Service
 public class LinePayOfflineService {
+    private static final Logger log = LoggerFactory.getLogger(LinePayOfflineService.class);
     private static final String MODE_OFFLINE = "OFFLINE";
     private static final Set<String> AUTH_REQUIRED_CODES = Set.of("1145", "1169");
     private static final Set<String> UNCERTAIN_CODES = Set.of(
@@ -109,6 +112,9 @@ public class LinePayOfflineService {
             if (!MODE_OFFLINE.equals(active.getPaymentMode())) {
                 throw new ServiceException(MessageUtils.message("line.pay.offline.attempt.blocked"));
             }
+            // 已有活跃流水:本次扫码不再发给 LINE,前端凭 reusedAttempt 提示不可重复提交
+            log.info("LINE Pay offline pay reuses active attempt, ddId={}, paymentId={}, status={}",
+                    ddId, active.getId(), active.getStatus());
             return result(active, true);
         }
         PosStoreLinePay credential = credentialService.getEnabledCurrent(order.getMdId());
@@ -118,9 +124,15 @@ public class LinePayOfflineService {
         IntentSelection selection = createIntent(order, credential);
         PosOrderLinePayment intent = selection.payment();
         if (!selection.created() || !"REQUESTING".equals(intent.getStatus())) {
+            log.info("LINE Pay offline pay returns existing attempt, ddId={}, paymentId={}, status={}",
+                    ddId, intent.getId(), intent.getStatus());
             return result(intent, true);
         }
         LinePayCredential gatewayCredential = toCredential(credential);
+        // 发往 LINE 前留下完整上下文(金额/流水/付款码掩码),网关超时排查依赖这一行
+        log.info("LINE Pay offline gateway pay start, ddId={}, paymentId={}, lineOrderId={}, storeId={}, amount={}, currency={}, myCode={}",
+                ddId, intent.getId(), intent.getLineOrderId(), intent.getStoreId(), intent.getAmount(),
+                intent.getCurrency(), maskMyCode(oneTimeKey));
         try {
             LinePayOfflineRequest request = new LinePayOfflineRequest(intent.getLineOrderId(),
                     ddId, "Order " + ddId, intent.getAmount(), intent.getCurrency(), oneTimeKey);
@@ -131,6 +143,9 @@ public class LinePayOfflineService {
                     () -> linePayClient.payOffline(gatewayCredential, request));
             return handlePayResponse(intent, response);
         } catch (Exception unknown) {
+            // 网关调用结果未知(超时/断网):异常在此被吞并转 REQUEST_UNKNOWN,无日志则完全无法事后排查
+            log.error("LINE Pay offline gateway pay ended unknown, ddId={}, paymentId={}, lineOrderId={}",
+                    ddId, intent.getId(), intent.getLineOrderId(), unknown);
             int changed = paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
                     shortly(), unknownDeadline());
             if (changed != 1) {
@@ -162,12 +177,17 @@ public class LinePayOfflineService {
         }
         PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
         if (credential == null) {
+            // 凭证被删后查单无从发起,静默转人工会让问题不可见
+            log.warn("LINE Pay offline credential gone to manual review, ddId={}, paymentId={}, credentialId={}",
+                    payment.getDdId(), payment.getId(), payment.getCredentialId());
             int changed = paymentService.markManualReview(
                     payment.getId(), payment.getVersion(), payment.getStatus());
             return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
         }
         boolean expired = payment.getReconcileDeadline() != null
                 && !new Date().before(payment.getReconcileDeadline());
+        log.info("LINE Pay offline reconcile start, source={}, paymentId={}, ddId={}, status={}, expired={}",
+                source, paymentId, payment.getDdId(), payment.getStatus(), expired);
         try {
             LinePayCredential gatewayCredential = toCredential(credential);
             LinePayResponse response = auditService == null
@@ -178,6 +198,9 @@ public class LinePayOfflineService {
                     () -> linePayClient.checkOffline(gatewayCredential, payment.getLineOrderId()));
             return handleCheckResponse(payment, response, expired);
         } catch (Exception unknown) {
+            // 查单异常被吞并改期/转人工,不记录则对账停摆无从发现
+            log.error("LINE Pay offline reconcile query failed, source={}, paymentId={}, ddId={}, expired={}",
+                    source, paymentId, payment.getDdId(), expired, unknown);
             if (expired) {
                 int changed = paymentService.markManualReview(
                         payment.getId(), payment.getVersion(), payment.getStatus());
@@ -202,6 +225,8 @@ public class LinePayOfflineService {
             payment.setTransactionId(response.transactionId());
             payment.setStatus("WAITING_AUTH");
             payment.setUpdateTime(new Date());
+            log.info("LINE Pay offline pay waiting auth, ddId={}, paymentId={}, returnCode={}, transactionId={}",
+                    payment.getDdId(), payment.getId(), response.returnCode(), response.transactionId());
             return result(payment, false);
         }
         if (response != null && response.isSuccess()) {
@@ -216,6 +241,9 @@ public class LinePayOfflineService {
             payment.setStatus("FAILED");
             payment.setActiveDdId(null);
             payment.setUpdateTime(new Date());
+            log.warn("LINE Pay offline pay definite failure, ddId={}, paymentId={}, httpStatus={}, returnCode={}, returnMessage={}",
+                    payment.getDdId(), payment.getId(), response.httpStatus(), response.returnCode(),
+                    response.returnMessage());
             return result(payment, false);
         }
         int changed = paymentService.markRequestUnknown(payment.getId(), payment.getVersion(),
@@ -225,6 +253,9 @@ public class LinePayOfflineService {
         }
         payment.setStatus("REQUEST_UNKNOWN");
         payment.setUpdateTime(new Date());
+        log.warn("LINE Pay offline pay result uncertain, ddId={}, paymentId={}, httpStatus={}, returnCode={}, returnMessage={}",
+                payment.getDdId(), payment.getId(), response.httpStatus(), response.returnCode(),
+                response.returnMessage());
         return result(payment, false);
     }
 
@@ -242,10 +273,19 @@ public class LinePayOfflineService {
             if (expired) {
                 int changed = paymentService.markManualReview(
                         payment.getId(), payment.getVersion(), payment.getStatus());
+                if (changed == 1) {
+                    log.warn("LINE Pay offline auth expired to manual review, ddId={}, paymentId={}",
+                            payment.getDdId(), payment.getId());
+                }
                 return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
             }
             int changed = paymentService.markOfflineWaitingAuth(payment.getId(), payment.getVersion(),
                     response.transactionId(), shortly(), waitingAuthDeadline(payment));
+            if (changed == 1) {
+                // 与 rescheduled 同属对账常态路径(60 秒一轮),debug 避免刷屏
+                log.debug("LINE Pay offline reconcile still waiting auth, ddId={}, paymentId={}, gatewayStatus={}",
+                        payment.getDdId(), payment.getId(), gatewayStatus);
+            }
             return changed == 1 ? "WAITING_AUTH" : reload(payment).getStatus();
         }
         if ("COMPLETE".equals(gatewayStatus)) {
@@ -254,11 +294,19 @@ public class LinePayOfflineService {
         if ("CANCEL".equals(gatewayStatus)) {
             int changed = paymentService.markTerminal(payment.getId(), payment.getVersion(),
                     payment.getStatus(), "CANCELLED_OR_EXPIRED");
+            if (changed == 1) {
+                log.info("LINE Pay offline payment cancelled at gateway, ddId={}, paymentId={}, transactionId={}",
+                        payment.getDdId(), payment.getId(), response.transactionId());
+            }
             return changed == 1 ? "CANCELLED_OR_EXPIRED" : reload(payment).getStatus();
         }
         if ("FAIL".equals(gatewayStatus)) {
             int changed = paymentService.markTerminal(payment.getId(), payment.getVersion(),
                     payment.getStatus(), "FAILED");
+            if (changed == 1) {
+                log.warn("LINE Pay offline payment failed at gateway, ddId={}, paymentId={}, transactionId={}",
+                        payment.getDdId(), payment.getId(), response.transactionId());
+            }
             return changed == 1 ? "FAILED" : reload(payment).getStatus();
         }
         return rescheduleOrReview(payment, expired);
@@ -278,6 +326,8 @@ public class LinePayOfflineService {
                 return result(reload(payment), false);
             }
             payment.setStatus("MANUAL_REVIEW");
+            log.warn("LINE Pay offline complete without transactionId, ddId={}, paymentId={}, lineOrderId={}",
+                    payment.getDdId(), payment.getId(), payment.getLineOrderId());
             return result(payment, false);
         }
         if (!payment.getLineOrderId().equals(orderId) || actualAmount == null
@@ -301,6 +351,9 @@ public class LinePayOfflineService {
             payment.setPaymentProvider(provider);
             payment.setCapturedAmount(actualAmount);
             payment.setStatus("MANUAL_REVIEW");
+            log.warn("LINE Pay offline evidence mismatch to manual review, ddId={}, paymentId={}, lineOrderId={}, gatewayOrderId={}, captured={}, transactionId={}",
+                    payment.getDdId(), payment.getId(), payment.getLineOrderId(), orderId,
+                    actualAmount, transactionId);
             return result(payment, false);
         }
         if (!payment.getAmount().equals(actualAmount)) {
@@ -314,6 +367,8 @@ public class LinePayOfflineService {
             payment.setPaymentProvider(provider);
             payment.setCapturedAmount(actualAmount);
             payment.setStatus("AMOUNT_MISMATCH");
+            log.warn("LINE Pay offline amount mismatch, ddId={}, paymentId={}, expected={}, captured={}, transactionId={}",
+                    payment.getDdId(), payment.getId(), payment.getAmount(), actualAmount, transactionId);
             return result(payment, false);
         }
         factService.applyPaidFact(payment.getId(), transactionId, provider, new Date());
@@ -322,6 +377,8 @@ public class LinePayOfflineService {
         payment.setCapturedAmount(payment.getAmount());
         payment.setStatus("PAID");
         payment.setUpdateTime(new Date());
+        log.info("LINE Pay offline payment PAID, ddId={}, paymentId={}, transactionId={}, provider={}, amount={}",
+                payment.getDdId(), payment.getId(), transactionId, provider, payment.getAmount());
         return result(payment, false);
     }
 
@@ -329,12 +386,21 @@ public class LinePayOfflineService {
         if (expired) {
             int changed = paymentService.markManualReview(
                     payment.getId(), payment.getVersion(), payment.getStatus());
+            if (changed == 1) {
+                log.warn("LINE Pay offline reconcile expired to manual review, ddId={}, paymentId={}, status={}",
+                        payment.getDdId(), payment.getId(), payment.getStatus());
+            }
             return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
         }
         int changed = paymentService.rescheduleOffline(
                 payment.getId(), payment.getVersion(), payment.getStatus(),
                 shortly(), payment.getReconcileDeadline() == null
                         ? unknownDeadline() : payment.getReconcileDeadline());
+        if (changed == 1) {
+            // 改期是常态路径(每分钟对账一轮都会触发),放 debug 避免刷屏
+            log.debug("LINE Pay offline reconcile rescheduled, ddId={}, paymentId={}, status={}",
+                    payment.getDdId(), payment.getId(), payment.getStatus());
+        }
         return changed == 1 ? payment.getStatus() : reload(payment).getStatus();
     }
 
@@ -345,6 +411,8 @@ public class LinePayOfflineService {
                         newLineOrderId(), credential.getId(), order.getMdId(), order.getAmount(),
                         "TWD", MODE_OFFLINE, unknownDeadline()), true);
             } catch (DuplicateKeyException duplicate) {
+                // 活跃流水唯一键冲突 = 并发扫码,后到者复用先到者流水
+                log.info("LINE Pay offline intent duplicate key, ddId={}, attempt={}", order.getDdId(), attempt);
                 PosOrderLinePayment active = paymentService.getActiveByDdId(
                         String.valueOf(order.getDdId()));
                 if (active != null) {
@@ -463,6 +531,14 @@ public class LinePayOfflineService {
                 credential.getChannelSecret());
     }
 
+    /** 付款码掩码:仅保留后 4 位;完整 oneTimeKey 属一次性凭证,禁止写入日志。 */
+    private static String maskMyCode(String oneTimeKey) {
+        if (oneTimeKey == null || oneTimeKey.length() < 4) {
+            return "****";
+        }
+        return "****" + oneTimeKey.substring(oneTimeKey.length() - 4);
+    }
+
     private static String normalizeDdId(String ddId) {
         if (ddId == null || ddId.trim().isEmpty()) {
             throw new ServiceException(MessageUtils.message("line.pay.order.required"));

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PaymentCreateGuardService.java

@@ -4,6 +4,8 @@ import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.MessageUtils;
 import org.redisson.api.RLock;
 import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -19,6 +21,7 @@ import java.util.concurrent.TimeUnit;
  */
 @Service
 public class PaymentCreateGuardService {
+    private static final Logger log = LoggerFactory.getLogger(PaymentCreateGuardService.class);
     private static final String PREFIX = "lock:payment:create:";
     /** 获取锁最长等待(秒):超时让调用方快速失败,避免用户长时间阻塞。 */
     private static final long LOCK_WAIT_SECONDS = 3L;
@@ -44,6 +47,8 @@ public class PaymentCreateGuardService {
             throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
         }
         if (!acquired) {
+            // 3 秒等锁失败 = 同订单有并发支付创建,这是"系统繁忙"提示的唯一现场线索
+            log.warn("Payment create lock not acquired within {}s, ddId={}", LOCK_WAIT_SECONDS, ddId);
             throw new ServiceException(MessageUtils.message("no.system.busy.try.again"));
         }
         boolean deferredUnlock = false;

+ 18 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java

@@ -3,6 +3,8 @@ package com.ruoyi.app.utils.linepay;
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
@@ -16,6 +18,7 @@ import java.util.function.Supplier;
 /** LINE Pay Online API v4 client. */
 @Component
 public class LinePayClient {
+    private static final Logger log = LoggerFactory.getLogger(LinePayClient.class);
 
     static final String REQUEST_URI = "/v4/payments/request";
     static final String RETRIEVE_URI = "/v4/payments";
@@ -151,7 +154,18 @@ public class LinePayClient {
                 credential.channelSecret(), uri, signedContent, nonce));
         headers.put("Content-Type", "application/json");
         String url = baseUrl() + uri + (query == null || query.isEmpty() ? "" : "?" + query);
-        LinePayHttpResponse httpResponse = transport.execute(method, url, body, headers, readTimeoutMs);
+        // 请求体可能含 oneTimeKey、请求头含 Channel 签名,两者都不得写日志;只记方法/URI/耗时/网关返回码
+        long started = System.currentTimeMillis();
+        log.info("LINE Pay gateway request, method={}, uri={}", method, uri);
+        LinePayHttpResponse httpResponse;
+        try {
+            httpResponse = transport.execute(method, url, body, headers, readTimeoutMs);
+        } catch (Exception transportError) {
+            // 失败单行留痕;完整堆栈由上层 Service 的 catch 记录,避免双份堆栈刷屏
+            log.warn("LINE Pay gateway request failed, method={}, uri={}, costMs={}, error={}",
+                    method, uri, System.currentTimeMillis() - started, transportError.toString());
+            throw transportError;
+        }
         JSONObject json;
         try {
             json = JSON.parseObject(httpResponse.body());
@@ -164,6 +178,9 @@ public class LinePayClient {
         Object infoValue = json.get("info");
         JSONObject info = infoValue instanceof JSONObject ? (JSONObject) infoValue : null;
         String transactionId = info == null ? null : info.getString("transactionId");
+        log.info("LINE Pay gateway response, method={}, uri={}, httpStatus={}, returnCode={}, returnMessage={}, costMs={}",
+                method, uri, httpResponse.statusCode(), json.getString("returnCode"),
+                json.getString("returnMessage"), System.currentTimeMillis() - started);
         return new LinePayResponse(httpResponse.statusCode(), json.getString("returnCode"),
                 json.getString("returnMessage"), transactionId, info, httpResponse.body());
     }