Kaynağa Gözat

feat(line-pay): LINE Pay Online API v4 线上支付接入(019-line-pay)

接入 LINE Pay(Online API v4,门店级 Channel ID/Secret 存库)用于餐饮订单线上支付。
规格 specs/019-line-pay/(spec/plan/tasks/research/data-model/contracts/quickstart/brainstorm)。

主要组件(ruoyi-admin/.../app/pay):
- LinePayController + LinePayService:发起(RequestPayment)/确认(confirm)/取消(cancel)/退款/查询
- PosStoreLinePayController(/system/storeLinePay):门店凭证管理(录入/启停/验证)
- LinePayReconcileTask:定时对账漏单补单(分布式锁)
- LinePayRefundService / LinePayOrderGuard / LinePayFactService / LinePayGatewayAuditService
  / LinePayCancellationCompensationService / LinePayReturnPageRenderer / LinePayAsyncService(AsyncConfig)
- utils/linepay/:LinePayClient/Signer/HttpTransport/Properties/Response 等(独立,零 OMG/蓝新依赖)

数据层(ruoyi-system)+ DDL(updatesql/sql.md):
- pos_order_line_payment / pos_order_line_refund / pos_store_line_pay / payment_gateway_log

订单集成:
- OrderLifecycleService:buildContext 加 LINE 状态(canReconcileLine/canRefundLine/linePaymentStatus)、
  validateOmg*/finalizeSystemLineRefund;订单 controller(商家/骑手/用户/平台)接 LINE 退款/取消钩子;
  AdminOrderStatusContext 加 LINE 字段

配置(application.yml):line-pay.{environment,base-url,confirm-url,cancel-url,reconcile.*};i18n 四语。

随附(同文件缠结,此前因与 LINE Pay 同 hunk 未提交):OrderLifecycleService 的 OMG T059 读侧迁移三处
(validateOmgReconcile 改 existsPaid||listUnpaid、validateOmgRefund→getLatestRefundableByDdId、
buildContext canReconcileOmg 重构 hasOmgActivity)。

校验:4 个 mapper XML(PosOrderMapper + 3 个 Line*)python ET.parse 通过、无原始 <;mvn compile BUILD SUCCESS。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 2 hafta önce
ebeveyn
işleme
b9a0514
91 değiştirilmiş dosya ile 7851 ekleme ve 85 silme
  1. 139 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreLinePayController.java
  2. 215 14
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  3. 57 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java
  4. 21 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java
  5. 52 6
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  6. 28 6
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  7. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/AdminOrderStatusContext.java
  8. 1 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPositionInfo.java
  9. 24 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayAsyncConfig.java
  10. 27 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayAsyncService.java
  11. 33 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayCancellationCompensationService.java
  12. 137 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java
  13. 199 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java
  14. 105 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayGatewayAuditService.java
  15. 66 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderGuard.java
  16. 76 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderNotificationService.java
  17. 365 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayRefundService.java
  18. 33 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayReturnPageRenderer.java
  19. 685 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  20. 15 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayCreateResult.java
  21. 9 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOrderRequest.java
  22. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayQueryResult.java
  23. 117 0
      ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java
  24. 63 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/ApacheLinePayHttpTransport.java
  25. 173 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java
  26. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayCredential.java
  27. 6 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayHttpResponse.java
  28. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayHttpTransport.java
  29. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayProperties.java
  30. 7 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayRequest.java
  31. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayResponse.java
  32. 35 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePaySigner.java
  33. 14 25
      ruoyi-admin/src/main/resources/application.yml
  34. 13 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  35. 13 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  36. 13 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  37. 13 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  38. 13 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  39. 107 0
      ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreLinePayControllerTest.java
  40. 162 2
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  41. 40 1
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderAdminStatusControllerTest.java
  42. 327 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java
  43. 90 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayControllerTest.java
  44. 47 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayGatewayAuditServiceTest.java
  45. 104 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOrderGuardTest.java
  46. 319 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReconcileTest.java
  47. 356 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayRefundTest.java
  48. 22 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReturnPageRendererTest.java
  49. 90 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePaySelectionTest.java
  50. 225 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java
  51. 103 0
      ruoyi-admin/src/test/java/com/ruoyi/app/task/LinePayReconcileTaskTest.java
  52. 121 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java
  53. 43 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePaySignerTest.java
  54. 38 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PaymentGatewayLog.java
  55. 1 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  56. 45 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLinePayment.java
  57. 39 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLineRefund.java
  58. 39 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreLinePay.java
  59. 12 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreLinePayCredentialDto.java
  60. 11 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreLinePayToggleDto.java
  61. 31 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreLinePayVo.java
  62. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PaymentGatewayLogMapper.java
  63. 58 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLinePaymentMapper.java
  64. 53 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLineRefundMapper.java
  65. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderMapper.java
  66. 20 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreLinePayMapper.java
  67. 8 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPaymentGatewayLogService.java
  68. 33 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLinePaymentService.java
  69. 27 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLineRefundService.java
  70. 18 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreLinePayService.java
  71. 29 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PaymentGatewayLogServiceImpl.java
  72. 179 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java
  73. 137 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImpl.java
  74. 158 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreLinePayServiceImpl.java
  75. 150 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml
  76. 118 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderLineRefundMapper.xml
  77. 84 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreLinePayMapper.xml
  78. 23 0
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  79. 81 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImplTest.java
  80. 73 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImplTest.java
  81. 92 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosStoreLinePayServiceImplTest.java
  82. 221 0
      specs/019-line-pay/brainstorm.md
  83. 6 4
      specs/019-line-pay/checklists/requirements.md
  84. 227 0
      specs/019-line-pay/contracts/api.md
  85. 211 0
      specs/019-line-pay/data-model.md
  86. 161 0
      specs/019-line-pay/plan.md
  87. 95 0
      specs/019-line-pay/quickstart.md
  88. 106 0
      specs/019-line-pay/research.md
  89. 42 22
      specs/019-line-pay/spec.md
  90. 92 0
      specs/019-line-pay/tasks.md
  91. 129 0
      updatesql/sql.md

+ 139 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreLinePayController.java

@@ -0,0 +1,139 @@
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayCredential;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.app.pay.LinePayGatewayAuditService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.domain.dto.StoreLinePayToggleDto;
+import com.ruoyi.system.domain.vo.PosStoreLinePayVo;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+/** 平台门店 LINE Pay 凭证版本管理。 */
+@RestController
+@RequestMapping("/system/storeLinePay")
+public class PosStoreLinePayController extends BaseController {
+
+    private final IPosStoreLinePayService credentialService;
+    private final LinePayClient linePayClient;
+    private final String environment;
+    private final LinePayGatewayAuditService auditService;
+
+    public PosStoreLinePayController(IPosStoreLinePayService credentialService,
+                                     LinePayClient linePayClient,
+                                     @Value("${line-pay.environment:sandbox}") String environment) {
+        this(credentialService, linePayClient, environment, null);
+    }
+
+    @Autowired
+    public PosStoreLinePayController(IPosStoreLinePayService credentialService,
+                                     LinePayClient linePayClient,
+                                     @Value("${line-pay.environment:sandbox}") String environment,
+                                     LinePayGatewayAuditService auditService) {
+        this.credentialService = credentialService;
+        this.linePayClient = linePayClient;
+        this.environment = environment.toUpperCase(Locale.ROOT);
+        this.auditService = auditService;
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam(required = false) String posName,
+                              @RequestParam(required = false) Integer isStall,
+                              @RequestParam(required = false) String credentialStatus,
+                              @RequestParam(required = false) Integer isEnabled) {
+        PosStoreLinePayVo query = new PosStoreLinePayVo();
+        query.setPosNameLike(posName);
+        query.setIsStall(isStall);
+        query.setCredentialStatus(credentialStatus);
+        query.setIsEnabled(isEnabled);
+        startPage();
+        List<PosStoreLinePayVo> rows = credentialService.selectStoreList(query);
+        // Mapper list contract deliberately does not select channel_secret.
+        return getDataTable(rows);
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:query')")
+    @GetMapping("/{storeId}")
+    public AjaxResult detail(@PathVariable Long storeId) {
+        return success(credentialService.selectStoreDetail(storeId));
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:saveCredentials')")
+    @Log(title = "LINE Pay credential", businessType = BusinessType.UPDATE)
+    @PutMapping("/saveCredentials")
+    public AjaxResult saveCredentials(@RequestBody StoreLinePayCredentialDto dto) {
+        if (dto == null || dto.getStoreId() == null || blank(dto.getChannelId())
+                || blank(dto.getChannelSecret())) {
+            return error(MessageUtils.message("line.pay.credential.required"));
+        }
+        LinePayResponse probe;
+        try {
+            LinePayCredential candidate = new LinePayCredential(null,
+                    dto.getChannelId().trim(), dto.getChannelSecret());
+            String probeOrderId = credentialProbeOrderId();
+            probe = auditService == null
+                    ? linePayClient.retrieveByOrderId(candidate, probeOrderId)
+                    : auditService.execute("CREDENTIAL_VERIFY", "ADMIN", null, null,
+                    null, dto.getStoreId(), null, probeOrderId, null,
+                    () -> linePayClient.retrieveByOrderId(candidate, probeOrderId));
+        } catch (Exception e) {
+            return error(MessageUtils.message("line.pay.credential.verify.unknown"));
+        }
+        if (!("1150".equals(probe.returnCode()) || validEmptySuccess(probe))) {
+            return error(MessageUtils.message("line.pay.credential.invalid"));
+        }
+        PosStoreLinePay saved = credentialService.saveVerifiedCredential(dto, environment,
+                probe.returnCode(), probe.returnMessage());
+        return success(MessageUtils.message("line.pay.credential.enabled"), saved);
+    }
+
+    @PreAuthorize("@ss.hasPermi('chanting:storeLinePay:toggleEnable')")
+    @Log(title = "LINE Pay enable", businessType = BusinessType.UPDATE)
+    @PutMapping("/toggleEnable")
+    public AjaxResult toggleEnable(@RequestBody StoreLinePayToggleDto dto) {
+        if (dto == null || dto.getStoreId() == null || dto.getEnabled() == null) {
+            return error(MessageUtils.message("line.pay.credential.required"));
+        }
+        return toAjax(credentialService.setCurrentEnabled(dto.getStoreId(), dto.getEnabled()));
+    }
+
+    private static boolean validEmptySuccess(LinePayResponse response) {
+        if (response == null || !response.isSuccess() || response.rawBody() == null) {
+            return false;
+        }
+        Object info = JSONObject.parseObject(response.rawBody()).get("info");
+        return info instanceof JSONArray && ((JSONArray) info).isEmpty();
+    }
+
+    private static String credentialProbeOrderId() {
+        return "LPVERIFY" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
+    }
+
+    private static boolean blank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+}

+ 215 - 14
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -6,14 +6,19 @@ import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
 import com.ruoyi.system.domain.PointsTransaction;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrderOmgPayment;
 import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
 import com.ruoyi.system.domain.UserWallet;
 import com.ruoyi.system.service.IPosOrderOmgPaymentService;
 import com.ruoyi.system.service.IPosOrderOmgRefundService;
 import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
 import com.ruoyi.system.service.IPointsTransactionService;
 import com.ruoyi.system.service.IUserWalletService;
 import com.ruoyi.system.utils.OrderLogHelper;
@@ -39,8 +44,9 @@ public class OrderLifecycleService {
     private final IPointsTransactionService pointsTransactionService;
     private final IPosOrderOmgPaymentService omgPaymentService;
     private final IPosOrderOmgRefundService omgRefundService;
+    private final IPosOrderLinePaymentService linePaymentService;
+    private final IPosOrderLineRefundService lineRefundService;
 
-    @Autowired
     public OrderLifecycleService(IPosOrderService posOrderService,
                                  OrderService billingService,
                                  OrderLogHelper orderLogHelper,
@@ -48,6 +54,20 @@ public class OrderLifecycleService {
                                  IPointsTransactionService pointsTransactionService,
                                  IPosOrderOmgPaymentService omgPaymentService,
                                  IPosOrderOmgRefundService omgRefundService) {
+        this(posOrderService, billingService, orderLogHelper, userWalletService,
+                pointsTransactionService, omgPaymentService, omgRefundService, null, null);
+    }
+
+    @Autowired
+    public OrderLifecycleService(IPosOrderService posOrderService,
+                                 OrderService billingService,
+                                 OrderLogHelper orderLogHelper,
+                                 IUserWalletService userWalletService,
+                                 IPointsTransactionService pointsTransactionService,
+                                 IPosOrderOmgPaymentService omgPaymentService,
+                                 IPosOrderOmgRefundService omgRefundService,
+                                 IPosOrderLinePaymentService linePaymentService,
+                                 IPosOrderLineRefundService lineRefundService) {
         this.posOrderService = posOrderService;
         this.billingService = billingService;
         this.orderLogHelper = orderLogHelper;
@@ -55,12 +75,100 @@ public class OrderLifecycleService {
         this.pointsTransactionService = pointsTransactionService;
         this.omgPaymentService = omgPaymentService;
         this.omgRefundService = omgRefundService;
+        this.linePaymentService = linePaymentService;
+        this.lineRefundService = lineRefundService;
     }
 
     public AdminOrderStatusContext getStatusContext(Long id) {
         return buildContext(requireOrder(id));
     }
 
+    @Transactional(rollbackFor = Exception.class)
+    public Long reserveLineRefund(Long id, String source) {
+        PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).last("FOR UPDATE"));
+        if (order == null) {
+            throw new ServiceException(lineMessage("line.pay.order.not.found", "LINE Pay order not found"));
+        }
+        if (!"3".equals(order.getPayType()) || Long.valueOf(3L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())) {
+            throw new ServiceException(lineMessage("line.pay.refund.not.allowed",
+                    "Current order state does not allow LINE Pay refund"));
+        }
+        PosOrderLinePayment payment = linePaymentService == null
+                ? null : selectLinePaymentForAdmin(
+                linePaymentService.getByDdId(String.valueOf(order.getDdId())));
+        if (payment == null || !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException(lineMessage("line.pay.refund.payment.not.found",
+                    "No refundable LINE Pay payment exists"));
+        }
+        PosOrderLineRefund refund = lineRefundService == null
+                ? null : lineRefundService.getByPaymentId(payment.getId());
+        if (refund != null && "REFUNDED".equals(refund.getStatus())) {
+            throw new ServiceException(lineMessage("line.pay.refund.already.completed",
+                    "LINE Pay payment is already refunded"));
+        }
+        lineRefundService.createIfAbsent(payment, source);
+        return payment.getId();
+    }
+
+    public boolean isRealLineOrder(PosOrder order) {
+        if (order == null || !"3".equals(order.getPayType()) || order.getDdId() == null
+                || linePaymentService == null) {
+            return false;
+        }
+        List<PosOrderLinePayment> attempts = linePaymentService
+                .getByDdId(String.valueOf(order.getDdId()));
+        return attempts != null && !attempts.isEmpty();
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void completePaidLineOrder(Long id) {
+        PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).last("FOR UPDATE"));
+        if (!isRealLineOrder(order) || !Long.valueOf(2L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())) {
+            throw new ServiceException(lineMessage("line.pay.order.completion.not.allowed",
+                    "Current LINE Pay order state does not allow completion"));
+        }
+        PosOrder update = new PosOrder();
+        update.setState(3L);
+        applyCas(order, update);
+        order.setState(3L);
+        completeSideEffects(order);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void acceptDeliveryByRider(Long id, Long riderId) {
+        PosOrder order = requireOrder(id);
+        if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(2L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null) {
+            throw conflict();
+        }
+        PosOrder update = new PosOrder();
+        update.setDeliveryStatus(1L);
+        update.setQsId(riderId);
+        applyCas(order, update);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void pickupDeliveryByRider(Long id, Long riderId, String riderImage) {
+        PosOrder order = requireOrder(id);
+        if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(2L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getQsId() == null || !order.getQsId().equals(riderId)) {
+            throw conflict();
+        }
+        PosOrder update = new PosOrder();
+        update.setDeliveryStatus(2L);
+        update.setQsImg(riderImage);
+        applyCas(order, update);
+    }
+
     public AdminOrderStatusContext validateOmgReconcile(Long id, AdminOrderActionRequest request) {
         requireReason(request);
         PosOrder order = requireOrder(id);
@@ -69,9 +177,11 @@ public class OrderLifecycleService {
                 || order.getState() == 4L || order.getAfterSaleStatus() != 0L) {
             throw new ServiceException("当前订单状态不允许 OMG 支付补单");
         }
-        PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
-        if (payment == null || !Integer.valueOf(0).equals(payment.getPayStatus())) {
-            throw new ServiceException("没有可查询的 OMG 未支付流水");
+        String ddId = String.valueOf(order.getDdId());
+        boolean hasPaid = omgPaymentService.existsPaidByDdId(ddId);
+        boolean hasUnpaid = !omgPaymentService.listUnpaidByDdId(ddId).isEmpty();
+        if (!hasPaid && !hasUnpaid) {
+            throw new ServiceException("没有可查询的 OMG 支付流水");
         }
         return buildContext(order);
     }
@@ -84,7 +194,7 @@ public class OrderLifecycleService {
             throw new ServiceException("该订单非 OMG 支付");
         }
         validateRefundableOrder(order);
-        PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
+        PosOrderOmgPayment payment = omgPaymentService.getLatestRefundableByDdId(String.valueOf(order.getDdId()));
         if (payment == null || (!Integer.valueOf(1).equals(payment.getPayStatus())
                 && !Integer.valueOf(3).equals(payment.getPayStatus()))) {
             throw new ServiceException("OMG 支付流水状态不允许退款");
@@ -260,6 +370,28 @@ public class OrderLifecycleService {
         return buildContext(order);
     }
 
+    @Transactional(rollbackFor = Exception.class)
+    public AdminOrderStatusContext finalizeSystemLineRefund(Long id) {
+        PosOrder order = requireOrder(id);
+        if (order.getState() == 4L && order.getPayStatus() == 2L && order.getAfterSaleStatus() == 3L) {
+            return buildContext(order);
+        }
+        if (!"3".equals(order.getPayType()) || order.getPayStatus() != 2L
+                || order.getAfterSaleStatus() != 0L || order.getState() == 3L) {
+            throw new ServiceException("LINE Pay refund order state requires reconciliation");
+        }
+        Long beforeState = order.getState();
+        PosOrder update = refundedOrderUpdate();
+        applyCas(order, update);
+        order.setState(4L);
+        order.setPayStatus(2L);
+        order.setAfterSaleStatus(3L);
+        refundPoints(order);
+        orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "system",
+                "LINE Pay refund synchronized: state " + beforeState + ", pay 1->2, afterSale 0->3");
+        return buildContext(order);
+    }
+
     @Transactional(rollbackFor = Exception.class)
     public AdminOrderStatusContext completeDeliveryByRider(Long id, Long riderId, String riderName) {
         return completeDeliveryByRider(id, riderId, riderName, null);
@@ -405,10 +537,18 @@ public class OrderLifecycleService {
                 .eq(PosOrder::getState, order.getState())
                 .eq(PosOrder::getPayStatus, order.getPayStatus())
                 .eq(PosOrder::getAfterSaleStatus, order.getAfterSaleStatus());
-        if (order.getDeliveryStatus() == null) {
-            wrapper.isNull(PosOrder::getDeliveryStatus);
-        } else {
-            wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
+        if (Long.valueOf(3L).equals(update.getState())) {
+            wrapper.notExists("SELECT 1 FROM pos_order_line_payment lp "
+                    + "JOIN pos_order_line_refund lr ON lr.payment_id = lp.id "
+                    + "WHERE lp.dd_id = pos_order.dd_id "
+                    + "AND lr.status <> 'FAILED'");
+        }
+        if (update.getDeliveryStatus() != null) {
+            if (order.getDeliveryStatus() == null) {
+                wrapper.isNull(PosOrder::getDeliveryStatus);
+            } else {
+                wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
+            }
         }
         if (!posOrderService.update(update, wrapper)) {
             throw conflict();
@@ -419,6 +559,14 @@ public class OrderLifecycleService {
         return new ServiceException("订单状态已变化,请刷新后重试");
     }
 
+    private String lineMessage(String key, String fallback) {
+        try {
+            return MessageUtils.message(key);
+        } catch (RuntimeException ignored) {
+            return fallback;
+        }
+    }
+
     private void completeSideEffects(PosOrder order) {
         billingService.setSanghuBilling(order);
         if (order.getType() == 0L && order.getQsId() != null) {
@@ -498,7 +646,9 @@ public class OrderLifecycleService {
                 && order.getPayStatus() == 1L);
 
         if (PAY_TYPE_OMG.equals(order.getPayType())) {
-            PosOrderOmgPayment payment = omgPaymentService.getLatestByDdId(String.valueOf(order.getDdId()));
+            String ddId = String.valueOf(order.getDdId());
+            // 退款/已付相关读可退款行(pay_status IN(1,3,4));未付款订单无此行 → payment=null
+            PosOrderOmgPayment payment = omgPaymentService.getLatestRefundableByDdId(ddId);
             if (payment != null) {
                 context.setOmgPaymentStatus(payment.getPayStatus());
                 List<PosOrderOmgRefund> refunds = omgRefundService.listByPayment(payment.getId());
@@ -506,8 +656,6 @@ public class OrderLifecycleService {
                 boolean manualPending = !manualDone && refunds.stream().anyMatch(this::isManualRefundPending);
                 context.setManualRefundPending(manualPending);
                 context.setRefundUnknown(Integer.valueOf(4).equals(payment.getPayStatus()));
-                context.setCanReconcileOmg(active && order.getPayStatus() == 0L
-                        && Integer.valueOf(0).equals(payment.getPayStatus()));
                 context.setCanRefundOmg(active && order.getPayStatus() == 1L
                         && (Integer.valueOf(1).equals(payment.getPayStatus())
                         || Integer.valueOf(3).equals(payment.getPayStatus()))
@@ -517,10 +665,50 @@ public class OrderLifecycleService {
                         || Integer.valueOf(3).equals(payment.getPayStatus()))
                         || manualDone && Integer.valueOf(3).equals(payment.getPayStatus())));
             }
+            // canReconcileOmg:订单未核销且仍有 OMG 流水(未付行可 queryTrade / 已付未核销可自愈),不依赖可退款行
+            boolean hasOmgActivity = payment != null || !omgPaymentService.listUnpaidByDdId(ddId).isEmpty();
+            context.setCanReconcileOmg(active && order.getPayStatus() == 0L && hasOmgActivity);
+        }
+        if (linePaymentService != null) {
+            List<PosOrderLinePayment> attempts = linePaymentService.getByDdId(String.valueOf(order.getDdId()));
+            PosOrderLinePayment line = selectLinePaymentForAdmin(attempts);
+            if (line != null) {
+                context.setLinePaymentId(line.getId());
+                context.setLinePaymentStatus(line.getStatus());
+                context.setLineTransactionId(line.getTransactionId());
+                PosOrderLineRefund refund = lineRefundService == null
+                        ? null : lineRefundService.getByPaymentId(line.getId());
+                context.setLineRefundStatus(refund == null ? null : refund.getStatus());
+                context.setCanReconcileLine(!"PAID".equals(line.getStatus())
+                        && !"CANCELLED_OR_EXPIRED".equals(line.getStatus())
+                        && !"FAILED".equals(line.getStatus()));
+                context.setCanRefundLine(!Long.valueOf(3L).equals(order.getState())
+                        && order.getPayStatus() == 1L
+                        && order.getAfterSaleStatus() == 0L && "PAID".equals(line.getStatus())
+                        && (refund == null || !"REFUNDED".equals(refund.getStatus())));
+            }
         }
         return context;
     }
 
+    private PosOrderLinePayment selectLinePaymentForAdmin(List<PosOrderLinePayment> attempts) {
+        List<PosOrderLinePayment> unrefundedPaid = attempts.stream()
+                .filter(payment -> "PAID".equals(payment.getStatus()))
+                .filter(payment -> {
+                    PosOrderLineRefund refund = lineRefundService == null
+                            ? null : lineRefundService.getByPaymentId(payment.getId());
+                    return refund == null || !"REFUNDED".equals(refund.getStatus());
+                }).toList();
+        if (unrefundedPaid.size() == 1) {
+            return unrefundedPaid.get(0);
+        }
+        if (unrefundedPaid.size() > 1) {
+            return null;
+        }
+        return attempts.stream().filter(payment -> payment.getActiveDdId() != null)
+                .findFirst().orElse(attempts.isEmpty() ? null : attempts.get(0));
+    }
+
     private List<Long> allowedOrderStates(PosOrder order) {
         List<Long> allowed = new ArrayList<>();
         allowed.add(order.getState());
@@ -529,14 +717,16 @@ public class OrderLifecycleService {
             return allowed;
         }
         if (order.getState() == 0L) {
-            if (!PAY_TYPE_OMG.equals(order.getPayType()) || order.getPayStatus() == 1L) {
+            if (!requiresOnlinePaymentBeforeProgress(order)) {
                 allowed.add(1L);
             }
             if (order.getPayStatus() == 0L) {
                 allowed.add(4L);
             }
         } else if (order.getState() == 1L) {
-            allowed.add(2L);
+            if (!requiresOnlinePaymentBeforeProgress(order)) {
+                allowed.add(2L);
+            }
             if (order.getPayStatus() == 0L) {
                 allowed.add(4L);
             }
@@ -547,6 +737,17 @@ public class OrderLifecycleService {
         return allowed;
     }
 
+    private boolean requiresOnlinePaymentBeforeProgress(PosOrder order) {
+        if (order.getPayStatus() == 1L) {
+            return false;
+        }
+        if (PAY_TYPE_OMG.equals(order.getPayType())) {
+            return true;
+        }
+        return "3".equals(order.getPayType()) && linePaymentService != null
+                && !linePaymentService.getByDdId(String.valueOf(order.getDdId())).isEmpty();
+    }
+
     private List<Long> allowedDeliveryStates(PosOrder order) {
         List<Long> allowed = new ArrayList<>();
         if (order.getType() != 0L) {

+ 57 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -16,6 +16,8 @@ import com.ruoyi.app.order.dto.PositionDto;
 import com.ruoyi.app.order.dto.QsDto;
 import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusContext;
+import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.app.pay.OmgPayController;
 import com.ruoyi.app.pay.dto.OmgRefundOutcome;
@@ -155,6 +157,14 @@ public class PosOrderController extends BaseController {
     private OrderLifecycleService orderLifecycleService;
     @Autowired
     private OmgPayController omgPayController;
+    @Autowired
+    private LinePayService linePayService;
+    @Autowired
+    private LinePayRefundService linePayRefundService;
+    @Autowired
+    private com.ruoyi.app.pay.LinePayOrderGuard linePayOrderGuard;
+    @Autowired
+    private IPosOrderLinePaymentService linePaymentService;
 
 
     //查询用户足迹
@@ -222,14 +232,25 @@ public class PosOrderController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String id = jwtUtil.getusid(token);
+        PosOrder persisted = posOrder.getId() == null ? null : posOrderService.getById(posOrder.getId());
+        boolean actualLineOrder = persisted != null && linePaymentService != null
+                && !linePaymentService.getByDdId(String.valueOf(persisted.getDdId())).isEmpty();
+        if (actualLineOrder) {
+            throw new ServiceException(lineMessage("line.pay.legacy.endpoint.disabled",
+                    "LINE Pay order must use the dedicated order operation endpoint"));
+        }
         if (posOrder.getState() != null) {
+            if (posOrder.getId() != null && posOrder.getState() != 0L) {
+                linePayOrderGuard.requirePaidBeforeAccept(persisted);
+            }
             //状态为送达,设置送达时间
             if (posOrder.getState() == 12) {
                 posOrder.setSdTime(new Date());
             }
             System.out.println("修改订单状态信息:" + JSON.toJSONString(posOrder));
             //设置支付类型为货到付款
-            if (posOrder.getState() == 0 && "1".equals(posOrder.getCollectPayment())) {
+            if (!actualLineOrder && posOrder.getState() == 0
+                    && "1".equals(posOrder.getCollectPayment())) {
                 System.out.println("进入设置paytype等于1");
                 posOrder.setPayType("1");
             }
@@ -1483,6 +1504,41 @@ public class PosOrderController extends BaseController {
         return success(orderLifecycleService.getStatusContext(id));
     }
 
+    @PreAuthorize("@ss.hasPermi('system:order:linePaymentReconcile')")
+    @RepeatSubmit(interval = 2000, message = "查询过于频繁")
+    @PostMapping("/{id}/line-payment/reconcile")
+    public AjaxResult adminReconcileLinePayment(@PathVariable Long id) {
+        AdminOrderStatusContext context = orderLifecycleService.getStatusContext(id);
+        if (context.getLinePaymentId() == null) {
+            throw new ServiceException("LINE Pay 流水不存在");
+        }
+        String status = linePayService.reconcilePayment(context.getLinePaymentId(), "ADMIN");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("status", status);
+        data.put("context", orderLifecycleService.getStatusContext(id));
+        return success("LINE Pay 状态查询完成", data);
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:order:lineRefund')")
+    @RepeatSubmit(interval = 2000, message = "请求过于频繁")
+    @PostMapping("/{id}/line-refund")
+    public AjaxResult adminRefundLinePayment(@PathVariable Long id) {
+        Long paymentId = orderLifecycleService.reserveLineRefund(id, "ADMIN");
+        String status = linePayRefundService.requestFullRefund(paymentId, "ADMIN");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("status", status);
+        data.put("context", orderLifecycleService.getStatusContext(id));
+        return success("LINE Pay 退款处理完成", data);
+    }
+
+    private String lineMessage(String key, String fallback) {
+        try {
+            return MessageUtils.message(key);
+        } catch (RuntimeException ignored) {
+            return fallback;
+        }
+    }
+
     @PreAuthorize("@ss.hasPermi('system:order:edit')")
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @PutMapping("/{id}/status")

+ 21 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -77,6 +77,7 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
+        requireRider(qsId);
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, id));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -110,6 +111,7 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
+        requireRider(qsId);
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, input.getId()));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -140,6 +142,7 @@ public class PosOrderQsOprateController extends BaseController {
         JwtUtil jwtUtil = new JwtUtil();
         PayPush push = new PayPush();
         String qsId = jwtUtil.getusid(token);
+        requireRider(qsId);
         PosOrder order = posOrderService.getOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getId, input.getId()));
         if (order == null) {
             throw new ServiceException(MessageUtils.message("no.order.not.found"));
@@ -167,7 +170,7 @@ public class PosOrderQsOprateController extends BaseController {
             if (lock.tryLock(20L, 10L, TimeUnit.SECONDS)) {
                 boolean releaseInfinally = true;
                 try {
-                    userService.checkUserStatus(Long.valueOf(qsId));
+                    requireRider(qsId);
                     PosOrder orst = posOrderService.getById(posOrder.getId());
                     if (orst.getDeliveryStatus() != null && orst.getDeliveryStatus() == 1L && posOrder.getDeliveryStatus() != null && posOrder.getDeliveryStatus() == 1L) {
                         throw new ServiceException(MessageUtils.message("no.order.snatched"));
@@ -195,8 +198,13 @@ public class PosOrderQsOprateController extends BaseController {
                                 qsName, posOrder.getQsImg());
                         posOrder.setState(3L);
                         org = true;
+                    } else if (Long.valueOf(1L).equals(posOrder.getDeliveryStatus())) {
+                        orderLifecycleService.acceptDeliveryByRider(posOrder.getId(), Long.valueOf(qsId));
+                        org = true;
                     } else {
-                        org = posOrderService.saveOrUpdate(posOrder);
+                        orderLifecycleService.pickupDeliveryByRider(posOrder.getId(), Long.valueOf(qsId),
+                                posOrder.getQsImg());
+                        org = true;
                     }
                     if (org) {
                         //到付订单,骑手送达时设置用户账单为完成
@@ -271,6 +279,17 @@ public class PosOrderQsOprateController extends BaseController {
         }
     }
 
+    private InfoUser requireRider(String userId) {
+        if (userId == null) {
+            throw new ServiceException(MessageUtils.message("no.wallet.noexist.userinfo"));
+        }
+        InfoUser user = userService.checkUserStatus(Long.valueOf(userId));
+        if (!"2".equals(user.getUserType())) {
+            throw new ServiceException(MessageUtils.message("rider.operation.role.required"));
+        }
+        return user;
+    }
+
     /**
      * 更新用户用户账单
      *

+ 52 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -10,6 +10,8 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.common.annotation.Anonymous;
@@ -60,6 +62,14 @@ public class PosOrderShOprateController extends BaseController {
     private OrderService orderService;
     @Autowired
     private OrderInvoiceService orderInvoiceService;
+    @Autowired
+    private LinePayOrderGuard linePayOrderGuard;
+    @Autowired
+    private LinePayRefundService linePayRefundService;
+    @Autowired
+    private IPosOrderLinePaymentService linePaymentService;
+    @Autowired
+    private OrderLifecycleService orderLifecycleService;
 
 
 
@@ -245,6 +255,9 @@ public class PosOrderShOprateController extends BaseController {
         if (order == null || order.getState() == null || order.getState() != 0L) {
             throw new ServiceException("订单不存在或状态不允许接单");
         }
+        InfoUser currentUser = currentMerchant(token);
+        linePayOrderGuard.requireMerchantOwnership(order, currentUser);
+        linePayOrderGuard.requirePaidBeforeAccept(order);
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(1L);
@@ -267,6 +280,8 @@ public class PosOrderShOprateController extends BaseController {
         if (order == null || order.getState() == null || order.getState() != 1L) {
             throw new ServiceException("订单不存在或状态不允许出餐");
         }
+        linePayOrderGuard.requireMerchantOwnership(order, currentMerchant(token));
+        linePayOrderGuard.requirePaidBeforeAccept(order);
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setState(2L);
@@ -300,21 +315,29 @@ public class PosOrderShOprateController extends BaseController {
         if (order == null || order.getState() == null || order.getState() != 2L) {
             throw new ServiceException("订单不存在或状态不允许完成");
         }
+        linePayOrderGuard.requireMerchantOwnership(order, currentMerchant(token));
+        linePayOrderGuard.requirePaidBeforeAccept(order);
         // 仅限自取(type=1)或堂食(type=2)
         if (order.getType() == null || (order.getType() != 1L && order.getType() != 2L)) {
             throw new ServiceException("仅自取和堂食订单支持此操作");
         }
-        PosOrder update = new PosOrder();
-        update.setId(order.getId());
-        update.setState(3L);
-        update.setPayStatus(1L);
-        posOrderService.saveOrUpdate(update);
+        if (orderLifecycleService.isRealLineOrder(order)) {
+            orderLifecycleService.completePaidLineOrder(order.getId());
+        } else {
+            PosOrder update = new PosOrder();
+            update.setId(order.getId());
+            update.setState(3L);
+            update.setPayStatus(1L);
+            posOrderService.saveOrUpdate(update);
+        }
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已完成订单");
         // 商家完成订单时生成商家账单
         PosOrder fullOrder = posOrderService.getById(order.getId());
-        orderService.setSanghuBilling(fullOrder);
+        if (!orderLifecycleService.isRealLineOrder(order)) {
+            orderService.setSanghuBilling(fullOrder);
+        }
         return AjaxResult.success();
     }
 
@@ -329,6 +352,8 @@ public class PosOrderShOprateController extends BaseController {
         if (order == null || order.getState() == null) {
             throw new ServiceException("订单不存在");
         }
+        InfoUser currentUser = currentMerchant(token);
+        linePayOrderGuard.requireMerchantOwnership(order, currentUser);
         if (order.getState() != 0L && order.getState() != 1L) {
             throw new ServiceException("当前状态不允许取消");
         }
@@ -352,12 +377,33 @@ public class PosOrderShOprateController extends BaseController {
                 logger.warn("OMG商家取消订单退款异常: ddId={}", latest.getDdId(), e);
             }
         }
+        requestLineRefundIfPaid(latest, "STORE_CANCEL");
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "取消订单");
         return AjaxResult.success();
     }
 
+    private InfoUser currentMerchant(String token) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        InfoUser user = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
+                .eq(InfoUser::getUserId, userId));
+        if (user == null) {
+            throw new ServiceException("用户信息不存在");
+        }
+        return user;
+    }
+
+    private void requestLineRefundIfPaid(PosOrder order, String source) {
+        if (order == null || !Long.valueOf(1L).equals(order.getPayStatus())) {
+            return;
+        }
+        linePaymentService.getByDdId(String.valueOf(order.getDdId())).stream()
+                .filter(payment -> "PAID".equals(payment.getStatus()))
+                .findFirst()
+                .ifPresent(payment -> linePayRefundService.createRefundIntent(payment.getId(), source));
+    }
+
     /**
      * 商家出餐推送:给用户和骑手推送通知
      */

+ 28 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderCreatItem;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.app.utils.DateUtil;
 import com.ruoyi.app.utils.OperatingUtil;
 import com.ruoyi.app.utils.PayPush;
@@ -78,6 +79,12 @@ public class UserOrderController extends BaseController {
     private OrderInvoiceService orderInvoiceService;
     @Autowired
     private IInfoInvoiceService infoInvoiceService;
+    @Autowired
+    private IPosOrderLinePaymentService linePaymentService;
+    @Autowired
+    private LinePayRefundService linePayRefundService;
+    @Autowired
+    private OrderLifecycleService orderLifecycleService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -626,8 +633,12 @@ public class UserOrderController extends BaseController {
         if (order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > 0) {
             return error("订单存在售后处理中,不可确认取餐");
         }
-        order.setState(3L);
-        posOrderService.updateById(order);
+        if (orderLifecycleService.isRealLineOrder(order)) {
+            orderLifecycleService.completePaidLineOrder(order.getId());
+        } else {
+            order.setState(3L);
+            posOrderService.updateById(order);
+        }
 
         // 确认取餐成功,添加操作日志
         InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(userId)));
@@ -664,10 +675,14 @@ public class UserOrderController extends BaseController {
         if (order.getAfterSaleStatus() != null && order.getAfterSaleStatus() > 0) {
             return error("订单存在售后处理中,不可确认完成");
         }
-        PosOrder update = new PosOrder();
-        update.setId(order.getId());
-        update.setState(3L);
-        posOrderService.updateById(update);
+        if (orderLifecycleService.isRealLineOrder(order)) {
+            orderLifecycleService.completePaidLineOrder(order.getId());
+        } else {
+            PosOrder update = new PosOrder();
+            update.setId(order.getId());
+            update.setState(3L);
+            posOrderService.updateById(update);
+        }
 
         // 用户确认完成,添加操作日志
         InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(userId)));
@@ -718,6 +733,13 @@ public class UserOrderController extends BaseController {
                 logger.warn("OMG取消订单退款异常: ddId={}", latest.getDdId(), e);
             }
         }
+        if (latest != null && Long.valueOf(1L).equals(latest.getPayStatus())) {
+            linePaymentService.getByDdId(String.valueOf(latest.getDdId())).stream()
+                    .filter(payment -> "PAID".equals(payment.getStatus()))
+                    .findFirst()
+                    .ifPresent(payment -> linePayRefundService
+                            .createRefundIntent(payment.getId(), "USER_CANCEL"));
+        }
 
         // 取消订单成功,添加操作日志
         InfoUser uUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(userId)));

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/AdminOrderStatusContext.java

@@ -33,4 +33,10 @@ public class AdminOrderStatusContext {
     private Integer omgPaymentStatus;
     private Boolean manualRefundPending = false;
     private Boolean refundUnknown = false;
+    private Long linePaymentId;
+    private String linePaymentStatus;
+    private String lineRefundStatus;
+    private String lineTransactionId;
+    private Boolean canReconcileLine = false;
+    private Boolean canRefundLine = false;
 }

+ 1 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPositionInfo.java

@@ -31,7 +31,7 @@ public class OrderPositionInfo {
     /** 出餐状态*/
     private Long diningStatus;
 
-    /** 支付类型:1 货到付款 2 vnpay 3 zalopay */
+    /** 支付类型:1 货到付款,2 VNPAY,3 LINE Pay,7 OMG */
     private String payType;
     /**
      * 骑手位置

+ 24 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayAsyncConfig.java

@@ -0,0 +1,24 @@
+package com.ruoyi.app.pay;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.ThreadPoolExecutor;
+
+/** Dedicated redirect recovery executor that never falls back to the browser request thread. */
+@Configuration
+public class LinePayAsyncConfig {
+
+    @Bean(name = "linePayTaskExecutor")
+    public ThreadPoolTaskExecutor linePayTaskExecutor() {
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        executor.setCorePoolSize(2);
+        executor.setMaxPoolSize(8);
+        executor.setQueueCapacity(100);
+        executor.setThreadNamePrefix("line-pay-");
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
+        executor.initialize();
+        return executor;
+    }
+}

+ 27 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayAsyncService.java

@@ -0,0 +1,27 @@
+package com.ruoyi.app.pay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+/** Runs redirect-triggered recovery outside the browser request thread. */
+@Service
+public class LinePayAsyncService {
+    private static final Logger log = LoggerFactory.getLogger(LinePayAsyncService.class);
+
+    private final LinePayService linePayService;
+
+    public LinePayAsyncService(LinePayService linePayService) {
+        this.linePayService = linePayService;
+    }
+
+    @Async("linePayTaskExecutor")
+    public void reconcile(Long paymentId, String source) {
+        try {
+            linePayService.reconcilePayment(paymentId, source);
+        } catch (Exception exception) {
+            log.warn("LINE Pay asynchronous reconcile failed, paymentId={}", paymentId);
+        }
+    }
+}

+ 33 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayCancellationCompensationService.java

@@ -0,0 +1,33 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/** Repairs a cancellation crash gap by creating any missing refund intent for captured payments. */
+@Service
+public class LinePayCancellationCompensationService {
+    private final IPosOrderLinePaymentService paymentService;
+    private final IPosOrderLineRefundService refundService;
+
+    public LinePayCancellationCompensationService(IPosOrderLinePaymentService paymentService,
+                                                  IPosOrderLineRefundService refundService) {
+        this.paymentService = paymentService;
+        this.refundService = refundService;
+    }
+
+    public void createMissingRefundIntents(List<PosOrderLinePayment> payments) {
+        if (payments == null) {
+            return;
+        }
+        payments.stream().filter(payment -> "PAID".equals(payment.getStatus()))
+                .forEach(payment -> refundService.createIfAbsent(payment, "CANCEL_SCAN"));
+    }
+
+    public void createMissingRefundIntent(String ddId) {
+        createMissingRefundIntents(paymentService.getByDdId(ddId));
+    }
+}

+ 137 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java

@@ -0,0 +1,137 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.pay.dto.LinePayOrderRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/** App and browser entry points for LINE Pay. */
+@RestController
+@RequestMapping("/pay/line")
+public class LinePayController {
+    private final LinePayService linePayService;
+    private final LinePayAsyncService asyncService;
+    private final LinePayReturnPageRenderer pageRenderer;
+    private final LinePayGatewayAuditService auditService;
+
+    public LinePayController(LinePayService linePayService,
+                             LinePayAsyncService asyncService,
+                             LinePayReturnPageRenderer pageRenderer) {
+        this(linePayService, asyncService, pageRenderer, null);
+    }
+
+    @org.springframework.beans.factory.annotation.Autowired
+    public LinePayController(LinePayService linePayService,
+                             LinePayAsyncService asyncService,
+                             LinePayReturnPageRenderer pageRenderer,
+                             LinePayGatewayAuditService auditService) {
+        this.linePayService = linePayService;
+        this.asyncService = asyncService;
+        this.pageRenderer = pageRenderer;
+        this.auditService = auditService;
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/create")
+    public AjaxResult create(@RequestHeader String token,
+                             @RequestBody(required = false) LinePayOrderRequest request) {
+        if (request == null || request.getDdId() == null || request.getDdId().trim().isEmpty()) {
+            return AjaxResult.error(MessageUtils.message("line.pay.order.required"));
+        }
+        try {
+            Long userId = Long.valueOf(new JwtUtil().getusid(token));
+            return AjaxResult.success(linePayService.create(userId, request.getDdId().trim()));
+        } catch (Exception exception) {
+            return AjaxResult.error(MessageUtils.message("line.pay.operation.failed"));
+        }
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/query")
+    public AjaxResult query(@RequestHeader String token,
+                            @RequestBody(required = false) LinePayOrderRequest request) {
+        if (request == null || request.getDdId() == null || request.getDdId().trim().isEmpty()) {
+            return AjaxResult.error(MessageUtils.message("line.pay.order.required"));
+        }
+        try {
+            Long userId = Long.valueOf(new JwtUtil().getusid(token));
+            return AjaxResult.success(linePayService.query(userId, request.getDdId().trim()));
+        } catch (Exception exception) {
+            return AjaxResult.error(MessageUtils.message("line.pay.operation.failed"));
+        }
+    }
+
+    @Anonymous
+    @GetMapping("/confirm")
+    public ResponseEntity<String> confirm(@RequestParam String orderId,
+                                          @RequestParam String transactionId) {
+        PosOrderLinePayment payment = linePayService.findByLineOrderId(orderId, transactionId);
+        if (payment != null) {
+            auditReturn(payment, "REDIRECT_CONFIRM");
+            schedule(payment, "CONFIRM_RETURN");
+        }
+        return html(payment == null ? null : payment.getDdId());
+    }
+
+    @Anonymous
+    @GetMapping("/cancel")
+    public ResponseEntity<String> cancel(
+            @RequestParam(required = false) String orderId,
+            @RequestParam(required = false) String transactionId) {
+        PosOrderLinePayment payment = orderId == null ? null
+                : linePayService.findByLineOrderId(orderId, transactionId);
+        if (payment != null) {
+            auditReturn(payment, "REDIRECT_CANCEL");
+            schedule(payment, "CANCEL_RETURN");
+        }
+        return html(payment == null ? null : payment.getDdId());
+    }
+
+    private ResponseEntity<String> html(String ddId) {
+        String nonce = UUID.randomUUID().toString().replace("-", "");
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
+        headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
+        headers.set("Pragma", "no-cache");
+        headers.set("Referrer-Policy", "no-referrer");
+        headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "
+                + "script-src 'nonce-" + nonce + "'; img-src data:; base-uri 'none'; "
+                + "form-action 'none'; frame-ancestors 'none'");
+        headers.set("X-Content-Type-Options", "nosniff");
+        return ResponseEntity.ok().headers(headers).body(pageRenderer.render(ddId, nonce));
+    }
+
+    private void schedule(PosOrderLinePayment payment, String source) {
+        try {
+            asyncService.reconcile(payment.getId(), source);
+        } catch (RuntimeException ignored) {
+            // The durable scheduled reconciler will process the already-persisted payment intent.
+        }
+    }
+
+    private void auditReturn(PosOrderLinePayment payment, String action) {
+        if (auditService != null) {
+            auditService.event(action, "BROWSER", payment.getId(), payment.getCredentialId(),
+                    payment.getStoreId(), payment.getDdId(), payment.getLineOrderId(),
+                    payment.getTransactionId());
+        }
+    }
+}

+ 199 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java

@@ -0,0 +1,199 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.app.order.OrderLifecycleService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+/** Commits gateway-proven payment facts and creates cancellation compensation intents. */
+@Service
+public class LinePayFactService {
+    private final IPosOrderService orderService;
+    private final IPosOrderLinePaymentService paymentService;
+    private final IPosOrderLineRefundService refundService;
+    private final PosOrderMapper orderMapper;
+    private final LinePayOrderNotificationService notificationService;
+    private final OrderLifecycleService orderLifecycleService;
+
+    public LinePayFactService(IPosOrderService orderService,
+                              IPosOrderLinePaymentService paymentService,
+                              IPosOrderLineRefundService refundService) {
+        this(orderService, paymentService, refundService, null, null, null);
+    }
+
+    public LinePayFactService(IPosOrderService orderService,
+                              IPosOrderLinePaymentService paymentService,
+                              IPosOrderLineRefundService refundService,
+                              PosOrderMapper orderMapper) {
+        this(orderService, paymentService, refundService, orderMapper, null, null);
+    }
+
+    @org.springframework.beans.factory.annotation.Autowired
+    public LinePayFactService(IPosOrderService orderService,
+                              IPosOrderLinePaymentService paymentService,
+                              IPosOrderLineRefundService refundService,
+                              PosOrderMapper orderMapper,
+                              LinePayOrderNotificationService notificationService,
+                              OrderLifecycleService orderLifecycleService) {
+        this.orderService = orderService;
+        this.paymentService = paymentService;
+        this.refundService = refundService;
+        this.orderMapper = orderMapper;
+        this.notificationService = notificationService;
+        this.orderLifecycleService = orderLifecycleService;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void applyPaidFact(Long paymentId, String transactionId,
+                              String paymentProvider, Date payTime) {
+        applyPaidFactInternal(paymentId, transactionId, paymentProvider, payTime, true);
+    }
+
+    private void applyPaidFactInternal(Long paymentId, String transactionId,
+                                       String paymentProvider, Date payTime,
+                                       boolean notifyOrder) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null) {
+            throw new ServiceException("LINE Pay payment not found");
+        }
+        int marked = paymentService.markPaid(paymentId, payment.getVersion(), payment.getStatus(),
+                transactionId, paymentProvider, payTime);
+        if (marked != 1 && !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException("LINE Pay payment state changed concurrently");
+        }
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>()
+                .eq("dd_id", payment.getDdId()).last("FOR UPDATE"));
+        if (order == null) {
+            throw new ServiceException("LINE Pay order not found");
+        }
+        if (orderMapper != null) {
+            int orderMarked = orderMapper.markLinePaid(payment.getDdId(), payment.getId());
+            if (orderMarked == 1 && !Long.valueOf(4L).equals(order.getState())
+                    && notifyOrder && notificationService != null) {
+                notificationService.paymentCaptured(order);
+            } else if (orderMarked != 1 && !Long.valueOf(4L).equals(order.getState())
+                    && !isOnlyRecordedLinePayment(order, payment)) {
+                refundService.createIfAbsent(payment, "DUPLICATE_PAYMENT");
+            }
+        } else {
+            orderService.update(new UpdateWrapper<PosOrder>()
+                    .eq("id", order.getId())
+                    .set("pay_status", 1L));
+        }
+        payment.setStatus("PAID");
+        payment.setTransactionId(transactionId);
+        if (Long.valueOf(4L).equals(order.getState())) {
+            refundService.createIfAbsent(payment, "TASK");
+        }
+    }
+
+    /** Records the captured gateway fact but blocks fulfillment for ambiguous refund evidence. */
+    @Transactional(rollbackFor = Exception.class)
+    public void applyPaidWithRefundReviewFact(Long paymentId, String transactionId,
+                                              String paymentProvider, Date payTime) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null) {
+            throw new ServiceException("LINE Pay payment not found");
+        }
+        int marked = paymentService.markPaid(paymentId, payment.getVersion(), payment.getStatus(),
+                transactionId, paymentProvider, payTime);
+        if (marked != 1 && !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException("LINE Pay payment state changed concurrently");
+        }
+        payment.setStatus("PAID");
+        payment.setTransactionId(transactionId);
+        PosOrderLineRefund refund = refundService.createIfAbsent(payment, "RETRIEVE");
+        if (refund == null) {
+            throw new ServiceException("LINE Pay refund review state could not be created");
+        }
+        if (!"MANUAL_REVIEW".equals(refund.getStatus()) && !"REFUNDED".equals(refund.getStatus())
+                && refundService.markManualReviewFromEvidence(refund.getId(), refund.getVersion(),
+                refund.getStatus()) != 1) {
+            throw new ServiceException("LINE Pay refund review state changed concurrently");
+        }
+    }
+
+    private boolean isOnlyRecordedLinePayment(PosOrder order, PosOrderLinePayment payment) {
+        if (!"3".equals(order.getPayType()) || !Long.valueOf(1L).equals(order.getPayStatus())) {
+            return false;
+        }
+        return paymentService.getByDdId(payment.getDdId()).stream()
+                .filter(row -> "PAID".equals(row.getStatus()))
+                .allMatch(row -> payment.getId().equals(row.getId()));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void applyRefundFact(Long paymentId) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null || !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException("LINE Pay payment not found or not captured");
+        }
+        if (orderMapper != null) {
+            int orderMarked = orderMapper.markLineRefunded(payment.getDdId(), payment.getId());
+            PosOrder latest = orderService.getOne(new QueryWrapper<PosOrder>()
+                    .eq("dd_id", payment.getDdId()).last("FOR UPDATE"));
+            if (orderMarked == 1 || latest != null && Long.valueOf(2L).equals(latest.getPayStatus())) {
+                if (orderLifecycleService != null && latest != null) {
+                    orderLifecycleService.finalizeSystemLineRefund(latest.getId());
+                }
+            } else if (latest == null || LinePayService.PAY_TYPE_LINE.equals(latest.getPayType())
+                    && !hasAnotherUnrefundedPayment(payment)) {
+                throw new ServiceException("LINE Pay refund order state requires reconciliation");
+            }
+        } else {
+            orderService.update(new UpdateWrapper<PosOrder>()
+                    .eq("dd_id", payment.getDdId())
+                    .eq("pay_status", 1L)
+                    .set("pay_status", 2L));
+        }
+    }
+
+    private boolean hasAnotherUnrefundedPayment(PosOrderLinePayment refundedPayment) {
+        return paymentService.getByDdId(refundedPayment.getDdId()).stream()
+                .filter(row -> "PAID".equals(row.getStatus()))
+                .filter(row -> !refundedPayment.getId().equals(row.getId()))
+                .anyMatch(row -> {
+                    PosOrderLineRefund refund = refundService.getByPaymentId(row.getId());
+                    return refund == null || !"REFUNDED".equals(refund.getStatus());
+                });
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void applyRefundedFact(Long refundId, Long paymentId, Long refundVersion,
+                                  String refundTransactionId, Date refundTime) {
+        if (refundService.markRefunded(refundId, refundVersion,
+                refundTransactionId, refundTime) != 1) {
+            throw new ServiceException("LINE Pay refund state changed concurrently");
+        }
+        applyRefundFact(paymentId);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void applyPaidAndRefundedFact(Long paymentId, String transactionId,
+                                         String paymentProvider, Date payTime,
+                                         String refundTransactionId, Date refundTime) {
+        applyPaidFactInternal(paymentId, transactionId, paymentProvider, payTime, false);
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        PosOrderLineRefund refund = refundService.createIfAbsent(payment, "RETRIEVE");
+        if ("REFUNDED".equals(refund.getStatus())) {
+            applyRefundFact(paymentId);
+            return;
+        }
+        if (refundService.markRefundedFromEvidence(refund.getId(), refund.getVersion(),
+                refund.getStatus(), refundTransactionId, refundTime) != 1) {
+            throw new ServiceException("LINE Pay refund evidence state changed concurrently");
+        }
+        applyRefundFact(paymentId);
+    }
+}

+ 105 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayGatewayAuditService.java

@@ -0,0 +1,105 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.system.domain.PaymentGatewayLog;
+import com.ruoyi.system.service.IPaymentGatewayLogService;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import org.springframework.stereotype.Service;
+
+import java.util.UUID;
+
+/** Appends LINE gateway audit rows without making audit availability part of money facts. */
+@Service
+public class LinePayGatewayAuditService {
+    private final IPaymentGatewayLogService gatewayLogService;
+
+    public LinePayGatewayAuditService(IPaymentGatewayLogService gatewayLogService) {
+        this.gatewayLogService = gatewayLogService;
+    }
+
+    public <T> T execute(String action, String source, Long paymentId, Long refundId,
+                         Long credentialId, Long storeId, String ddId,
+                         String gatewayOrderId, String transactionId,
+                         GatewayCall<T> call) throws Exception {
+        long started = System.currentTimeMillis();
+        String correlationId = UUID.randomUUID().toString();
+        append(correlationId, action, source, paymentId, refundId, credentialId,
+                storeId, ddId, gatewayOrderId, transactionId, 0, 0L, null, "REQUEST");
+        try {
+            T result = call.call();
+            append(correlationId, action, source, paymentId, refundId, credentialId,
+                    storeId, ddId, gatewayOrderId, transactionId, 1,
+                    System.currentTimeMillis() - started,
+                    result instanceof LinePayResponse ? (LinePayResponse) result : null, "RESPONSE");
+            return result;
+        } catch (Exception exception) {
+            append(correlationId, action, source, paymentId, refundId, credentialId,
+                    storeId, ddId, gatewayOrderId, transactionId, 0,
+                    System.currentTimeMillis() - started, null, "RESPONSE");
+            throw exception;
+        }
+    }
+
+    public void event(String action, String source, Long paymentId, Long credentialId,
+                      Long storeId, String ddId, String gatewayOrderId, String transactionId) {
+        append(UUID.randomUUID().toString(), action, source, paymentId, null, credentialId,
+                storeId, ddId, gatewayOrderId, transactionId, 1, 0L, null, "EVENT");
+    }
+
+    private void append(String correlationId, String action, String source,
+                        Long paymentId, Long refundId, Long credentialId, Long storeId,
+                        String ddId, String gatewayOrderId, String transactionId,
+                        int success, long durationMs, LinePayResponse response, String direction) {
+        PaymentGatewayLog log = new PaymentGatewayLog();
+        log.setCorrelationId(correlationId);
+        log.setAction(action);
+        log.setDirection(direction);
+        log.setSource(source);
+        log.setPaymentId(paymentId);
+        log.setRefundId(refundId);
+        log.setCredentialId(credentialId);
+        log.setStoreId(storeId);
+        log.setDdId(ddId);
+        log.setGatewayOrderId(gatewayOrderId);
+        log.setTransactionId(response != null && response.transactionId() != null
+                ? response.transactionId() : transactionId);
+        log.setHttpStatus(response == null ? null : response.httpStatus());
+        log.setReturnCode(response == null ? null : response.returnCode());
+        log.setReturnMessage(response == null ? null : response.returnMessage());
+        log.setPayload(response == null ? null : safePayload(response.rawBody()));
+        log.setSuccess(response == null ? success : response.isSuccess() ? 1 : 0);
+        log.setDurationMs(durationMs);
+        try {
+            gatewayLogService.append(log);
+        } catch (Exception ignored) {
+            // Audit failure must not alter an already known gateway or payment outcome.
+        }
+    }
+
+    private static String safePayload(String rawBody) {
+        if (rawBody == null || rawBody.isBlank()) {
+            return rawBody;
+        }
+        try {
+            JSONObject root = JSON.parseObject(rawBody);
+            JSONObject info = root.getJSONObject("info");
+            if (info != null) {
+                info.remove("paymentAccessToken");
+                JSONObject paymentUrl = info.getJSONObject("paymentUrl");
+                if (paymentUrl != null) {
+                    paymentUrl.remove("app");
+                    paymentUrl.remove("web");
+                }
+            }
+            return root.toJSONString();
+        } catch (RuntimeException ignored) {
+            return null;
+        }
+    }
+
+    @FunctionalInterface
+    public interface GatewayCall<T> {
+        T call() throws Exception;
+    }
+}

+ 66 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOrderGuard.java

@@ -0,0 +1,66 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/** Enforces merchant ownership and prevents accepting an unpaid real LINE order. */
+@Service
+public class LinePayOrderGuard {
+    private final IPosOrderLinePaymentService paymentService;
+
+    public LinePayOrderGuard(IPosOrderLinePaymentService paymentService) {
+        this.paymentService = paymentService;
+    }
+
+    public void requireMerchantOwnership(PosOrder order, InfoUser user) {
+        if (order == null || user == null || user.getUserId() == null) {
+            throw new ServiceException("No permission to operate this order");
+        }
+        if ("0".equals(user.getUserType()) || "2".equals(user.getUserType())) {
+            throw new ServiceException("No permission to operate this order");
+        }
+        boolean merchant = "1".equals(user.getUserType()) || "3".equals(user.getUserType());
+        boolean owned = merchant
+                ? user.getUserId().equals(order.getShId())
+                : user.getStoreId() != null && user.getStoreId().equals(order.getMdId());
+        if (!owned) {
+            throw new ServiceException("No permission to operate this order");
+        }
+    }
+
+    public void requirePaidBeforeAccept(PosOrder order) {
+        if (order == null || !LinePayService.PAY_TYPE_LINE.equals(order.getPayType())
+                || Long.valueOf(1L).equals(order.getPayStatus())) {
+            return;
+        }
+        List<PosOrderLinePayment> attempts = paymentService.getByDdId(String.valueOf(order.getDdId()));
+        if (attempts != null && !attempts.isEmpty()) {
+            throw new ServiceException("LINE Pay order is not paid yet");
+        }
+    }
+
+    /** Applies ownership rules to the still-supported legacy order-state endpoint. */
+    public void requireLegacyOrderActor(PosOrder order, InfoUser user, Long targetState) {
+        if (user == null || user.getUserId() == null) {
+            throw new ServiceException("No permission to operate this order");
+        }
+        if ("2".equals(user.getUserType())) {
+            boolean riderState = Long.valueOf(3L).equals(targetState)
+                    || Long.valueOf(4L).equals(targetState)
+                    || Long.valueOf(12L).equals(targetState);
+            boolean assignedRider = order != null && order.getQsId() != null
+                    && user.getUserId().equals(order.getQsId());
+            if (!riderState || !assignedRider) {
+                throw new ServiceException("No permission to operate this order");
+            }
+            return;
+        }
+        requireMerchantOwnership(order, user);
+    }
+}

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

@@ -0,0 +1,76 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.app.order.dto.OrderPushBodyDto;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.app.utils.event.PushEventService;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.utils.OrderLogHelper;
+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;
+
+/** Executes the existing one-time order log and push side effects after LINE payment capture. */
+@Service
+public class LinePayOrderNotificationService {
+    private static final Logger log = LoggerFactory.getLogger(LinePayOrderNotificationService.class);
+
+    private final IInfoUserService infoUserService;
+    private final PushEventService pushEventService;
+    private final OrderLogHelper orderLogHelper;
+
+    public LinePayOrderNotificationService(IInfoUserService infoUserService,
+                                           PushEventService pushEventService,
+                                           OrderLogHelper orderLogHelper) {
+        this.infoUserService = infoUserService;
+        this.pushEventService = pushEventService;
+        this.orderLogHelper = orderLogHelper;
+    }
+
+    public void paymentCaptured(PosOrder order) {
+        orderLogHelper.logSync(String.valueOf(order.getDdId()), 0, null, "system",
+                "LINE Pay payment captured");
+        runAfterCommit(() -> pushPaymentSuccess(order));
+    }
+
+    private void pushPaymentSuccess(PosOrder order) {
+        try {
+            String ddId = String.valueOf(order.getDdId());
+            String title = MessageUtils.message("no.message.push.message");
+            String body = OrderPushBodyDto.getJson(ddId, String.valueOf(order.getState()), 0);
+            InfoUser user = order.getUserId() == null ? null : infoUserService.getById(order.getUserId());
+            if (user != null) {
+                new PayPush().apppush(user.getCid(), title,
+                        MessageUtils.message("no.message.push.payment.success"), body);
+                pushEventService.PublisherEvent(user.getUserId(), title,
+                        MessageUtils.message("no.message.push.payment.success"), body);
+            }
+            InfoUser merchant = order.getShId() == null ? null : infoUserService.getById(order.getShId());
+            if (merchant != null) {
+                new PayPush().shpush(merchant.getCid(), title,
+                        MessageUtils.message("no.message.push.new.order"), body);
+                pushEventService.PublisherEvent(merchant.getUserId(), title,
+                        MessageUtils.message("no.message.push.new.order"), body);
+            }
+        } catch (Exception exception) {
+            log.error("LINE Pay payment success push failed, ddId={}", order.getDdId(), exception);
+        }
+    }
+
+    private static void runAfterCommit(Runnable action) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            action.run();
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCommit() {
+                action.run();
+            }
+        });
+    }
+}

+ 365 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayRefundService.java

@@ -0,0 +1,365 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayCredential;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+
+import java.util.Date;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+/** Executes idempotent full refunds and recovers uncertain refund outcomes by Retrieve. */
+@Service
+public class LinePayRefundService {
+    private static final Set<String> RETRYABLE_CODES = Set.of("1900", "1902", "1999");
+    private static final Set<String> UNCERTAIN_CODES = Set.of(
+            "1164", "1165", "1198", "1199", "9000");
+    private static final Set<String> CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106");
+
+    private final IPosOrderLinePaymentService paymentService;
+    private final IPosOrderLineRefundService refundService;
+    private final IPosStoreLinePayService credentialService;
+    private final LinePayClient linePayClient;
+    private final LinePayFactService factService;
+    private final LinePayGatewayAuditService auditService;
+    @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
+    private long unknownDeadlineHours = 24L;
+    @Value("${line-pay.reconcile.row-lease-seconds:120}")
+    private long rowLeaseSeconds = 120L;
+
+    public LinePayRefundService(IPosOrderLinePaymentService paymentService,
+                                IPosOrderLineRefundService refundService,
+                                IPosStoreLinePayService credentialService,
+                                LinePayClient linePayClient,
+                                LinePayFactService factService) {
+        this(paymentService, refundService, credentialService, linePayClient, factService, null);
+    }
+
+    @Autowired
+    public LinePayRefundService(IPosOrderLinePaymentService paymentService,
+                                IPosOrderLineRefundService refundService,
+                                IPosStoreLinePayService credentialService,
+                                LinePayClient linePayClient,
+                                LinePayFactService factService,
+                                LinePayGatewayAuditService auditService) {
+        this.paymentService = paymentService;
+        this.refundService = refundService;
+        this.credentialService = credentialService;
+        this.linePayClient = linePayClient;
+        this.factService = factService;
+        this.auditService = auditService;
+    }
+
+    public String requestFullRefund(Long paymentId, String source) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null || !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException("Only a captured LINE Pay payment can be refunded");
+        }
+        PosOrderLineRefund refund = refundService.createIfAbsent(payment, source);
+        if ("REFUNDED".equals(refund.getStatus())) {
+            factService.applyRefundFact(payment.getId());
+            return "REFUNDED";
+        }
+        if (refund.getReconcileDeadline() != null
+                && !new Date().before(refund.getReconcileDeadline())) {
+            if ("PROCESSING".equals(refund.getStatus())) {
+                refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
+                        shortly(), unknownDeadline());
+                refund = refundService.getByPaymentId(paymentId);
+            }
+            if (refund == null) {
+                throw new ServiceException("LINE Pay refund state requires reconciliation");
+            }
+            String recovered = recoverUnknown(payment, refund, credentialFor(payment), false);
+            if ("REFUNDED".equals(recovered)) {
+                return recovered;
+            }
+            if ("MANUAL_REVIEW".equals(recovered)) {
+                return recovered;
+            }
+            return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
+                    refund.getStatus());
+        }
+        LinePayCredential credential = credentialFor(payment);
+        if ("PROCESSING".equals(refund.getStatus())) {
+            if (refund.getLeaseUntil() != null && refund.getLeaseUntil().before(new Date())
+                    && refundService.recoverExpiredProcessing(refund.getId(), refund.getVersion(),
+                    shortly(), unknownDeadline()) == 1) {
+                refund = refundService.getByPaymentId(paymentId);
+            } else {
+                return "PROCESSING";
+            }
+        }
+        if ("UNKNOWN".equals(refund.getStatus())) {
+            return recoverUnknown(payment, refund, credential);
+        }
+        if (!("CREATED".equals(refund.getStatus()) || "RETRY_WAIT".equals(refund.getStatus()))) {
+            return refund.getStatus();
+        }
+        String owner = (source == null ? "LINE" : source) + "-" + UUID.randomUUID();
+        if (refundService.claimProcessing(refund.getId(), refund.getVersion(), refund.getStatus(),
+                owner, leaseUntil()) != 1) {
+            return refund.getStatus();
+        }
+        long processingVersion = refund.getVersion() + 1;
+        try {
+            RefundEvidenceResult evidenceResult = retrieveRefundEvidence(
+                    payment, refund, credential, source);
+            RefundEvidence existingRefund = evidenceResult.evidence();
+            LinePayCredential actionCredential = evidenceResult.credential();
+            if (existingRefund.fullRefundTransactionId() != null) {
+                factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
+                        existingRefund.fullRefundTransactionId(), new Date());
+                return "REFUNDED";
+            }
+            if (existingRefund.hasAnyRefund()) {
+                return markManualReview(refund.getPaymentId(), refund.getId(), processingVersion,
+                        "PROCESSING");
+            }
+            if (!existingRefund.definitelyNotRefunded()) {
+                refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
+                return "UNKNOWN";
+            }
+            LinePayResponse response = gatewayCall("REFUND", source, payment, refund,
+                    actionCredential.id(),
+                    () -> linePayClient.refundFull(actionCredential, payment.getTransactionId()));
+            String refundTransactionId = response.info() == null
+                    ? null : response.info().getString("refundTransactionId");
+            if (response.isSuccess() && refundTransactionId != null
+                    ) {
+                factService.applyRefundedFact(refund.getId(), payment.getId(), processingVersion,
+                        refundTransactionId, new Date());
+                return "REFUNDED";
+            }
+            if (response != null && RETRYABLE_CODES.contains(response.returnCode())) {
+                refundService.markRetryWait(refund.getId(), processingVersion, shortly());
+                return "RETRY_WAIT";
+            }
+            if (response != null && response.httpStatus() >= 200 && response.httpStatus() < 300
+                    && response.returnCode() != null
+                    && !UNCERTAIN_CODES.contains(response.returnCode())) {
+                refundService.markFailed(refund.getId(), processingVersion);
+                return "FAILED";
+            }
+        } catch (Exception ignored) {
+            // The gateway may have accepted the request; only Retrieve may decide its outcome.
+        }
+        refundService.markUnknown(refund.getId(), processingVersion, shortly(), unknownDeadline());
+        return "UNKNOWN";
+    }
+
+    private LinePayCredential credentialFor(PosOrderLinePayment payment) {
+        PosStoreLinePay credentialVersion = credentialService.getById(payment.getCredentialId());
+        if (credentialVersion == null) {
+            throw new ServiceException("LINE Pay credential version not found");
+        }
+        return new LinePayCredential(credentialVersion.getId(), credentialVersion.getChannelId(),
+                credentialVersion.getChannelSecret());
+    }
+
+    /** Creates the durable idempotent intent; the scheduled worker performs the gateway call. */
+    public String createRefundIntent(Long paymentId, String source) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null || !"PAID".equals(payment.getStatus())) {
+            throw new ServiceException("Only a captured LINE Pay payment can be refunded");
+        }
+        return refundService.createIfAbsent(payment, source).getStatus();
+    }
+
+    private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
+                                  LinePayCredential credential) {
+        return recoverUnknown(payment, refund, credential, true);
+    }
+
+    private String recoverUnknown(PosOrderLinePayment payment, PosOrderLineRefund refund,
+                                  LinePayCredential credential, boolean reschedule) {
+        try {
+            RefundEvidence evidence = retrieveRefundEvidence(
+                    payment, refund, credential, "TASK").evidence();
+            if (evidence.fullRefundTransactionId() != null) {
+                factService.applyRefundedFact(refund.getId(), payment.getId(), refund.getVersion(),
+                        evidence.fullRefundTransactionId(), new Date());
+                return "REFUNDED";
+            }
+            if (evidence.hasAnyRefund()) {
+                return markManualReview(refund.getPaymentId(), refund.getId(), refund.getVersion(),
+                        refund.getStatus());
+            }
+        } catch (Exception ignored) {
+            // Keep UNKNOWN; a later scheduled Retrieve can recover it.
+        }
+        if (reschedule) {
+            Date deadline = refund.getReconcileDeadline() == null
+                    ? unknownDeadline() : refund.getReconcileDeadline();
+            refundService.rescheduleUnknown(refund.getId(), refund.getVersion(), shortly(), deadline);
+        }
+        return "UNKNOWN";
+    }
+
+    private String markManualReview(Long paymentId, Long refundId, Long version,
+                                    String expectedStatus) {
+        if (refundService.markManualReview(refundId, version, expectedStatus) == 1) {
+            return "MANUAL_REVIEW";
+        }
+        PosOrderLineRefund current = refundService.getByPaymentId(paymentId);
+        if (current == null) {
+            throw new ServiceException("LINE Pay refund state requires reconciliation");
+        }
+        if ("MANUAL_REVIEW".equals(current.getStatus()) || "REFUNDED".equals(current.getStatus())) {
+            return current.getStatus();
+        }
+        if (("UNKNOWN".equals(current.getStatus()) || "PROCESSING".equals(current.getStatus()))
+                && refundService.markManualReview(current.getId(), current.getVersion(),
+                current.getStatus()) == 1) {
+            return "MANUAL_REVIEW";
+        }
+        PosOrderLineRefund latest = refundService.getByPaymentId(paymentId);
+        return latest == null ? current.getStatus() : latest.getStatus();
+    }
+
+    private RefundEvidenceResult retrieveRefundEvidence(PosOrderLinePayment payment,
+                                                         PosOrderLineRefund refund,
+                                                         LinePayCredential credential,
+                                                         String source) throws Exception {
+        LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment, refund,
+                credential.id(),
+                () -> linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
+        RefundEvidence evidence = refundEvidence(retrieve, payment);
+        if (!isCredentialAuthFailure(retrieve)) {
+            return new RefundEvidenceResult(evidence, credential);
+        }
+        PosStoreLinePay original = credentialService.getById(payment.getCredentialId());
+        PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId());
+        if (original == null || current == null || Objects.equals(current.getId(), original.getId())
+                || !Objects.equals(current.getChannelId(), original.getChannelId())
+                || !Objects.equals(current.getEnvironment(), original.getEnvironment())) {
+            return new RefundEvidenceResult(evidence, credential);
+        }
+        LinePayCredential currentCredential = new LinePayCredential(current.getId(),
+                current.getChannelId(), current.getChannelSecret());
+        LinePayResponse proof = gatewayCall("RETRIEVE", source, payment, refund,
+                currentCredential.id(),
+                () -> linePayClient.retrieveByTransactionId(
+                        currentCredential, payment.getTransactionId()));
+        RefundEvidence currentEvidence = refundEvidence(proof, payment);
+        return currentEvidence.conclusive()
+                ? new RefundEvidenceResult(currentEvidence, currentCredential)
+                : new RefundEvidenceResult(evidence, credential);
+    }
+
+    private static boolean isCredentialAuthFailure(LinePayResponse response) {
+        return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
+    }
+
+    private static RefundEvidence refundEvidence(LinePayResponse response, PosOrderLinePayment expected) {
+        if (response == null || !response.isSuccess() || response.rawBody() == null) {
+            return RefundEvidence.INCONCLUSIVE;
+        }
+        JSONArray info = JSONObject.parseObject(response.rawBody()).getJSONArray("info");
+        if (info == null || info.size() != 1) {
+            return RefundEvidence.INCONCLUSIVE;
+        }
+        JSONObject payment = info.getJSONObject(0);
+        if (payment == null || !expected.getTransactionId().equals(payment.getString("transactionId"))
+                || !expected.getLineOrderId().equals(payment.getString("orderId"))
+                || !"PAYMENT".equals(payment.getString("transactionType"))
+                || !expected.getCurrency().equals(payment.getString("currency"))
+                || !expected.getAmount().equals(paymentAmount(payment))) {
+            return RefundEvidence.INCONCLUSIVE;
+        }
+        JSONArray refunds = payment.getJSONArray("refundList");
+        if (refunds == null || refunds.isEmpty()) {
+            return RefundEvidence.DEFINITELY_NOT_REFUNDED;
+        }
+        long refundedAmount = 0L;
+        String lastRefundTransactionId = null;
+        for (int i = 0; i < refunds.size(); i++) {
+            JSONObject item = refunds.getJSONObject(i);
+            if (item == null) {
+                return RefundEvidence.INCONCLUSIVE;
+            }
+            String refundTransactionId = item.getString("refundTransactionId");
+            Integer amount = item.getInteger("refundAmount");
+            if (refundTransactionId == null || amount == null || amount == 0) {
+                return RefundEvidence.INCONCLUSIVE;
+            }
+            refundedAmount += Math.abs((long) amount);
+            lastRefundTransactionId = refundTransactionId;
+        }
+        return new RefundEvidence(true,
+                refundedAmount == expected.getAmount() ? lastRefundTransactionId : null, false);
+    }
+
+    private static Integer paymentAmount(JSONObject transaction) {
+        JSONArray payInfo = transaction.getJSONArray("payInfo");
+        if (payInfo == null || payInfo.isEmpty()) {
+            return null;
+        }
+        long total = 0L;
+        for (int i = 0; i < payInfo.size(); i++) {
+            JSONObject item = payInfo.getJSONObject(i);
+            Integer amount = item == null ? null : item.getInteger("amount");
+            if (amount == null || amount <= 0) {
+                return null;
+            }
+            total += amount;
+        }
+        return total <= Integer.MAX_VALUE ? (int) total : null;
+    }
+
+    private record RefundEvidence(boolean hasAnyRefund, String fullRefundTransactionId,
+                                  boolean definitelyNotRefunded) {
+        private static final RefundEvidence DEFINITELY_NOT_REFUNDED =
+                new RefundEvidence(false, null, true);
+        private static final RefundEvidence INCONCLUSIVE = new RefundEvidence(false, null, false);
+
+        private boolean conclusive() {
+            return hasAnyRefund || definitelyNotRefunded;
+        }
+    }
+
+    private record RefundEvidenceResult(RefundEvidence evidence, LinePayCredential credential) {
+    }
+
+    private static Date shortly() {
+        return new Date(System.currentTimeMillis() + 30_000L);
+    }
+
+    private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
+                              PosOrderLineRefund refund,
+                              LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
+        return gatewayCall(action, source, payment, refund, payment.getCredentialId(), call);
+    }
+
+    private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
+                              PosOrderLineRefund refund, Long credentialId,
+                              LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
+        if (auditService == null) {
+            return call.call();
+        }
+        return auditService.execute(action, source == null ? "LINE" : source,
+                payment.getId(), refund.getId(), credentialId, payment.getStoreId(),
+                payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call);
+    }
+
+    private Date unknownDeadline() {
+        return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L);
+    }
+
+    private Date leaseUntil() {
+        return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L);
+    }
+}

+ 33 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayReturnPageRenderer.java

@@ -0,0 +1,33 @@
+package com.ruoyi.app.pay;
+
+import org.springframework.stereotype.Component;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/** Self-contained redirect page; no separately deployed server page is required. */
+@Component
+public class LinePayReturnPageRenderer {
+
+    public String render(String ddId) {
+        return render(ddId, "");
+    }
+
+    public String render(String ddId, String nonce) {
+        String encoded = URLEncoder.encode(ddId == null ? "" : ddId, StandardCharsets.UTF_8)
+                .replace("+", "%20");
+        String deepLink = "com.twanmsdyh.app://payment/result?orderId=" + encoded;
+        return "<!doctype html><html><head><meta charset=\"utf-8\">"
+                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+                + "<title>LINE Pay</title><style>body{font-family:system-ui,sans-serif;margin:0;"
+                + "background:#f5f7fa;color:#202124}.card{max-width:420px;margin:12vh auto;padding:32px;"
+                + "background:#fff;border-radius:16px;box-shadow:0 8px 30px #00000012;text-align:center}"
+                + "a{display:inline-block;margin-top:18px;padding:12px 22px;border-radius:8px;"
+                + "background:#06c755;color:#fff;text-decoration:none}</style></head><body>"
+                + "<main class=\"card\"><h1>Payment is being confirmed</h1>"
+                + "<p>请返回 App 查询最终支付结果,请勿重复付款。</p>"
+                + "<a id=\"open-app\" href=\"" + deepLink + "\">Open app</a></main>"
+                + "<script nonce=\"" + nonce + "\">setTimeout(function(){location.href=document.getElementById('open-app').href},80)</script>"
+                + "</body></html>";
+    }
+}

+ 685 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java

@@ -0,0 +1,685 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONArray;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.pay.dto.LinePayCreateResult;
+import com.ruoyi.app.pay.dto.LinePayQueryResult;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayCredential;
+import com.ruoyi.app.utils.linepay.LinePayRequest;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Value;
+
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+/** LINE Pay create, query, confirm, retrieval and payment-fact orchestration. */
+@Service
+public class LinePayService {
+
+    public static final String PAY_TYPE_LINE = "3";
+    private static final List<String> DEFINITE_REQUEST_FAILURE_CODES = List.of(
+            "1104", "1105", "1106", "1124", "1178", "1183", "1194", "2101", "2102");
+    private static final Set<String> CREDENTIAL_AUTH_FAILURE_CODES = Set.of("1104", "1105", "1106");
+    @Value("${line-pay.reconcile.auth-deadline-minutes:30}")
+    private long authDeadlineMinutes = 30L;
+    @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
+    private long unknownDeadlineHours = 24L;
+    @Value("${line-pay.reconcile.row-lease-seconds:120}")
+    private long rowLeaseSeconds = 120L;
+
+    private final IPosOrderService orderService;
+    private final IPosStoreLinePayService credentialService;
+    private final IPosOrderLinePaymentService paymentService;
+    private final IPosOrderLineRefundService refundService;
+    private final LinePayClient linePayClient;
+    private final LinePayFactService factService;
+    private final LinePayGatewayAuditService auditService;
+    private final PaymentCreateGuardService createGuard;
+
+    public LinePayService(IPosOrderService orderService,
+                          IPosStoreLinePayService credentialService,
+                          IPosOrderLinePaymentService paymentService,
+                          IPosOrderLineRefundService refundService,
+                          LinePayClient linePayClient,
+                          LinePayFactService factService) {
+        this(orderService, credentialService, paymentService, refundService,
+                linePayClient, factService, null, null);
+    }
+
+    @Autowired
+    public LinePayService(IPosOrderService orderService,
+                          IPosStoreLinePayService credentialService,
+                          IPosOrderLinePaymentService paymentService,
+                          IPosOrderLineRefundService refundService,
+                          LinePayClient linePayClient,
+                          LinePayFactService factService,
+                          LinePayGatewayAuditService auditService,
+                          PaymentCreateGuardService createGuard) {
+        this.orderService = orderService;
+        this.credentialService = credentialService;
+        this.paymentService = paymentService;
+        this.refundService = refundService;
+        this.linePayClient = linePayClient;
+        this.factService = factService;
+        this.auditService = auditService;
+        this.createGuard = createGuard;
+    }
+
+    public LinePayCreateResult create(Long userId, String ddId) {
+        if (createGuard != null) {
+            return createGuard.withLock(ddId, () -> createUnderLock(userId, ddId));
+        }
+        return createUnderLock(userId, ddId);
+    }
+
+    private LinePayCreateResult createUnderLock(Long userId, String ddId) {
+        PosOrder order = requirePayableOrder(userId, ddId);
+        requireSingleStoreOrder(order);
+        PosStoreLinePay credential = credentialService.getEnabledCurrent(order.getMdId());
+        if (credential == null) {
+            throw new ServiceException("LINE Pay is not enabled for this store");
+        }
+        PosOrderLinePayment active = paymentService.getActiveByDdId(ddId);
+        if (active != null) {
+            return createResult(active, true);
+        }
+
+        IntentSelection selection = createIntent(order, credential);
+        PosOrderLinePayment intent = selection.payment();
+        if (!selection.created()) {
+            return createResult(intent, true);
+        }
+        try {
+            LinePayResponse response = gatewayCall("REQUEST", "APP", intent,
+                    () -> linePayClient.request(toCredential(credential),
+                            new LinePayRequest(intent.getLineOrderId(), ddId,
+                                    "Order " + ddId, order.getAmount(), "TWD")));
+            if (!response.isSuccess() || response.transactionId() == null) {
+                if (isDefiniteRequestFailure(response)) {
+                    if (paymentService.markTerminal(intent.getId(), intent.getVersion(),
+                            "REQUESTING", "FAILED") == 1) {
+                        intent.setStatus("FAILED");
+                        intent.setActiveDdId(null);
+                        return createResult(intent, false);
+                    }
+                    PosOrderLinePayment latest = paymentService.getById(intent.getId());
+                    if (latest != null) {
+                        return createResult(latest, true);
+                    }
+                    throw new ServiceException("LINE Pay payment state requires reconciliation");
+                }
+                paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
+                        shortly(), unknownDeadline());
+                intent.setStatus("REQUEST_UNKNOWN");
+                return createResult(intent, false);
+            }
+            JSONObject paymentUrl = response.info() == null
+                    ? null : response.info().getJSONObject("paymentUrl");
+            String web = paymentUrl == null ? null : paymentUrl.getString("web");
+            String app = paymentUrl == null ? null : paymentUrl.getString("app");
+            if (web == null || paymentService.markRequestSucceeded(intent.getId(), intent.getVersion(),
+                    response.transactionId(), web, app, shortly(), authDeadline()) != 1) {
+                paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
+                        shortly(), unknownDeadline());
+                intent.setStatus("REQUEST_UNKNOWN");
+                return createResult(intent, false);
+            }
+            intent.setTransactionId(response.transactionId());
+            intent.setPaymentUrlWeb(web);
+            intent.setPaymentUrlApp(app);
+            intent.setStatus("WAITING_AUTH");
+            return createResult(intent, false);
+        } catch (Exception unknown) {
+            paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
+                    shortly(), unknownDeadline());
+            intent.setStatus("REQUEST_UNKNOWN");
+            return createResult(intent, false);
+        }
+    }
+
+    public LinePayQueryResult query(Long userId, String ddId) {
+        PosOrder order = requireOwnedOrder(userId, ddId);
+        List<PosOrderLinePayment> attempts = paymentService.getByDdId(ddId);
+        PosOrderLinePayment selected = selectForApp(order, attempts);
+        LinePayQueryResult result = new LinePayQueryResult();
+        result.setDdId(ddId);
+        result.setOrderPayStatus(order.getPayStatus());
+        if (selected != null) {
+            result.setPaymentId(selected.getId());
+            result.setPaymentStatus(selected.getStatus());
+            result.setTransactionId(selected.getTransactionId());
+            result.setUpdatedAt(selected.getUpdateTime());
+            PosOrderLineRefund refund = refundService.getByPaymentId(selected.getId());
+            result.setRefundStatus(refund == null ? null : refund.getStatus());
+        }
+        return result;
+    }
+
+    public PosOrderLinePayment findByLineOrderId(String lineOrderId, String transactionId) {
+        PosOrderLinePayment payment = paymentService.getByLineOrderId(lineOrderId);
+        if (payment == null) {
+            return null;
+        }
+        if (transactionId != null && payment.getTransactionId() != null
+                && !transactionId.equals(payment.getTransactionId())) {
+            return null;
+        }
+        return payment;
+    }
+
+    public String reconcilePayment(Long paymentId, String source) {
+        return reconcilePayment(paymentId, source, null);
+    }
+
+    public String reconcilePayment(Long paymentId, String source, String reconcileLeaseOwner) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null) {
+            throw new ServiceException("LINE Pay payment not found");
+        }
+        if (payment.getReconcileDeadline() != null
+                && !new Date().before(payment.getReconcileDeadline())) {
+            return finalDeadlineReconcile(payment, source);
+        }
+        PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
+        if (credential == null) {
+            throw new ServiceException("LINE Pay credential version not found");
+        }
+        try {
+            RetrieveResult retrieveResult = retrieveWithCredentialRecovery(
+                    payment, source, credential, toCredential(credential));
+            LinePayResponse retrieve = retrieveResult.response();
+            LinePayCredential gatewayCredential = retrieveResult.credential();
+            JSONObject captured = capturedPayment(retrieve, payment);
+            if (captured != null) {
+                String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
+                if (fullRefundTransactionId != null) {
+                    factService.applyPaidAndRefundedFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
+                            fullRefundTransactionId, new Date());
+                    return "REFUNDED";
+                }
+                if (hasRefundEvidence(captured)) {
+                    if (isLocallyRefunded(payment.getId())) {
+                        return "REFUNDED";
+                    }
+                    factService.applyPaidWithRefundReviewFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date());
+                    return "MANUAL_REVIEW";
+                }
+                factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
+                        paymentProvider(retrieve), new Date());
+                return "PAID";
+            }
+            if (!("1150".equals(retrieve.returnCode()) || retrieve.isSuccess())) {
+                return payment.getStatus();
+            }
+            if (!"1150".equals(retrieve.returnCode())
+                    || payment.getTransactionId() == null
+                    || !canCheckOrConfirm(payment.getStatus())) {
+                return payment.getStatus();
+            }
+            LinePayResponse check = gatewayCall("CHECK", source, payment,
+                    () -> linePayClient.check(gatewayCredential, payment.getTransactionId()));
+            return handleCheck(payment, gatewayCredential, check, source, reconcileLeaseOwner);
+        } catch (Exception unknown) {
+            if ("CONFIRMING".equals(payment.getStatus())) {
+                paymentService.markConfirmUnknown(payment.getId(), payment.getVersion(),
+                        shortly(), unknownDeadline());
+                return "CONFIRM_UNKNOWN";
+            }
+            return payment.getStatus();
+        }
+    }
+
+    private String finalDeadlineReconcile(PosOrderLinePayment payment, String source) {
+        PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
+        if (credential == null) {
+            paymentService.markManualReview(payment.getId(), payment.getVersion(), payment.getStatus());
+            return "MANUAL_REVIEW";
+        }
+        try {
+            RetrieveResult retrieveResult = retrieveWithCredentialRecovery(
+                    payment, source, credential, toCredential(credential));
+            LinePayCredential gatewayCredential = retrieveResult.credential();
+            LinePayResponse retrieve = retrieveResult.response();
+            JSONObject captured = capturedPayment(retrieve, payment);
+            if (captured != null) {
+                String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
+                if (fullRefundTransactionId != null) {
+                    factService.applyPaidAndRefundedFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
+                            fullRefundTransactionId, new Date());
+                    return "REFUNDED";
+                }
+                if (hasRefundEvidence(captured)) {
+                    if (isLocallyRefunded(payment.getId())) {
+                        return "REFUNDED";
+                    }
+                    factService.applyPaidWithRefundReviewFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date());
+                    return "MANUAL_REVIEW";
+                }
+                factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
+                        paymentProvider(retrieve), new Date());
+                return "PAID";
+            }
+            if ("1150".equals(retrieve.returnCode()) && payment.getTransactionId() != null
+                    && canCheckOrConfirm(payment.getStatus())) {
+                LinePayResponse check = gatewayCall("CHECK", source, payment,
+                        () -> linePayClient.check(gatewayCredential, payment.getTransactionId()));
+                if ("0121".equals(check.returnCode()) || "0122".equals(check.returnCode())) {
+                    String terminal = "0121".equals(check.returnCode())
+                            ? "CANCELLED_OR_EXPIRED" : "FAILED";
+                    paymentService.markTerminal(payment.getId(), payment.getVersion(),
+                            payment.getStatus(), terminal);
+                    return terminal;
+                }
+            }
+        } catch (Exception ignored) {
+            // The final read was inconclusive; stop automatic scanning for manual review.
+        }
+        paymentService.markManualReview(payment.getId(), payment.getVersion(), payment.getStatus());
+        return "MANUAL_REVIEW";
+    }
+
+    private String handleCheck(PosOrderLinePayment payment, LinePayCredential credential,
+                               LinePayResponse check, String source,
+                               String reconcileLeaseOwner) throws Exception {
+        String code = check.returnCode();
+        if ("0000".equals(code)) {
+            return payment.getStatus();
+        }
+        if ("0121".equals(code) || "0122".equals(code)) {
+            String terminal = "0121".equals(code) ? "CANCELLED_OR_EXPIRED" : "FAILED";
+            paymentService.markTerminal(payment.getId(), payment.getVersion(), payment.getStatus(), terminal);
+            return terminal;
+        }
+        if ("0123".equals(code)) {
+            LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment,
+                    () -> payment.getTransactionId() == null
+                            ? linePayClient.retrieveByOrderId(credential, payment.getLineOrderId())
+                            : linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
+            JSONObject captured = capturedPayment(retrieve, payment);
+            if (captured != null) {
+                String fullRefundTransactionId = fullRefundTransactionId(captured, payment.getAmount());
+                if (fullRefundTransactionId != null) {
+                    factService.applyPaidAndRefundedFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date(),
+                            fullRefundTransactionId, new Date());
+                    return "REFUNDED";
+                }
+                if (hasRefundEvidence(captured)) {
+                    if (isLocallyRefunded(payment.getId())) {
+                        return "REFUNDED";
+                    }
+                    factService.applyPaidWithRefundReviewFact(payment.getId(),
+                            captured.getString("transactionId"), paymentProvider(retrieve), new Date());
+                    return "MANUAL_REVIEW";
+                }
+                factService.applyPaidFact(payment.getId(), captured.getString("transactionId"),
+                        paymentProvider(retrieve), new Date());
+                return "PAID";
+            }
+            return payment.getStatus();
+        }
+        if (!"0110".equals(code)) {
+            return payment.getStatus();
+        }
+        if (!canCheckOrConfirm(payment.getStatus())) {
+            return payment.getStatus();
+        }
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", payment.getDdId()));
+        if (order == null || !isPayableOrderState(order)) {
+            paymentService.markAuthDoneOrderCancelled(payment.getId(), payment.getVersion(),
+                    payment.getStatus(), shortly(), authDeadline());
+            return "AUTH_DONE_ORDER_CANCELLED";
+        }
+        if (!LinePayService.PAY_TYPE_LINE.equals(order.getPayType())
+                || Long.valueOf(1L).equals(order.getPayStatus())
+                || !order.getMdId().equals(payment.getStoreId())
+                || !order.getAmount().equals(payment.getAmount())) {
+            return payment.getStatus();
+        }
+        String leaseOwner = reconcileLeaseOwner == null
+                ? (source == null ? "LINE" : source) + "-" + UUID.randomUUID()
+                : reconcileLeaseOwner;
+        if (paymentService.claimConfirm(payment.getId(), payment.getVersion(), payment.getStatus(),
+                leaseOwner, leaseUntil()) != 1) {
+            return payment.getStatus();
+        }
+        LinePayResponse confirm;
+        try {
+            confirm = gatewayCall("CONFIRM", source, payment,
+                    () -> linePayClient.confirm(credential, payment.getTransactionId(),
+                            payment.getAmount(), payment.getCurrency()));
+        } catch (Exception unknown) {
+            paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
+                    shortly(), unknownDeadline());
+            return "CONFIRM_UNKNOWN";
+        }
+        if (confirm.isSuccess()) {
+            if (!confirmedPayment(confirm, payment)) {
+                paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
+                        shortly(), unknownDeadline());
+                return "CONFIRM_UNKNOWN";
+            }
+            factService.applyPaidFact(payment.getId(), payment.getTransactionId(),
+                    paymentProvider(confirm), new Date());
+            return "PAID";
+        }
+        paymentService.markConfirmUnknown(payment.getId(), payment.getVersion() + 1,
+                shortly(), unknownDeadline());
+        return "CONFIRM_UNKNOWN";
+    }
+
+    private static JSONObject capturedPayment(LinePayResponse response, PosOrderLinePayment payment) {
+        if (response == null || !response.isSuccess()) {
+            return null;
+        }
+        JSONObject root = JSONObject.parseObject(response.rawBody());
+        JSONArray info = root.getJSONArray("info");
+        if (info == null || info.size() != 1) {
+            return null;
+        }
+        JSONObject transaction = info.getJSONObject(0);
+        String gatewayTransactionId = transaction.getString("transactionId");
+        boolean transactionMatches = payment.getTransactionId() == null
+                ? gatewayTransactionId != null
+                : payment.getTransactionId().equals(gatewayTransactionId);
+        Integer gatewayAmount = paymentAmount(transaction);
+        return transactionMatches
+                && payment.getLineOrderId().equals(transaction.getString("orderId"))
+                && payment.getCurrency().equals(transaction.getString("currency"))
+                && "PAYMENT".equals(transaction.getString("transactionType"))
+                && gatewayAmount != null && gatewayAmount.equals(payment.getAmount())
+                ? transaction : null;
+    }
+
+    private static Integer paymentAmount(JSONObject transaction) {
+        JSONArray payInfo = transaction.getJSONArray("payInfo");
+        if (payInfo == null || payInfo.isEmpty()) {
+            return null;
+        }
+        long total = 0L;
+        for (int i = 0; i < payInfo.size(); i++) {
+            JSONObject item = payInfo.getJSONObject(i);
+            Integer amount = item == null ? null : item.getInteger("amount");
+            if (amount == null || amount <= 0) {
+                return null;
+            }
+            total += amount;
+        }
+        return total <= Integer.MAX_VALUE ? (int) total : null;
+    }
+
+    private static String fullRefundTransactionId(JSONObject transaction, int expectedAmount) {
+        JSONArray refunds = transaction.getJSONArray("refundList");
+        if (refunds == null || refunds.isEmpty()) {
+            return null;
+        }
+        long total = 0L;
+        String lastTransactionId = null;
+        for (int i = 0; i < refunds.size(); i++) {
+            JSONObject refund = refunds.getJSONObject(i);
+            Integer amount = refund == null ? null : refund.getInteger("refundAmount");
+            String transactionId = refund == null ? null : refund.getString("refundTransactionId");
+            if (amount == null || amount == 0 || transactionId == null) {
+                return null;
+            }
+            total += Math.abs((long) amount);
+            lastTransactionId = transactionId;
+        }
+        return total == expectedAmount ? lastTransactionId : null;
+    }
+
+    private static boolean hasRefundEvidence(JSONObject transaction) {
+        JSONArray refunds = transaction.getJSONArray("refundList");
+        return refunds != null && !refunds.isEmpty();
+    }
+
+    private static boolean canCheckOrConfirm(String status) {
+        return "WAITING_AUTH".equals(status) || "READY_CONFIRM".equals(status);
+    }
+
+    private static String paymentProvider(LinePayResponse response) {
+        if (response == null || response.rawBody() == null) {
+            return null;
+        }
+        JSONObject root = JSONObject.parseObject(response.rawBody());
+        JSONObject objectInfo = root.getJSONObject("info");
+        if (objectInfo != null) {
+            return objectInfo.getString("paymentProvider");
+        }
+        JSONArray info = root.getJSONArray("info");
+        return info != null && info.size() == 1
+                ? info.getJSONObject(0).getString("paymentProvider") : null;
+    }
+
+    private static boolean confirmedPayment(LinePayResponse response, PosOrderLinePayment payment) {
+        if (response == null || !response.isSuccess() || response.rawBody() == null) {
+            return false;
+        }
+        JSONObject info = JSONObject.parseObject(response.rawBody()).getJSONObject("info");
+        if (info == null || !payment.getTransactionId().equals(info.getString("transactionId"))) {
+            return false;
+        }
+        String orderId = info.getString("orderId");
+        String currency = info.getString("currency");
+        return (orderId == null || payment.getLineOrderId().equals(orderId))
+                && (currency == null || payment.getCurrency().equals(currency))
+                && payment.getAmount().equals(paymentAmount(info));
+    }
+
+    private IntentSelection createIntent(PosOrder order, PosStoreLinePay credential) {
+        for (int attempt = 0; attempt < 3; attempt++) {
+            try {
+                return new IntentSelection(paymentService.createRequesting(
+                        String.valueOf(order.getDdId()), newLineOrderId(), credential.getId(),
+                        order.getMdId(), order.getAmount(), "TWD", unknownDeadline()), true);
+            } catch (DuplicateKeyException duplicate) {
+                PosOrderLinePayment concurrent = paymentService.getActiveByDdId(String.valueOf(order.getDdId()));
+                if (concurrent != null) {
+                    return new IntentSelection(concurrent, false);
+                }
+                if (attempt == 2) {
+                    throw duplicate;
+                }
+            }
+        }
+        throw new IllegalStateException("Unable to create LINE Pay attempt");
+    }
+
+    private record IntentSelection(PosOrderLinePayment payment, boolean created) {
+    }
+
+    private PosOrder requirePayableOrder(Long userId, String ddId) {
+        PosOrder order = requireOwnedOrder(userId, ddId);
+        if (!PAY_TYPE_LINE.equals(order.getPayType()) || Long.valueOf(1L).equals(order.getPayStatus())
+                || !isPayableOrderState(order) || order.getAmount() == null
+                || order.getAmount() <= 0 || order.getMdId() == null) {
+            throw new ServiceException("Order is not payable by LINE Pay");
+        }
+        return order;
+    }
+
+    private static boolean isPayableOrderState(PosOrder order) {
+        return order.getState() != null && (order.getState() == 0L || order.getState() == 1L)
+                && (order.getAfterSaleStatus() == null || order.getAfterSaleStatus() == 0L);
+    }
+
+    private static boolean isDefiniteRequestFailure(LinePayResponse response) {
+        if (response == null || response.httpStatus() < 200 || response.httpStatus() >= 300
+                || response.returnCode() == null || response.isSuccess()) {
+            return false;
+        }
+        return DEFINITE_REQUEST_FAILURE_CODES.contains(response.returnCode());
+    }
+
+    private Date leaseUntil() {
+        return new Date(System.currentTimeMillis() + Math.max(60L, rowLeaseSeconds) * 1000L);
+    }
+
+    private PosOrder requireOwnedOrder(Long userId, String ddId) {
+        if (userId == null || ddId == null || ddId.trim().isEmpty()) {
+            throw new ServiceException("Invalid order");
+        }
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
+        if (order == null || order.getUserId() == null || !order.getUserId().equals(userId)) {
+            throw new ServiceException("Order not found or forbidden");
+        }
+        return order;
+    }
+
+    private void requireSingleStoreOrder(PosOrder order) {
+        String parentDdId = order.getParentDdId() == null
+                ? String.valueOf(order.getDdId()) : order.getParentDdId();
+        long count = orderService.count(new QueryWrapper<PosOrder>().eq("parent_dd_id", parentDdId));
+        if (count > 1) {
+            throw new ServiceException("LINE Pay supports single-store orders only");
+        }
+    }
+
+    private PosOrderLinePayment selectForApp(PosOrder order, List<PosOrderLinePayment> attempts) {
+        if (attempts == null || attempts.isEmpty()) {
+            return null;
+        }
+        List<PosOrderLinePayment> paid = attempts.stream()
+                .filter(payment -> "PAID".equals(payment.getStatus())).toList();
+        List<PosOrderLinePayment> unrefunded = paid.stream().filter(payment -> {
+            PosOrderLineRefund refund = refundService.getByPaymentId(payment.getId());
+            return refund == null || !"REFUNDED".equals(refund.getStatus());
+        }).toList();
+        if (Long.valueOf(1L).equals(order.getPayStatus()) && unrefunded.size() == 1) {
+            return unrefunded.get(0);
+        }
+        if (Long.valueOf(2L).equals(order.getPayStatus()) && !paid.isEmpty()) {
+            return paid.stream().filter(payment -> {
+                PosOrderLineRefund refund = refundService.getByPaymentId(payment.getId());
+                return refund != null && "REFUNDED".equals(refund.getStatus());
+            }).findFirst().orElse(paid.get(0));
+        }
+        if (unrefunded.size() > 1) {
+            PosOrderLinePayment manual = unrefunded.get(0);
+            manual.setStatus("MANUAL_REVIEW");
+            return manual;
+        }
+        if (paid.size() == 1) {
+            return paid.get(0);
+        }
+        return attempts.stream().filter(payment -> payment.getActiveDdId() != null).findFirst()
+                .orElseGet(() -> attempts.stream().max(Comparator
+                        .comparing(PosOrderLinePayment::getCreateTime,
+                                Comparator.nullsFirst(Date::compareTo))
+                        .thenComparing(PosOrderLinePayment::getId,
+                                Comparator.nullsFirst(Long::compareTo))).orElse(null));
+    }
+
+    private static LinePayCredential toCredential(PosStoreLinePay credential) {
+        return new LinePayCredential(credential.getId(), credential.getChannelId(),
+                credential.getChannelSecret());
+    }
+
+    private RetrieveResult retrieveWithCredentialRecovery(PosOrderLinePayment payment, String source,
+                                                           PosStoreLinePay originalVersion,
+                                                           LinePayCredential originalCredential)
+            throws Exception {
+        LinePayResponse original = retrieve(payment, source, originalCredential);
+        if (!isCredentialAuthFailure(original)) {
+            return new RetrieveResult(original, originalCredential);
+        }
+        PosStoreLinePay current = credentialService.getCurrent(payment.getStoreId());
+        if (current == null || Objects.equals(current.getId(), originalVersion.getId())
+                || !Objects.equals(current.getChannelId(), originalVersion.getChannelId())
+                || !Objects.equals(current.getEnvironment(), originalVersion.getEnvironment())) {
+            return new RetrieveResult(original, originalCredential);
+        }
+        LinePayCredential currentCredential = toCredential(current);
+        LinePayResponse proof = retrieve(payment, source, currentCredential);
+        return capturedPayment(proof, payment) == null
+                ? new RetrieveResult(original, originalCredential)
+                : new RetrieveResult(proof, currentCredential);
+    }
+
+    private LinePayResponse retrieve(PosOrderLinePayment payment, String source,
+                                     LinePayCredential credential) throws Exception {
+        return gatewayCall("RETRIEVE", source, payment, credential.id(),
+                () -> payment.getTransactionId() == null
+                        ? linePayClient.retrieveByOrderId(credential, payment.getLineOrderId())
+                        : linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
+    }
+
+    private static boolean isCredentialAuthFailure(LinePayResponse response) {
+        return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
+    }
+
+    private boolean isLocallyRefunded(Long paymentId) {
+        PosOrderLineRefund refund = refundService.getByPaymentId(paymentId);
+        return refund != null && "REFUNDED".equals(refund.getStatus());
+    }
+
+    private record RetrieveResult(LinePayResponse response, LinePayCredential credential) {
+    }
+
+    private static LinePayCreateResult createResult(PosOrderLinePayment payment, boolean reused) {
+        LinePayCreateResult result = new LinePayCreateResult();
+        result.setDdId(payment.getDdId());
+        result.setPaymentId(payment.getId());
+        result.setLineOrderId(payment.getLineOrderId());
+        result.setTransactionId(payment.getTransactionId());
+        result.setPaymentUrl(payment.getPaymentUrlWeb());
+        result.setStatus(payment.getStatus());
+        result.setReusedAttempt(reused);
+        return result;
+    }
+
+    private static String newLineOrderId() {
+        return "LP" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT);
+    }
+
+    private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
+                              LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
+        return gatewayCall(action, source, payment, payment.getCredentialId(), call);
+    }
+
+    private <T> T gatewayCall(String action, String source, PosOrderLinePayment payment,
+                              Long credentialId,
+                              LinePayGatewayAuditService.GatewayCall<T> call) throws Exception {
+        if (auditService == null) {
+            return call.call();
+        }
+        return auditService.execute(action, source == null ? "LINE" : source,
+                payment.getId(), null, credentialId, payment.getStoreId(),
+                payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(), call);
+    }
+
+    private static Date shortly() {
+        return new Date(System.currentTimeMillis() + 30_000L);
+    }
+
+    private Date authDeadline() {
+        return new Date(System.currentTimeMillis() + authDeadlineMinutes * 60L * 1000L);
+    }
+
+    private Date unknownDeadline() {
+        return new Date(System.currentTimeMillis() + unknownDeadlineHours * 60L * 60L * 1000L);
+    }
+}

+ 15 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayCreateResult.java

@@ -0,0 +1,15 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+@Data
+public class LinePayCreateResult {
+    private String ddId;
+    private Long paymentId;
+    private String lineOrderId;
+    private String transactionId;
+    private String paymentUrl;
+    private String status;
+    private boolean reusedAttempt;
+}
+

+ 9 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOrderRequest.java

@@ -0,0 +1,9 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+@Data
+public class LinePayOrderRequest {
+    private String ddId;
+}
+

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayQueryResult.java

@@ -0,0 +1,17 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class LinePayQueryResult {
+    private String ddId;
+    private Long paymentId;
+    private Long orderPayStatus;
+    private String paymentStatus;
+    private String refundStatus;
+    private String transactionId;
+    private Date updatedAt;
+}
+

+ 117 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java

@@ -0,0 +1,117 @@
+package com.ruoyi.app.task;
+
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.pay.LinePayCancellationCompensationService;
+import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Value;
+
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+/** Periodically retrieves LINE payment/refund facts and resumes recoverable state. */
+@Component
+public class LinePayReconcileTask {
+    private static final Logger log = LoggerFactory.getLogger(LinePayReconcileTask.class);
+    private static final String LOCK_KEY = "lock:line-pay:reconcile";
+    @Value("${line-pay.reconcile.batch-size:20}")
+    private int batchSize = 20;
+    @Value("${line-pay.reconcile.row-lease-seconds:120}")
+    private long rowLeaseSeconds = 120L;
+    @Value("${line-pay.reconcile.round-budget-seconds:45}")
+    private long roundBudgetSeconds = 45L;
+
+    private final IPosOrderLinePaymentService paymentService;
+    private final IPosOrderLineRefundService refundService;
+    private final LinePayService linePayService;
+    private final LinePayRefundService linePayRefundService;
+    private final RedissonClient redissonClient;
+    private final LinePayCancellationCompensationService cancellationCompensationService;
+
+    public LinePayReconcileTask(IPosOrderLinePaymentService paymentService,
+                                IPosOrderLineRefundService refundService,
+                                LinePayService linePayService,
+                                LinePayRefundService linePayRefundService,
+                                RedissonClient redissonClient,
+                                LinePayCancellationCompensationService cancellationCompensationService) {
+        this.paymentService = paymentService;
+        this.refundService = refundService;
+        this.linePayService = linePayService;
+        this.linePayRefundService = linePayRefundService;
+        this.redissonClient = redissonClient;
+        this.cancellationCompensationService = cancellationCompensationService;
+    }
+
+    @Scheduled(fixedDelayString = "${line-pay.reconcile.fixed-delay-ms:60000}",
+            initialDelayString = "${line-pay.reconcile.initial-delay-ms:60000}")
+    public void reconcile() {
+        RLock lock = redissonClient.getLock(LOCK_KEY);
+        if (!lock.tryLock()) {
+            return;
+        }
+        try {
+            reconcileRound();
+        } catch (Exception exception) {
+            log.error("LINE Pay scheduled reconcile failed", exception);
+        } finally {
+            if (lock.isHeldByCurrentThread()) {
+                lock.unlock();
+            }
+        }
+    }
+
+    private void reconcileRound() {
+        long started = System.currentTimeMillis();
+        Date now = new Date(started);
+        List<PosOrderLinePayment> payments = paymentService.scanDue(now, batchSize);
+        for (PosOrderLinePayment payment : payments) {
+            if (roundBudgetExceeded(started)) {
+                break;
+            }
+            String owner = "TASK-" + UUID.randomUUID();
+            if (paymentService.claimReconcileLease(payment.getId(), payment.getVersion(), owner,
+                    new Date(System.currentTimeMillis() + rowLeaseSeconds * 1000L), new Date()) != 1) {
+                continue;
+            }
+            try {
+                linePayService.reconcilePayment(payment.getId(), "TASK", owner);
+            } catch (Exception exception) {
+                log.warn("LINE Pay payment reconcile failed, paymentId={}", payment.getId());
+            }
+        }
+        repairCancelledPayments();
+        List<PosOrderLineRefund> refunds = refundService.scanDue(new Date(), batchSize);
+        long refundStarted = System.currentTimeMillis();
+        boolean processedRefund = false;
+        for (PosOrderLineRefund refund : refunds) {
+            if (processedRefund && roundBudgetExceeded(refundStarted)) {
+                break;
+            }
+            try {
+                linePayRefundService.requestFullRefund(refund.getPaymentId(), "TASK");
+            } catch (Exception exception) {
+                log.warn("LINE Pay refund reconcile failed, paymentId={}", refund.getPaymentId());
+            }
+            processedRefund = true;
+        }
+    }
+
+    private void repairCancelledPayments() {
+        cancellationCompensationService.createMissingRefundIntents(
+                paymentService.getCancelledPaidWithoutRefund(batchSize));
+    }
+
+    private boolean roundBudgetExceeded(long started) {
+        return System.currentTimeMillis() - started >= roundBudgetSeconds * 1000L;
+    }
+}

+ 63 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/ApacheLinePayHttpTransport.java

@@ -0,0 +1,63 @@
+package com.ruoyi.app.utils.linepay;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+@Component
+public class ApacheLinePayHttpTransport implements LinePayHttpTransport {
+
+    private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
+
+    @Override
+    public LinePayHttpResponse execute(String method, String url, String body,
+                                       Map<String, String> headers, int readTimeoutMs) throws Exception {
+        RequestConfig requestConfig = RequestConfig.custom()
+                .setConnectTimeout(5_000)
+                .setConnectionRequestTimeout(5_000)
+                .setSocketTimeout(readTimeoutMs)
+                .build();
+        HttpRequestBase request;
+        if ("GET".equals(method)) {
+            request = new HttpGet(url);
+        } else if ("POST".equals(method)) {
+            HttpPost post = new HttpPost(url);
+            post.setEntity(new StringEntity(body == null ? "" : body,
+                    ContentType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8)));
+            request = post;
+        } else {
+            throw new IllegalArgumentException("Unsupported LINE Pay HTTP method");
+        }
+        request.setConfig(requestConfig);
+        headers.forEach(request::setHeader);
+        try (CloseableHttpClient client = HttpClients.custom().disableRedirectHandling().build();
+             CloseableHttpResponse response = client.execute(request)) {
+            HttpEntity entity = response.getEntity();
+            if (entity == null) {
+                return new LinePayHttpResponse(response.getStatusLine().getStatusCode(), "");
+            }
+            long length = entity.getContentLength();
+            if (length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("LINE Pay response is too large");
+            }
+            byte[] bytes = EntityUtils.toByteArray(entity);
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("LINE Pay response is too large");
+            }
+            return new LinePayHttpResponse(response.getStatusLine().getStatusCode(),
+                    new String(bytes, StandardCharsets.UTF_8));
+        }
+    }
+}

+ 173 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java

@@ -0,0 +1,173 @@
+package com.ruoyi.app.utils.linepay;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import org.springframework.stereotype.Component;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+/** LINE Pay Online API v4 client. */
+@Component
+public class LinePayClient {
+
+    static final String REQUEST_URI = "/v4/payments/request";
+    static final String RETRIEVE_URI = "/v4/payments";
+
+    private final LinePayProperties properties;
+    private final LinePaySigner signer;
+    private final LinePayHttpTransport transport;
+    private final Supplier<String> nonceSupplier;
+
+    public LinePayClient(LinePayProperties properties, LinePaySigner signer,
+                         LinePayHttpTransport transport) {
+        this(properties, signer, transport, () -> UUID.randomUUID().toString());
+    }
+
+    LinePayClient(LinePayProperties properties, LinePaySigner signer,
+                  LinePayHttpTransport transport, Supplier<String> nonceSupplier) {
+        this.properties = properties;
+        this.signer = signer;
+        this.transport = transport;
+        this.nonceSupplier = nonceSupplier;
+    }
+
+    public LinePayResponse request(LinePayCredential credential, LinePayRequest request) throws Exception {
+        validateRequest(request);
+        JSONObject product = new JSONObject();
+        product.put("name", request.productName());
+        product.put("quantity", 1);
+        product.put("price", request.amount());
+        JSONObject packageValue = new JSONObject();
+        packageValue.put("id", request.ddId());
+        packageValue.put("amount", request.amount());
+        packageValue.put("products", new JSONArray().fluentAdd(product));
+
+        JSONObject redirectUrls = new JSONObject();
+        redirectUrls.put("confirmUrl", require(properties.getConfirmUrl(), "confirmUrl"));
+        redirectUrls.put("cancelUrl", require(properties.getCancelUrl(), "cancelUrl"));
+
+        JSONObject payment = new JSONObject();
+        payment.put("capture", true);
+        JSONObject options = new JSONObject();
+        options.put("payment", payment);
+
+        JSONObject body = new JSONObject();
+        body.put("amount", request.amount());
+        body.put("currency", request.currency());
+        body.put("orderId", request.lineOrderId());
+        body.put("packages", new JSONArray().fluentAdd(packageValue));
+        body.put("redirectUrls", redirectUrls);
+        body.put("options", options);
+        return execute("POST", REQUEST_URI, null, body.toJSONString(), credential, 10_000);
+    }
+
+    public LinePayResponse check(LinePayCredential credential, String transactionId) throws Exception {
+        String uri = "/v4/payments/requests/" + pathSegment(transactionId) + "/check";
+        return execute("GET", uri, null, "", credential, 20_000);
+    }
+
+    public LinePayResponse confirm(LinePayCredential credential, String transactionId,
+                                   int amount, String currency) throws Exception {
+        if (amount <= 0) {
+            throw new IllegalArgumentException("amount must be positive");
+        }
+        JSONObject body = new JSONObject();
+        body.put("amount", amount);
+        body.put("currency", require(currency, "currency"));
+        return execute("POST", "/v4/payments/" + pathSegment(transactionId) + "/confirm",
+                null, body.toJSONString(), credential, 40_000);
+    }
+
+    public LinePayResponse retrieveByOrderId(LinePayCredential credential, String lineOrderId)
+            throws Exception {
+        String query = "orderId=" + URLEncoder.encode(require(lineOrderId, "lineOrderId"),
+                StandardCharsets.UTF_8);
+        return execute("GET", RETRIEVE_URI, query, "", credential, 20_000);
+    }
+
+    public LinePayResponse retrieveByTransactionId(LinePayCredential credential, String transactionId)
+            throws Exception {
+        String query = "transactionId=" + URLEncoder.encode(require(transactionId, "transactionId"),
+                StandardCharsets.UTF_8);
+        return execute("GET", RETRIEVE_URI, query, "", credential, 20_000);
+    }
+
+    public LinePayResponse refundFull(LinePayCredential credential, String transactionId) throws Exception {
+        return execute("POST", "/v4/payments/" + pathSegment(transactionId) + "/refund",
+                null, "{}", credential, 20_000);
+    }
+
+    private LinePayResponse execute(String method, String uri, String query, String body,
+                                    LinePayCredential credential, int readTimeoutMs) throws Exception {
+        validateCredential(credential);
+        String nonce = require(nonceSupplier.get(), "nonce");
+        String signedContent = "GET".equals(method) ? (query == null ? "" : query) : body;
+        Map<String, String> headers = new LinkedHashMap<>();
+        headers.put("X-LINE-ChannelId", credential.channelId());
+        headers.put("X-LINE-Authorization-Nonce", nonce);
+        headers.put("X-LINE-Authorization", signer.sign(
+                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);
+        JSONObject json;
+        try {
+            json = JSON.parseObject(httpResponse.body());
+        } catch (RuntimeException parseError) {
+            throw new IllegalStateException("LINE Pay returned invalid JSON", parseError);
+        }
+        if (json == null) {
+            throw new IllegalStateException("LINE Pay returned an empty response");
+        }
+        Object infoValue = json.get("info");
+        JSONObject info = infoValue instanceof JSONObject ? (JSONObject) infoValue : null;
+        String transactionId = info == null ? null : info.getString("transactionId");
+        return new LinePayResponse(httpResponse.statusCode(), json.getString("returnCode"),
+                json.getString("returnMessage"), transactionId, info, httpResponse.body());
+    }
+
+    private String baseUrl() {
+        String baseUrl = require(properties.getBaseUrl(), "baseUrl");
+        if (!("https://sandbox-api-pay.line.me".equals(baseUrl)
+                || "https://api-pay.line.me".equals(baseUrl))) {
+            throw new IllegalStateException("Unsupported LINE Pay base URL");
+        }
+        return baseUrl;
+    }
+
+    private static void validateCredential(LinePayCredential credential) {
+        if (credential == null) {
+            throw new IllegalArgumentException("credential is required");
+        }
+        require(credential.channelId(), "channelId");
+        require(credential.channelSecret(), "channelSecret");
+    }
+
+    private static void validateRequest(LinePayRequest request) {
+        if (request == null || request.amount() <= 0) {
+            throw new IllegalArgumentException("valid request is required");
+        }
+        require(request.lineOrderId(), "lineOrderId");
+        require(request.ddId(), "ddId");
+        require(request.productName(), "productName");
+        require(request.currency(), "currency");
+    }
+
+    private static String pathSegment(String value) {
+        return URLEncoder.encode(require(value, "path segment"), StandardCharsets.UTF_8)
+                .replace("+", "%20");
+    }
+
+    private static String require(String value, String name) {
+        if (value == null || value.trim().isEmpty()) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+        return value;
+    }
+}

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayCredential.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.utils.linepay;
+
+/** Exact credential version chosen for a LINE transaction. */
+public record LinePayCredential(Long id, String channelId, String channelSecret) {
+}
+

+ 6 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayHttpResponse.java

@@ -0,0 +1,6 @@
+package com.ruoyi.app.utils.linepay;
+
+/** HTTP transport result. */
+public record LinePayHttpResponse(int statusCode, String body) {
+}
+

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayHttpTransport.java

@@ -0,0 +1,10 @@
+package com.ruoyi.app.utils.linepay;
+
+import java.util.Map;
+
+/** Injectable HTTP boundary for deterministic LINE contract tests. */
+public interface LinePayHttpTransport {
+    LinePayHttpResponse execute(String method, String url, String body,
+                                Map<String, String> headers, int readTimeoutMs) throws Exception;
+}
+

+ 18 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayProperties.java

@@ -0,0 +1,18 @@
+package com.ruoyi.app.utils.linepay;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/** LINE Pay environment and redirect configuration. Credentials remain store-scoped in DB. */
+@Data
+@Component
+@ConfigurationProperties(prefix = "line-pay")
+public class LinePayProperties {
+    private String environment = "sandbox";
+    private String baseUrl = "https://sandbox-api-pay.line.me";
+    private String confirmUrl;
+    private String cancelUrl;
+    private String appPackageName = "com.twanmsdyh.app";
+}
+

+ 7 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayRequest.java

@@ -0,0 +1,7 @@
+package com.ruoyi.app.utils.linepay;
+
+/** Values required to create a normal captured LINE Pay request. */
+public record LinePayRequest(String lineOrderId, String ddId, String productName,
+                             int amount, String currency) {
+}
+

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayResponse.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.utils.linepay;
+
+import com.alibaba.fastjson2.JSONObject;
+
+/** Parsed LINE response plus original JSON for audit. */
+public record LinePayResponse(int httpStatus, String returnCode, String returnMessage,
+                              String transactionId, JSONObject info, String rawBody) {
+    public boolean isSuccess() {
+        return httpStatus >= 200 && httpStatus < 300 && "0000".equals(returnCode);
+    }
+}
+

+ 35 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePaySigner.java

@@ -0,0 +1,35 @@
+package com.ruoyi.app.utils.linepay;
+
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/** LINE Pay v4 HMAC-SHA256 signature calculator. */
+@Component
+public class LinePaySigner {
+
+    public String sign(String channelSecret, String uri, String bodyOrQuery, String nonce) {
+        require(channelSecret, "channelSecret");
+        require(uri, "uri");
+        require(nonce, "nonce");
+        String payload = channelSecret + uri + (bodyOrQuery == null ? "" : bodyOrQuery) + nonce;
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(channelSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(digest);
+        } catch (Exception e) {
+            throw new IllegalStateException("Unable to sign LINE Pay request", e);
+        }
+    }
+
+    private static void require(String value, String name) {
+        if (value == null || value.isEmpty()) {
+            throw new IllegalArgumentException(name + " is required");
+        }
+    }
+}
+

+ 14 - 25
ruoyi-admin/src/main/resources/application.yml

@@ -233,31 +233,20 @@ minio:
   accessKey: minioadmin
   secretKey: minioadmin
   bucketName: ruoyi
-# 沙箱环境参数 START ZaloPay支付配置
-zalo:
-  pay:
-    configs:
-      - app_id: "4514"
-        key1: "DzdAnAuPsIsQbfpZ8nDOqWRt4ZNBhrg6"
-        key2: "grDK67uf4zvUZuuZd4g1Zpj4PHJa3HzC"
-        pay_type: "3"
-        description: "钱包支付"
-      - app_id: "4515"
-        key1: "XHY56ATseHKddPmgWtbFI0yUev37dEW8"
-        key2: "4X61sJFkxZoVozDgkrV3DjEZyTvCdGoz"
-        pay_type: "4"
-        description: "二维码支付"
-      - app_id: "4516"
-        key1: "RhqYGAqKeoZf2ZNxTw0gXYhb9KoLZPzg"
-        key2: "D6RZsLeZvGIRXp43iMsp8o52gPPCq96H"
-        pay_type: "5"
-        description: "银行卡支付"
-    api:
-      create_order_url: "https://sb-openapi.zalopay.vn/v2/create"
-      query_order_url: "https://sb-openapi.zalopay.vn/v2/query"
-      refund_url: "https://sb-openapi.zalopay.vn/v2/refund"
-      query_refund_url: "https://sb-openapi.zalopay.vn/v2/query_refund"
-# 沙箱环境参数 END
+# LINE Pay Online API v4(Channel ID/Secret 按门店保存于数据库)
+line-pay:
+  environment: ${LINE_PAY_ENVIRONMENT:sandbox}
+  base-url: ${LINE_PAY_BASE_URL:https://sandbox-api-pay.line.me}
+  confirm-url: ${LINE_PAY_CONFIRM_URL:https://foodieapi.waimai-paotui.com/pay/line/confirm}
+  cancel-url: ${LINE_PAY_CANCEL_URL:https://foodieapi.waimai-paotui.com/pay/line/cancel}
+  reconcile:
+    fixed-delay-ms: 60000
+    initial-delay-ms: 60000
+    batch-size: 20
+    row-lease-seconds: 120
+    round-budget-seconds: 45
+    auth-deadline-minutes: 30
+    unknown-deadline-hours: 24
 # 正式环境参数 START ZaloPay支付配置
 #zalo:
 #  pay:

+ 13 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -204,3 +204,16 @@ no.invoice.donation.noubn=捐赠发票不支持统编
 no.invoice.buyername.blank=买方名称不能为空
 no.invoice.carriertype.invalid=载具类型只能为 0手机条码/1自然人凭证/2ezPay会员
 no.invoice.carriernum.blank=载具号码不能为空
+line.pay.credential.required=LINE Pay 门店、Channel ID 和 Secret 不能为空
+line.pay.credential.verify.unknown=LINE Pay 凭证验证服务暂不可用,请稍后重试
+line.pay.credential.invalid=LINE Pay 凭证验证失败,请检查 Channel ID 和 Secret
+line.pay.credential.enabled=LINE Pay 凭证验证通过并已启用
+line.pay.order.required=LINE Pay 订单号不能为空
+line.pay.operation.failed=LINE Pay 操作未完成,请稍后重试
+line.pay.order.not.found=LINE Pay 订单不存在
+line.pay.refund.not.allowed=当前订单状态不允许 LINE Pay 退款
+line.pay.refund.payment.not.found=没有可退款的 LINE Pay 支付记录
+line.pay.refund.already.completed=LINE Pay 支付已经退款
+line.pay.order.completion.not.allowed=当前 LINE Pay 订单状态不允许完成
+line.pay.legacy.endpoint.disabled=LINE Pay 订单请使用专用订单操作接口
+rider.operation.role.required=只有骑手可以操作配送订单

+ 13 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -208,3 +208,16 @@ no.invoice.donation.noubn=Donation invoices do not support a uniform business nu
 no.invoice.buyername.blank=Buyer name cannot be empty
 no.invoice.carriertype.invalid=Carrier type must be 0 (mobile barcode) / 1 (citizen certificate) / 2 (ezPay member)
 no.invoice.carriernum.blank=Carrier number cannot be empty
+line.pay.credential.required=LINE Pay store, Channel ID, and Secret are required
+line.pay.credential.verify.unknown=LINE Pay credential verification is temporarily unavailable
+line.pay.credential.invalid=LINE Pay credential verification failed; check the Channel ID and Secret
+line.pay.credential.enabled=LINE Pay credentials were verified and enabled
+line.pay.order.required=The LINE Pay order number is required
+line.pay.operation.failed=The LINE Pay operation could not be completed; please try again later
+line.pay.order.not.found=The LINE Pay order does not exist
+line.pay.refund.not.allowed=The current order state does not allow a LINE Pay refund
+line.pay.refund.payment.not.found=No refundable LINE Pay payment record exists
+line.pay.refund.already.completed=The LINE Pay payment has already been refunded
+line.pay.order.completion.not.allowed=The current LINE Pay order state does not allow completion
+line.pay.legacy.endpoint.disabled=Use the dedicated order operation endpoint for LINE Pay orders
+rider.operation.role.required=Only riders can operate delivery orders

+ 13 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -208,3 +208,16 @@ no.invoice.donation.noubn=Hóa đơn quyên góp không hỗ trợ mã số doan
 no.invoice.buyername.blank=Tên người mua không được để trống
 no.invoice.carriertype.invalid=Loại thiết bị lưu trữ chỉ có thể là 0 (mã vạch điện thoại) / 1 (chứng chỉ số công dân) / 2 (thành viên ezPay)
 no.invoice.carriernum.blank=Số thiết bị lưu trữ không được để trống
+line.pay.credential.required=Cửa hàng, Channel ID và Secret của LINE Pay là bắt buộc
+line.pay.credential.verify.unknown=Dịch vụ xác minh thông tin LINE Pay tạm thời không khả dụng
+line.pay.credential.invalid=Xác minh thông tin LINE Pay thất bại; vui lòng kiểm tra Channel ID và Secret
+line.pay.credential.enabled=Thông tin LINE Pay đã được xác minh và kích hoạt
+line.pay.order.required=Vui lòng nhập mã đơn hàng LINE Pay
+line.pay.operation.failed=Không thể hoàn tất thao tác LINE Pay, vui lòng thử lại sau
+line.pay.order.not.found=Đơn hàng LINE Pay không tồn tại
+line.pay.refund.not.allowed=Trạng thái đơn hàng hiện tại không cho phép hoàn tiền LINE Pay
+line.pay.refund.payment.not.found=Không có giao dịch LINE Pay nào có thể hoàn tiền
+line.pay.refund.already.completed=Giao dịch LINE Pay đã được hoàn tiền
+line.pay.order.completion.not.allowed=Trạng thái đơn hàng LINE Pay hiện tại không cho phép hoàn tất
+line.pay.legacy.endpoint.disabled=Hãy sử dụng API thao tác đơn hàng chuyên dụng cho đơn LINE Pay
+rider.operation.role.required=Chỉ tài xế giao hàng mới có thể thao tác đơn giao hàng

+ 13 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -208,3 +208,16 @@ no.invoice.donation.noubn=捐赠发票不支持统编
 no.invoice.buyername.blank=买方名称不能为空
 no.invoice.carriertype.invalid=载具类型只能为 0手机条码/1自然人凭证/2ezPay会员
 no.invoice.carriernum.blank=载具号码不能为空
+line.pay.credential.required=LINE Pay 门店、Channel ID 和 Secret 不能为空
+line.pay.credential.verify.unknown=LINE Pay 凭证验证服务暂不可用,请稍后重试
+line.pay.credential.invalid=LINE Pay 凭证验证失败,请检查 Channel ID 和 Secret
+line.pay.credential.enabled=LINE Pay 凭证验证通过并已启用
+line.pay.order.required=LINE Pay 订单号不能为空
+line.pay.operation.failed=LINE Pay 操作未完成,请稍后重试
+line.pay.order.not.found=LINE Pay 订单不存在
+line.pay.refund.not.allowed=当前订单状态不允许 LINE Pay 退款
+line.pay.refund.payment.not.found=没有可退款的 LINE Pay 支付记录
+line.pay.refund.already.completed=LINE Pay 支付已经退款
+line.pay.order.completion.not.allowed=当前 LINE Pay 订单状态不允许完成
+line.pay.legacy.endpoint.disabled=LINE Pay 订单请使用专用订单操作接口
+rider.operation.role.required=只有骑手可以操作配送订单

+ 13 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -208,3 +208,16 @@ no.invoice.donation.noubn=捐贈發票不支援統編
 no.invoice.buyername.blank=買方名稱不能為空
 no.invoice.carriertype.invalid=載具類型只能為 0手機條碼/1自然人憑證/2ezPay會員
 no.invoice.carriernum.blank=載具號碼不能為空
+line.pay.credential.required=LINE Pay 門店、Channel ID 和 Secret 不能為空
+line.pay.credential.verify.unknown=LINE Pay 憑證驗證服務暫時無法使用,請稍後重試
+line.pay.credential.invalid=LINE Pay 憑證驗證失敗,請檢查 Channel ID 和 Secret
+line.pay.credential.enabled=LINE Pay 憑證驗證通過並已啟用
+line.pay.order.required=LINE Pay 訂單號不能為空
+line.pay.operation.failed=LINE Pay 操作未完成,請稍後重試
+line.pay.order.not.found=LINE Pay 訂單不存在
+line.pay.refund.not.allowed=目前訂單狀態不允許 LINE Pay 退款
+line.pay.refund.payment.not.found=沒有可退款的 LINE Pay 支付記錄
+line.pay.refund.already.completed=LINE Pay 支付已經退款
+line.pay.order.completion.not.allowed=目前 LINE Pay 訂單狀態不允許完成
+line.pay.legacy.endpoint.disabled=LINE Pay 訂單請使用專用訂單操作介面
+rider.operation.role.required=只有騎手可以操作配送訂單

+ 107 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreLinePayControllerTest.java

@@ -0,0 +1,107 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PosStoreLinePayControllerTest {
+
+    private IPosStoreLinePayService credentialService;
+    private LinePayClient linePayClient;
+    private PosStoreLinePayController controller;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp() {
+        credentialService = mock(IPosStoreLinePayService.class);
+        linePayClient = mock(LinePayClient.class);
+        controller = new PosStoreLinePayController(credentialService, linePayClient, "SANDBOX");
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(any(String.class))).thenAnswer(
+                invocation -> invocation.getArgument(0));
+    }
+
+    @org.junit.jupiter.api.AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    @Test
+    void expectedNoHistoryResponseSavesAndEnablesImmutableCredentialVersion() throws Exception {
+        StoreLinePayCredentialDto dto = credential();
+        when(linePayClient.retrieveByOrderId(any(), any())).thenReturn(
+                new LinePayResponse(200, "1150", "No transaction history", null, null, "{}"));
+        PosStoreLinePay saved = new PosStoreLinePay();
+        saved.setId(33L);
+        saved.setIsEnabled(1);
+        when(credentialService.saveVerifiedCredential(eq(dto), eq("SANDBOX"),
+                eq("1150"), eq("No transaction history"))).thenReturn(saved);
+
+        AjaxResult result = controller.saveCredentials(dto);
+
+        assertEquals(200, result.get(AjaxResult.CODE_TAG));
+        ArgumentCaptor<com.ruoyi.app.utils.linepay.LinePayCredential> credentialCaptor =
+                ArgumentCaptor.forClass(com.ruoyi.app.utils.linepay.LinePayCredential.class);
+        verify(linePayClient).retrieveByOrderId(credentialCaptor.capture(), any());
+        assertEquals("1234567890", credentialCaptor.getValue().channelId());
+        assertEquals("plain-secret", credentialCaptor.getValue().channelSecret());
+    }
+
+    @Test
+    void authenticationFailureDoesNotReplaceCurrentCredential() throws Exception {
+        when(linePayClient.retrieveByOrderId(any(), any())).thenReturn(
+                new LinePayResponse(401, "1104", "Invalid signature", null, null, "{}"));
+
+        AjaxResult result = controller.saveCredentials(credential());
+
+        assertEquals(500, result.get(AjaxResult.CODE_TAG));
+        verify(credentialService, never()).saveVerifiedCredential(any(), any(), any(), any());
+    }
+
+    @Test
+    void networkUnknownDoesNotReplaceCurrentCredential() throws Exception {
+        when(linePayClient.retrieveByOrderId(any(), any())).thenThrow(new IllegalStateException("timeout"));
+
+        AjaxResult result = controller.saveCredentials(credential());
+
+        assertEquals(500, result.get(AjaxResult.CODE_TAG));
+        verify(credentialService, never()).saveVerifiedCredential(any(), any(), any(), any());
+    }
+
+    @Test
+    void nonEmptySuccessfulRetrieveIsNotAcceptedAsCredentialProbe() throws Exception {
+        when(linePayClient.retrieveByOrderId(any(), any())).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null,
+                        "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"1\"}]}"));
+
+        AjaxResult result = controller.saveCredentials(credential());
+
+        assertEquals(500, result.get(AjaxResult.CODE_TAG));
+        verify(credentialService, never()).saveVerifiedCredential(any(), any(), any(), any());
+    }
+
+    private static StoreLinePayCredentialDto credential() {
+        StoreLinePayCredentialDto dto = new StoreLinePayCredentialDto();
+        dto.setStoreId(9L);
+        dto.setChannelId("1234567890");
+        dto.setChannelSecret("plain-secret");
+        return dto;
+    }
+}

+ 162 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java

@@ -6,8 +6,12 @@ import com.ruoyi.app.order.dto.AdminOrderStatusContext;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
 import com.ruoyi.system.domain.PosOrderOmgPayment;
 import com.ruoyi.system.domain.PosOrderOmgRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
 import com.ruoyi.system.service.IPosOrderOmgPaymentService;
 import com.ruoyi.system.service.IPosOrderOmgRefundService;
 import com.ruoyi.system.service.IPosOrderService;
@@ -65,7 +69,7 @@ class OrderLifecycleServiceTest {
         PosOrderOmgPayment payment = new PosOrderOmgPayment();
         payment.setId(1L);
         payment.setPayStatus(1);
-        when(omgPaymentService.getLatestByDdId(order.getDdId())).thenReturn(payment);
+        when(omgPaymentService.getLatestRefundableByDdId(order.getDdId())).thenReturn(payment);
         when(omgRefundService.listByPayment(1L)).thenReturn(List.of());
 
         AdminOrderStatusContext context = service.getStatusContext(10L);
@@ -76,6 +80,141 @@ class OrderLifecycleServiceTest {
         assertFalse(context.getCanConfirmOfflinePayment());
     }
 
+    @Test
+    void blocksPlatformProgressForUnpaidLineOrderButNotHistoricalPayTypeThree() {
+        PosOrder order = pickupOrder("3", 0L);
+        order.setState(1L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        IPosOrderLinePaymentService linePaymentService = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService lineRefundService = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment attempt = new PosOrderLinePayment();
+        attempt.setId(9L);
+        attempt.setDdId(order.getDdId());
+        attempt.setStatus("WAITING_AUTH");
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of(attempt));
+        OrderLifecycleService lineService = new OrderLifecycleService(posOrderService, billingService,
+                orderLogHelper, userWalletService, pointsTransactionService, omgPaymentService,
+                omgRefundService, linePaymentService, lineRefundService);
+
+        assertEquals(List.of(1L, 4L), lineService.getStatusContext(20L).getAllowedOrderStates());
+
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of());
+        assertEquals(List.of(1L, 2L, 4L), lineService.getStatusContext(20L).getAllowedOrderStates());
+    }
+
+    @Test
+    void adminContextSelectsUniqueUnrefundedPaidAttemptBeforeActiveRetry() {
+        PosOrder order = pickupOrder("3", 1L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        IPosOrderLinePaymentService linePaymentService = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService lineRefundService = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment paid = new PosOrderLinePayment();
+        paid.setId(8L);
+        paid.setDdId(order.getDdId());
+        paid.setStatus("PAID");
+        PosOrderLinePayment active = new PosOrderLinePayment();
+        active.setId(9L);
+        active.setDdId(order.getDdId());
+        active.setStatus("WAITING_AUTH");
+        active.setActiveDdId(order.getDdId());
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of(active, paid));
+        OrderLifecycleService lineService = new OrderLifecycleService(posOrderService, billingService,
+                orderLogHelper, userWalletService, pointsTransactionService, omgPaymentService,
+                omgRefundService, linePaymentService, lineRefundService);
+
+        AdminOrderStatusContext context = lineService.getStatusContext(20L);
+
+        assertEquals(8L, context.getLinePaymentId());
+        assertTrue(context.getCanRefundLine());
+    }
+
+    @Test
+    void completedLineOrderCannotStartExternalRefund() {
+        PosOrder order = pickupOrder("3", 1L);
+        order.setState(3L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        IPosOrderLinePaymentService linePaymentService = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService lineRefundService = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment paid = new PosOrderLinePayment();
+        paid.setId(8L);
+        paid.setStatus("PAID");
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of(paid));
+        OrderLifecycleService lineService = new OrderLifecycleService(posOrderService, billingService,
+                orderLogHelper, userWalletService, pointsTransactionService, omgPaymentService,
+                omgRefundService, linePaymentService, lineRefundService);
+
+        assertFalse(lineService.getStatusContext(20L).getCanRefundLine());
+        assertThrows(ServiceException.class, () -> lineService.reserveLineRefund(20L, "ADMIN"));
+    }
+
+    @Test
+    void lineRefundReservationBlocksConcurrentOrderCompletion() {
+        PosOrder order = pickupOrder("3", 1L);
+        order.setState(2L);
+        IPosOrderLinePaymentService linePaymentService = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService lineRefundService = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment paid = new PosOrderLinePayment();
+        paid.setId(8L);
+        paid.setDdId(order.getDdId());
+        paid.setStatus("PAID");
+        PosOrderLineRefund reservation = new PosOrderLineRefund();
+        reservation.setPaymentId(paid.getId());
+        when(posOrderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(posOrderService.getById(order.getId())).thenReturn(order);
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of(paid));
+        when(lineRefundService.createIfAbsent(paid, "ADMIN")).thenReturn(reservation);
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(false);
+        OrderLifecycleService lineService = new OrderLifecycleService(posOrderService, billingService,
+                orderLogHelper, userWalletService, pointsTransactionService, omgPaymentService,
+                omgRefundService, linePaymentService, lineRefundService);
+
+        assertEquals(8L, lineService.reserveLineRefund(order.getId(), "ADMIN"));
+        assertThrows(ServiceException.class, () -> lineService.completePaidLineOrder(order.getId()));
+        verify(lineRefundService).createIfAbsent(paid, "ADMIN");
+        verify(billingService, never()).setSanghuBilling(any());
+    }
+
+    @Test
+    void completingPaidLineOrderRunsCompletionSideEffectsOnce() {
+        PosOrder order = pickupOrder("3", 1L);
+        order.setState(2L);
+        IPosOrderLinePaymentService linePaymentService = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService lineRefundService = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment paid = new PosOrderLinePayment();
+        paid.setId(8L);
+        paid.setDdId(order.getDdId());
+        paid.setStatus("PAID");
+        when(posOrderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(linePaymentService.getByDdId(order.getDdId())).thenReturn(List.of(paid));
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
+        OrderLifecycleService lineService = new OrderLifecycleService(posOrderService, billingService,
+                orderLogHelper, userWalletService, pointsTransactionService, omgPaymentService,
+                omgRefundService, linePaymentService, lineRefundService);
+
+        lineService.completePaidLineOrder(order.getId());
+
+        verify(billingService).setSanghuBilling(order);
+        verify(billingService, never()).setQishouBilling(any());
+    }
+
+    @Test
+    void riderAssignmentAndPickupRequireLegalStateAndOwnership() {
+        PosOrder order = deliveryOrder();
+        order.setDeliveryStatus(0L);
+        order.setQsId(null);
+        when(posOrderService.getById(10L)).thenReturn(order);
+        when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
+
+        service.acceptDeliveryByRider(10L, 88L);
+        order.setQsId(88L);
+        order.setDeliveryStatus(1L);
+        service.pickupDeliveryByRider(10L, 88L, "proof");
+
+        order.setQsId(99L);
+        assertThrows(ServiceException.class,
+                () -> service.pickupDeliveryByRider(10L, 88L, "proof"));
+    }
+
     @Test
     void marksDeliveryAsDeliveredAndCompletesExactlyOnce() {
         PosOrder order = deliveryOrder();
@@ -268,6 +407,27 @@ class OrderLifecycleServiceTest {
         verify(orderLogHelper).logSync(anyString(), anyInt(), any(), anyString(), anyString());
     }
 
+    @Test
+    void lineRefundCompletesAfterSaleAndExistingRefundSideEffects() {
+        PosOrder order = pickupOrder("3", 2L);
+        order.setState(1L);
+        when(posOrderService.getById(20L)).thenReturn(order);
+        doAnswer(invocation -> {
+            PosOrder update = invocation.getArgument(0);
+            order.setState(update.getState());
+            order.setPayStatus(update.getPayStatus());
+            order.setAfterSaleStatus(update.getAfterSaleStatus());
+            return true;
+        }).when(posOrderService).update(any(PosOrder.class), any(Wrapper.class));
+
+        AdminOrderStatusContext context = service.finalizeSystemLineRefund(20L);
+
+        assertEquals(4L, context.getState());
+        assertEquals(2L, context.getPayStatus());
+        assertEquals(3L, context.getAfterSaleStatus());
+        verify(orderLogHelper).logSync(anyString(), anyInt(), any(), anyString(), anyString());
+    }
+
     @Test
     void exposesManualRefundLocalCompensationAfterLedgerAlreadyCompleted() {
         PosOrder order = pickupOrder("7", 1L);
@@ -279,7 +439,7 @@ class OrderLifecycleServiceTest {
         PosOrderOmgRefund completed = new PosOrderOmgRefund();
         completed.setRtnCode(1);
         when(posOrderService.getById(20L)).thenReturn(order);
-        when(omgPaymentService.getLatestByDdId(order.getDdId())).thenReturn(payment);
+        when(omgPaymentService.getLatestRefundableByDdId(order.getDdId())).thenReturn(payment);
         when(omgRefundService.listByPayment(3L)).thenReturn(List.of(completed));
 
         AdminOrderStatusContext context = service.getStatusContext(20L);

+ 40 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderAdminStatusControllerTest.java

@@ -3,9 +3,15 @@ package com.ruoyi.app.order;
 import com.ruoyi.app.order.dto.AdminOrderActionRequest;
 import com.ruoyi.app.order.dto.AdminOrderStatusUpdateRequest;
 import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.JwtUtil;
 import org.junit.jupiter.api.Test;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
 import java.util.Arrays;
@@ -13,7 +19,10 @@ import java.util.Arrays;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 class PosOrderAdminStatusControllerTest {
 
@@ -23,7 +32,13 @@ class PosOrderAdminStatusControllerTest {
             if (method.getName().startsWith("admin") || method.getName().equals("getStatusContext")) {
                 PreAuthorize annotation = method.getAnnotation(PreAuthorize.class);
                 assertNotNull(annotation, method.getName() + " must require permission");
-                assertTrue(annotation.value().contains("system:order:edit"));
+                if (method.getName().equals("adminReconcileLinePayment")) {
+                    assertTrue(annotation.value().contains("system:order:linePaymentReconcile"));
+                } else if (method.getName().equals("adminRefundLinePayment")) {
+                    assertTrue(annotation.value().contains("system:order:lineRefund"));
+                } else {
+                    assertTrue(annotation.value().contains("system:order:edit"));
+                }
             }
         }
     }
@@ -48,6 +63,30 @@ class PosOrderAdminStatusControllerTest {
         assertEquals("订单状态请使用状态专用操作", result.get(AjaxResult.MSG_TAG));
     }
 
+    @Test
+    void legacyStateEndpointRejectsRealLineOrderBeforeApplyingClientFields() {
+        PosOrderController controller = new PosOrderController();
+        IPosOrderService orderService = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService paymentService = mock(IPosOrderLinePaymentService.class);
+        ReflectionTestUtils.setField(controller, "posOrderService", orderService);
+        ReflectionTestUtils.setField(controller, "linePaymentService", paymentService);
+        PosOrder stored = new PosOrder();
+        stored.setId(1L);
+        stored.setDdId("D-1");
+        stored.setPayType("3");
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setDdId("D-1");
+        when(orderService.getById(1L)).thenReturn(stored);
+        when(paymentService.getByDdId("D-1")).thenReturn(java.util.List.of(payment));
+        PosOrder malicious = new PosOrder();
+        malicious.setId(1L);
+        malicious.setState(7L);
+        malicious.setPoints(999);
+
+        assertThrows(ServiceException.class,
+                () -> controller.setorderuzt(JwtUtil.token("9", "tester"), malicious));
+    }
+
     private boolean hasField(Class<?> type, String name) {
         return Arrays.stream(type.getDeclaredFields()).anyMatch(field -> field.getName().equals(name))
                 || (type.getSuperclass() != null && type.getSuperclass() != Object.class

+ 327 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java

@@ -0,0 +1,327 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.mapper.PosOrderMapper;
+import com.ruoyi.app.order.OrderLifecycleService;
+import org.junit.jupiter.api.Test;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
+
+class LinePayCancellationRaceTest {
+
+    @Test
+    void cancellationFirstThenLateCaptureCreatesOneDurableRefundIntent() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment payment = payment("CONFIRM_UNKNOWN");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("CONFIRM_UNKNOWN"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder cancelled = order(4L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(cancelled);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+
+        verify(refunds).createIfAbsent(payment, "TASK");
+        assertEquals("PAID", payment.getStatus());
+    }
+
+    @Test
+    void tableOrderStateOneAcceptsCaptureWithoutCompensationRefund() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment payment = payment("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order(1L));
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+
+        verify(refunds, never()).createIfAbsent(any(), any());
+    }
+
+    @Test
+    void paidFirstThenCancellationUsesUniqueRefundRow() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment payment = payment("PAID");
+        when(payments.getById(70L)).thenReturn(payment);
+        PosOrderLineRefund existing = new PosOrderLineRefund();
+        existing.setPaymentId(70L);
+        existing.setStatus("CREATED");
+        when(refunds.createIfAbsent(payment, "USER_CANCEL")).thenReturn(existing);
+        LinePayRefundService service = new LinePayRefundService(payments, refunds,
+                mock(com.ruoyi.system.service.IPosStoreLinePayService.class),
+                mock(com.ruoyi.app.utils.linepay.LinePayClient.class), mock(LinePayFactService.class));
+
+        assertEquals("CREATED", service.createRefundIntent(70L, "USER_CANCEL"));
+        assertEquals("CREATED", service.createRefundIntent(70L, "USER_CANCEL"));
+
+        verify(refunds, org.mockito.Mockito.times(2)).createIfAbsent(payment, "USER_CANCEL");
+    }
+
+    @Test
+    void lateDuplicateCaptureCreatesCompensationRefundWhenOrderWasAlreadyPaidElsewhere() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        PosOrderLinePayment payment = payment("FAILED");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("FAILED"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder order = order(1L);
+        order.setPayType("7");
+        order.setPayStatus(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(0);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper);
+
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+
+        verify(refunds).createIfAbsent(payment, "DUPLICATE_PAYMENT");
+    }
+
+    @Test
+    void firstSuccessfulOrderCaptureTriggersNotificationOnlyOnce() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class);
+        PosOrderLinePayment payment = payment("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder order = order(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(1, 0);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper,
+                notifications, null);
+
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+        payment.setStatus("PAID");
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+
+        verify(notifications, times(1)).paymentCaptured(order);
+    }
+
+    @Test
+    void alreadyRefundedRetrieveDoesNotNotifyOrderAsNewlyPaid() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class);
+        PosOrderLinePayment payment = payment("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder order = order(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderMapper.markLinePaid("DD-1", 70L)).thenReturn(1);
+        when(orderMapper.markLineRefunded("DD-1", 70L)).thenReturn(1);
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setId(80L);
+        refund.setPaymentId(70L);
+        refund.setStatus("CREATED");
+        refund.setVersion(1L);
+        when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refund);
+        when(refunds.markRefundedFromEvidence(eq(80L), eq(1L), eq("CREATED"),
+                eq("RF-1"), any())).thenReturn(1);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper,
+                notifications, null);
+
+        facts.applyPaidAndRefundedFact(70L, "TX-1", "TSP", new Date(), "RF-1", new Date());
+
+        verify(notifications, never()).paymentCaptured(any());
+    }
+
+    @Test
+    void verifiedFullRefundEvidenceEscalatesPriorFailedRefund() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        PosOrderLinePayment payment = payment("PAID");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder order = order(1L);
+        order.setPayStatus(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderMapper.markLineRefunded("DD-1", 70L)).thenReturn(1);
+        PosOrderLineRefund failed = new PosOrderLineRefund();
+        failed.setId(80L);
+        failed.setPaymentId(70L);
+        failed.setStatus("FAILED");
+        failed.setVersion(3L);
+        when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(failed);
+        when(refunds.markRefundedFromEvidence(eq(80L), eq(3L), eq("FAILED"),
+                eq("RF-EXTERNAL"), any())).thenReturn(1);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper,
+                mock(LinePayOrderNotificationService.class), null);
+
+        facts.applyPaidAndRefundedFact(70L, "TX-1", "TSP", new Date(),
+                "RF-EXTERNAL", new Date());
+
+        verify(refunds).markRefundedFromEvidence(eq(80L), eq(3L), eq("FAILED"),
+                eq("RF-EXTERNAL"), any());
+        verify(refunds, never()).markRefunded(any(), any(), any(), any());
+    }
+
+    @Test
+    void partialRefundEvidenceRecordsPaymentFactWithoutFulfillingOrder() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class);
+        PosOrderLinePayment payment = payment("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("WAITING_AUTH"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setId(80L);
+        refund.setPaymentId(70L);
+        refund.setStatus("CREATED");
+        refund.setVersion(1L);
+        when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refund);
+        when(refunds.markManualReviewFromEvidence(80L, 1L, "CREATED")).thenReturn(1);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper,
+                notifications, null);
+
+        facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date());
+
+        verify(orderMapper, never()).markLinePaid(any(), any());
+        verify(notifications, never()).paymentCaptured(any());
+        verify(refunds).markManualReviewFromEvidence(80L, 1L, "CREATED");
+    }
+
+    @Test
+    void ambiguousLaterEvidenceNeverDowngradesConfirmedRefund() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment payment = payment("PAID");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrderLineRefund refunded = new PosOrderLineRefund();
+        refunded.setId(80L);
+        refunded.setPaymentId(70L);
+        refunded.setStatus("REFUNDED");
+        refunded.setVersion(3L);
+        when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(refunded);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date());
+
+        verify(refunds, never()).markManualReview(any(), any(), any());
+    }
+
+    @Test
+    void verifiedPartialRefundEvidenceEscalatesPriorFailedRefund() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment payment = payment("PAID");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("PAID"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrderLineRefund failed = new PosOrderLineRefund();
+        failed.setId(80L);
+        failed.setPaymentId(70L);
+        failed.setStatus("FAILED");
+        failed.setVersion(3L);
+        when(refunds.createIfAbsent(payment, "RETRIEVE")).thenReturn(failed);
+        when(refunds.markManualReviewFromEvidence(80L, 3L, "FAILED")).thenReturn(1);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        facts.applyPaidWithRefundReviewFact(70L, "TX-1", "TSP", new Date());
+
+        verify(refunds).markManualReviewFromEvidence(80L, 3L, "FAILED");
+        verify(refunds, never()).markManualReview(any(), any(), any());
+    }
+
+    @Test
+    void cancelledLateCaptureCanCommitFullRefundWithoutPaymentNotification() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderMapper orderMapper = mock(PosOrderMapper.class);
+        LinePayOrderNotificationService notifications = mock(LinePayOrderNotificationService.class);
+        OrderLifecycleService lifecycle = mock(OrderLifecycleService.class);
+        PosOrderLinePayment payment = payment("CONFIRM_UNKNOWN");
+        when(payments.getById(70L)).thenReturn(payment);
+        when(payments.markPaid(eq(70L), eq(2L), eq("CONFIRM_UNKNOWN"), eq("TX-1"),
+                any(), any())).thenReturn(1);
+        PosOrder cancelled = order(4L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(cancelled);
+        when(orderMapper.markLinePaid("DD-1", 70L)).thenAnswer(invocation -> {
+            cancelled.setPayStatus(1L);
+            return 1;
+        });
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setId(80L);
+        refund.setPaymentId(70L);
+        refund.setStatus("CREATED");
+        refund.setVersion(1L);
+        when(refunds.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(refunds.markRefunded(eq(80L), eq(1L), eq("RF-1"), any())).thenReturn(1);
+        when(orderMapper.markLineRefunded("DD-1", 70L)).thenAnswer(invocation -> {
+            cancelled.setPayStatus(2L);
+            return 1;
+        });
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds, orderMapper,
+                notifications, lifecycle);
+
+        facts.applyPaidFact(70L, "TX-1", "TSP", new Date());
+        facts.applyRefundedFact(80L, 70L, 1L, "RF-1", new Date());
+
+        assertEquals(2L, cancelled.getPayStatus());
+        verify(notifications, never()).paymentCaptured(any());
+        verify(lifecycle).finalizeSystemLineRefund(9L);
+    }
+
+    private static PosOrderLinePayment payment(String status) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(70L);
+        payment.setDdId("DD-1");
+        payment.setTransactionId("TX-1");
+        payment.setStatus(status);
+        payment.setVersion(2L);
+        return payment;
+    }
+
+    private static PosOrder order(Long state) {
+        PosOrder order = new PosOrder();
+        order.setId(9L);
+        order.setDdId("DD-1");
+        order.setState(state);
+        order.setPayType("3");
+        order.setPayStatus(0L);
+        order.setAfterSaleStatus(0L);
+        return order;
+    }
+}

+ 90 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayControllerTest.java

@@ -0,0 +1,90 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doThrow;
+
+class LinePayControllerTest {
+    private LinePayService linePayService;
+    private LinePayAsyncService asyncService;
+    private LinePayController controller;
+
+    @BeforeEach
+    void setUp() {
+        linePayService = mock(LinePayService.class);
+        asyncService = mock(LinePayAsyncService.class);
+        controller = new LinePayController(linePayService, asyncService,
+                new LinePayReturnPageRenderer());
+    }
+
+    @Test
+    void confirmReturnsSecurePageWithoutWaitingForGateway() {
+        PosOrderLinePayment payment = payment();
+        when(linePayService.findByLineOrderId("LP-1", "TX-1")).thenReturn(payment);
+
+        ResponseEntity<String> response = controller.confirm("LP-1", "TX-1");
+
+        assertEquals(200, response.getStatusCode().value());
+        assertEquals("no-store, no-cache, must-revalidate",
+                response.getHeaders().getFirst("Cache-Control"));
+        assertTrue(response.getHeaders().getFirst("Content-Security-Policy")
+                .contains("frame-ancestors 'none'"));
+        String csp = response.getHeaders().getFirst("Content-Security-Policy");
+        assertTrue(!csp.contains("script-src 'unsafe-inline'"));
+        String nonce = csp.substring(csp.indexOf("script-src 'nonce-") + 18,
+                csp.indexOf("'; img-src"));
+        assertTrue(response.getBody().contains("<script nonce=\"" + nonce + "\">"));
+        assertTrue(response.getBody().contains("com.twanmsdyh.app://payment/result?orderId=DD-1"));
+        verify(asyncService).reconcile(9L, "CONFIRM_RETURN");
+    }
+
+    @Test
+    void cancelDoesNotWriteTerminalStatusAndOnlySchedulesReconcile() {
+        PosOrderLinePayment payment = payment();
+        when(linePayService.findByLineOrderId("LP-1", "TX-1")).thenReturn(payment);
+
+        ResponseEntity<String> response = controller.cancel("LP-1", "TX-1");
+
+        assertEquals(200, response.getStatusCode().value());
+        assertEquals("WAITING_AUTH", payment.getStatus());
+        verify(asyncService).reconcile(9L, "CANCEL_RETURN");
+    }
+
+    @Test
+    void unknownReturnParametersStillRenderSafePage() {
+        ResponseEntity<String> response = controller.cancel(null, null);
+
+        assertEquals(200, response.getStatusCode().value());
+        assertTrue(response.getBody().contains("Payment is being confirmed"));
+    }
+
+    @Test
+    void saturatedExecutorStillReturnsTheSafetyPage() {
+        PosOrderLinePayment payment = payment();
+        when(linePayService.findByLineOrderId("LP-1", "TX-1")).thenReturn(payment);
+        doThrow(new java.util.concurrent.RejectedExecutionException("full"))
+                .when(asyncService).reconcile(9L, "CONFIRM_RETURN");
+
+        ResponseEntity<String> response = controller.confirm("LP-1", "TX-1");
+
+        assertEquals(200, response.getStatusCode().value());
+        assertTrue(response.getBody().contains("Payment is being confirmed"));
+    }
+
+    private static PosOrderLinePayment payment() {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(9L);
+        payment.setDdId("DD-1");
+        payment.setStatus("WAITING_AUTH");
+        return payment;
+    }
+}

+ 47 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayGatewayAuditServiceTest.java

@@ -0,0 +1,47 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.system.service.IPaymentGatewayLogService;
+import com.ruoyi.system.domain.PaymentGatewayLog;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.alibaba.fastjson2.JSONObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import org.mockito.ArgumentCaptor;
+
+class LinePayGatewayAuditServiceTest {
+
+    @Test
+    void auditFailureNeverChangesGatewayResultOrEscapes() throws Exception {
+        IPaymentGatewayLogService logs = mock(IPaymentGatewayLogService.class);
+        when(logs.append(any())).thenThrow(new IllegalStateException("log database unavailable"));
+        LinePayGatewayAuditService audit = new LinePayGatewayAuditService(logs);
+
+        String result = audit.execute("REQUEST", "APP", 1L, null, 5L,
+                9L, "DD-1", "LP-1", "TX-1", () -> "gateway-result");
+
+        assertEquals("gateway-result", result);
+    }
+
+    @Test
+    void responseAuditRemovesPaymentTokenAndPaymentUrls() throws Exception {
+        IPaymentGatewayLogService logs = mock(IPaymentGatewayLogService.class);
+        LinePayGatewayAuditService audit = new LinePayGatewayAuditService(logs);
+        String raw = "{\"returnCode\":\"0000\",\"info\":{\"paymentAccessToken\":\"token\","
+                + "\"paymentUrl\":{\"web\":\"https://secret\",\"app\":\"line://secret\"}}}";
+        LinePayResponse response = new LinePayResponse(200, "0000", "Success", null,
+                JSONObject.parseObject(raw).getJSONObject("info"), raw);
+
+        audit.execute("REQUEST", "APP", 1L, null, 5L,
+                9L, "DD-1", "LP-1", null, () -> response);
+
+        ArgumentCaptor<PaymentGatewayLog> captor = ArgumentCaptor.forClass(PaymentGatewayLog.class);
+        verify(logs, org.mockito.Mockito.times(2)).append(captor.capture());
+        String payload = captor.getAllValues().get(1).getPayload();
+        assertEquals("{\"returnCode\":\"0000\",\"info\":{\"paymentUrl\":{}}}", payload);
+    }
+}

+ 104 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOrderGuardTest.java

@@ -0,0 +1,104 @@
+package com.ruoyi.app.pay;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class LinePayOrderGuardTest {
+
+    @Test
+    void merchantOwnershipUsesShIdForRegularAndNightMarketUsers() {
+        LinePayOrderGuard guard = new LinePayOrderGuard(mock(IPosOrderLinePaymentService.class));
+        PosOrder order = new PosOrder();
+        order.setShId(10L);
+        InfoUser merchant = user(10L, "1", null);
+
+        assertDoesNotThrow(() -> guard.requireMerchantOwnership(order, merchant));
+        merchant.setUserId(11L);
+        assertThrows(ServiceException.class, () -> guard.requireMerchantOwnership(order, merchant));
+    }
+
+    @Test
+    void stallMerchantOwnershipUsesStoreIdAndLineOrderMustBePaidBeforeAccept() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        LinePayOrderGuard guard = new LinePayOrderGuard(payments);
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1");
+        order.setMdId(20L);
+        order.setPayType("3");
+        order.setPayStatus(0L);
+        InfoUser stall = user(30L, "9", 20L);
+        PosOrderLinePayment line = new PosOrderLinePayment();
+        line.setStatus("WAITING_AUTH");
+        when(payments.getByDdId("DD-1")).thenReturn(List.of(line));
+
+        assertDoesNotThrow(() -> guard.requireMerchantOwnership(order, stall));
+        assertThrows(ServiceException.class, () -> guard.requirePaidBeforeAccept(order));
+
+        order.setPayStatus(1L);
+        assertDoesNotThrow(() -> guard.requirePaidBeforeAccept(order));
+    }
+
+    @Test
+    void historicalPayTypeThreeWithoutLineAttemptIsNotTreatedAsLinePay() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        LinePayOrderGuard guard = new LinePayOrderGuard(payments);
+        PosOrder historical = new PosOrder();
+        historical.setDdId("OLD-ZALO");
+        historical.setPayType("3");
+        historical.setPayStatus(0L);
+        when(payments.getByDdId("OLD-ZALO")).thenReturn(List.of());
+
+        assertDoesNotThrow(() -> guard.requirePaidBeforeAccept(historical));
+    }
+
+    @Test
+    void ordinaryUserAndRiderCannotUseMerchantCancellationOwnership() {
+        LinePayOrderGuard guard = new LinePayOrderGuard(mock(IPosOrderLinePaymentService.class));
+        PosOrder order = new PosOrder();
+        order.setMdId(20L);
+
+        assertThrows(ServiceException.class,
+                () -> guard.requireMerchantOwnership(order, user(1L, "0", 20L)));
+        assertThrows(ServiceException.class,
+                () -> guard.requireMerchantOwnership(order, user(2L, "2", 20L)));
+    }
+
+    @Test
+    void legacyLineOrderAllowsOnlyOwnerMerchantOrAssignedRiderTransition() {
+        LinePayOrderGuard guard = new LinePayOrderGuard(mock(IPosOrderLinePaymentService.class));
+        PosOrder order = new PosOrder();
+        order.setShId(10L);
+        order.setQsId(30L);
+
+        assertDoesNotThrow(() -> guard.requireLegacyOrderActor(order, user(10L, "1", null), 2L));
+        assertDoesNotThrow(() -> guard.requireLegacyOrderActor(order, user(30L, "2", null), 4L));
+        assertThrows(ServiceException.class,
+                () -> guard.requireLegacyOrderActor(order, user(31L, "2", null), 4L));
+        assertThrows(ServiceException.class,
+                () -> guard.requireLegacyOrderActor(order, user(30L, "2", null), 2L));
+        order.setQsId(null);
+        assertThrows(ServiceException.class,
+                () -> guard.requireLegacyOrderActor(order, user(30L, "2", null), 4L));
+        assertThrows(ServiceException.class,
+                () -> guard.requireLegacyOrderActor(order, user(99L, "0", null), 0L));
+    }
+
+    private static InfoUser user(Long id, String type, Long storeId) {
+        InfoUser user = new InfoUser();
+        user.setUserId(id);
+        user.setUserType(type);
+        user.setStoreId(storeId);
+        return user;
+    }
+}

+ 319 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReconcileTest.java

@@ -0,0 +1,319 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LinePayReconcileTest {
+
+    private IPosOrderService orderService;
+    private IPosStoreLinePayService credentialService;
+    private IPosOrderLinePaymentService paymentService;
+    private IPosOrderLineRefundService refundService;
+    private LinePayClient client;
+    private LinePayFactService factService;
+    private LinePayService service;
+
+    @BeforeEach
+    void setUp() {
+        orderService = mock(IPosOrderService.class);
+        credentialService = mock(IPosStoreLinePayService.class);
+        paymentService = mock(IPosOrderLinePaymentService.class);
+        refundService = mock(IPosOrderLineRefundService.class);
+        client = mock(LinePayClient.class);
+        factService = mock(LinePayFactService.class);
+        service = new LinePayService(orderService, credentialService, paymentService,
+                refundService, client, factService);
+        when(paymentService.getById(70L)).thenReturn(payment("WAITING_AUTH"));
+        when(credentialService.getById(5L)).thenReturn(credential());
+    }
+
+    @Test
+    void retrieveUniqueCapturedPaymentAppliesPaidFactWithoutCheckOrConfirm() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("PAID", status);
+        verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
+        verify(client, never()).check(any(), any());
+        verify(client, never()).confirm(any(), any(), any(Integer.class), any());
+    }
+
+    @Test
+    void rotatedSameChannelCredentialMayRecoverOnlyAfterStrictRetrieveProof() throws Exception {
+        PosStoreLinePay current = currentCredential("channel");
+        when(credentialService.getCurrent(9L)).thenReturn(current);
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                5L, "channel", "secret")), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1105\"}"));
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," 
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
+
+        assertEquals("PAID", service.reconcilePayment(70L, "TASK"));
+
+        verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
+    }
+
+    @Test
+    void rotatedDifferentChannelCredentialIsNeverUsedForOldTransaction() throws Exception {
+        when(credentialService.getCurrent(9L)).thenReturn(currentCredential("different-channel"));
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1105\"}"));
+
+        assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
+
+        verify(client, org.mockito.Mockito.times(1)).retrieveByTransactionId(any(), eq("TX-100"));
+        verify(factService, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void confirmedLocalRefundIsNeverDowngradedByLaterAmbiguousEvidence() throws Exception {
+        PosOrderLineRefund refunded = new PosOrderLineRefund();
+        refunded.setPaymentId(70L);
+        refunded.setStatus("REFUNDED");
+        when(refundService.getByPaymentId(70L)).thenReturn(refunded);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\"," 
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
+                        + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\"," 
+                        + "\"refundAmount\":-100}]}]}"));
+
+        assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK"));
+
+        verify(factService, never()).applyPaidWithRefundReviewFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void check0110ConfirmsOnlyAfterLocalClaimAndThenAppliesPaidFact() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1150\"}"));
+        when(client.check(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"0110\"}"));
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order(0L));
+        when(paymentService.claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"),
+                any(), any())).thenReturn(1);
+        when(client.confirm(any(), eq("TX-100"), eq(280), eq("TWD"))).thenReturn(
+                response("{\"returnCode\":\"0000\",\"info\":{\"transactionId\":\"TX-100\","
+                        + "\"orderId\":\"LP-100\",\"currency\":\"TWD\","
+                        + "\"payInfo\":[{\"amount\":280}]}}"));
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("PAID", status);
+        verify(paymentService).claimConfirm(eq(70L), eq(2L), eq("WAITING_AUTH"), any(), any());
+        verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
+    }
+
+    @Test
+    void check0121TerminatesOnlyAfterRetrieveFoundNoPayment() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1150\"}"));
+        when(client.check(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"0121\"}"));
+        when(paymentService.markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED"))
+                .thenReturn(1);
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("CANCELLED_OR_EXPIRED", status);
+        verify(paymentService).markTerminal(70L, 2L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED");
+    }
+
+    @Test
+    void cancelledOrderIsNeverConfirmedAfterAuthorization() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1150\"}"));
+        when(client.check(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"0110\"}"));
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order(4L));
+        when(paymentService.markAuthDoneOrderCancelled(eq(70L), eq(2L), eq("WAITING_AUTH"),
+                any(), any()))
+                .thenReturn(1);
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("AUTH_DONE_ORDER_CANCELLED", status);
+        verify(client, never()).confirm(any(), any(), any(Integer.class), any());
+    }
+
+    @Test
+    void requestUnknownCanRecoverCapturedTransactionByOrderId() throws Exception {
+        PosOrderLinePayment unknown = payment("REQUEST_UNKNOWN");
+        unknown.setTransactionId(null);
+        when(paymentService.getById(70L)).thenReturn(unknown);
+        when(client.retrieveByOrderId(any(), eq("LP-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-RECOVERED\","
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}]}]}"));
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("PAID", status);
+        verify(factService).applyPaidFact(eq(70L), eq("TX-RECOVERED"), any(), any());
+    }
+
+    @Test
+    void check0123ImmediatelyRetrievesAgainBeforeDeciding() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100")))
+                .thenReturn(response("{\"returnCode\":\"1150\"}"))
+                .thenReturn(response("{\"returnCode\":\"0000\",\"info\":[{"
+                        + "\"transactionId\":\"TX-100\",\"orderId\":\"LP-100\","
+                        + "\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\","
+                        + "\"payInfo\":[{\"amount\":280}]}]}"));
+        when(client.check(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"0123\"}"));
+
+        String status = service.reconcilePayment(70L, "TASK");
+
+        assertEquals("PAID", status);
+        verify(factService).applyPaidFact(eq(70L), eq("TX-100"), any(), any());
+    }
+
+    @Test
+    void expiredRecoveryPerformsFinalRetrieveThenStopsForManualReview() throws Exception {
+        PosOrderLinePayment expired = payment("CONFIRM_UNKNOWN");
+        expired.setReconcileDeadline(new java.util.Date(1L));
+        when(paymentService.getById(70L)).thenReturn(expired);
+        when(client.retrieveByTransactionId(any(), eq("TX-100")))
+                .thenThrow(new IllegalStateException("timeout"));
+        when(paymentService.markManualReview(70L, 2L, "CONFIRM_UNKNOWN")).thenReturn(1);
+
+        assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK"));
+        verify(paymentService).markManualReview(70L, 2L, "CONFIRM_UNKNOWN");
+    }
+
+    @Test
+    void confirmUnknownNeverChecksOrConfirmsAgain() throws Exception {
+        PosOrderLinePayment unknown = payment("CONFIRM_UNKNOWN");
+        when(paymentService.getById(70L)).thenReturn(unknown);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                response("{\"returnCode\":\"1150\"}"));
+
+        assertEquals("CONFIRM_UNKNOWN", service.reconcilePayment(70L, "TASK"));
+
+        verify(client, never()).check(any(), any());
+        verify(client, never()).confirm(any(), any(), any(Integer.class), any());
+    }
+
+    @Test
+    void retrieveAmountMismatchDoesNotApplyPaidFact() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":100}]}]}"));
+
+        assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
+
+        verify(factService, never()).applyPaidFact(any(), any(), any(), any());
+        verify(client, never()).check(any(), any());
+    }
+
+    @Test
+    void retrieveAlreadyFullyRefundedPaymentAppliesBothFacts() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
+                        + "\"refundList\":[{\"refundTransactionId\":\"RF-1\","
+                        + "\"transactionType\":\"PARTIAL_REFUND\",\"refundAmount\":-280}]}]}"));
+
+        assertEquals("REFUNDED", service.reconcilePayment(70L, "TASK"));
+
+        verify(factService).applyPaidAndRefundedFact(eq(70L), eq("TX-100"), any(), any(),
+                eq("RF-1"), any());
+        verify(factService, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void retrievePartiallyRefundedPaymentStopsForManualReviewWithoutFulfillment() throws Exception {
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(response(
+                "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
+                        + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\","
+                        + "\"currency\":\"TWD\",\"payInfo\":[{\"amount\":280}],"
+                        + "\"refundList\":[{\"refundTransactionId\":\"RF-PART\","
+                        + "\"refundAmount\":-100}]}]}"));
+        assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK"));
+
+        verify(factService).applyPaidWithRefundReviewFact(eq(70L), eq("TX-100"), any(), any());
+        verify(factService, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    private static LinePayResponse response(String raw) {
+        JSONObject root = JSONObject.parseObject(raw);
+        return new LinePayResponse(200, root.getString("returnCode"),
+                root.getString("returnMessage"), null, root.getJSONObject("info"), raw);
+    }
+
+    private static PosOrderLinePayment payment(String status) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(70L);
+        payment.setDdId("DD-100");
+        payment.setLineOrderId("LP-100");
+        payment.setTransactionId("TX-100");
+        payment.setCredentialId(5L);
+        payment.setStoreId(9L);
+        payment.setAmount(280);
+        payment.setCurrency("TWD");
+        payment.setStatus(status);
+        payment.setActiveDdId("DD-100");
+        payment.setVersion(2L);
+        return payment;
+    }
+
+    private static PosStoreLinePay credential() {
+        PosStoreLinePay value = new PosStoreLinePay();
+        value.setId(5L);
+        value.setEnvironment("SANDBOX");
+        value.setChannelId("channel");
+        value.setChannelSecret("secret");
+        return value;
+    }
+
+    private static PosStoreLinePay currentCredential(String channelId) {
+        PosStoreLinePay value = new PosStoreLinePay();
+        value.setId(6L);
+        value.setStoreId(9L);
+        value.setEnvironment("SANDBOX");
+        value.setChannelId(channelId);
+        value.setChannelSecret("new-secret");
+        return value;
+    }
+
+    private static PosOrder order(Long state) {
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-100");
+        order.setMdId(9L);
+        order.setAmount(280);
+        order.setPayType("3");
+        order.setPayStatus(0L);
+        order.setState(state);
+        return order;
+    }
+}

+ 356 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayRefundTest.java

@@ -0,0 +1,356 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+
+class LinePayRefundTest {
+    private IPosOrderLinePaymentService paymentService;
+    private IPosOrderLineRefundService refundService;
+    private IPosStoreLinePayService credentialService;
+    private LinePayClient client;
+    private LinePayFactService factService;
+    private LinePayRefundService service;
+
+    @BeforeEach
+    void setUp() {
+        paymentService = mock(IPosOrderLinePaymentService.class);
+        refundService = mock(IPosOrderLineRefundService.class);
+        credentialService = mock(IPosStoreLinePayService.class);
+        client = mock(LinePayClient.class);
+        factService = mock(LinePayFactService.class);
+        service = new LinePayRefundService(paymentService, refundService, credentialService,
+                client, factService);
+    }
+
+    @Test
+    void successfulFullRefundMarksRefundFactWithoutChangingPaymentStatus() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "ADMIN")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(noRefundResponse());
+        JSONObject info = new JSONObject();
+        info.put("refundTransactionId", "RF-100");
+        when(client.refundFull(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, info, "{}"));
+
+        String status = service.requestFullRefund(70L, "ADMIN");
+
+        assertEquals("REFUNDED", status);
+        assertEquals("PAID", payment.getStatus());
+        verify(client).refundFull(any(), eq("TX-100"));
+        verify(factService).applyRefundedFact(eq(88L), eq(70L), eq(1L), eq("RF-100"), any());
+    }
+
+    @Test
+    void rotatedSameChannelCredentialRequiresStrictRetrieveBeforeRefund() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "ADMIN")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(credentialService.getCurrent(9L)).thenReturn(currentCredential("channel"));
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                5L, "channel", "secret")), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "1105", "Authentication failed", null, null, "{}"));
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(noRefundResponse());
+        JSONObject info = new JSONObject();
+        info.put("refundTransactionId", "RF-NEW");
+        when(client.refundFull(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, info, "{}"));
+
+        assertEquals("REFUNDED", service.requestFullRefund(70L, "ADMIN"));
+
+        verify(client).refundFull(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                6L, "channel", "new-secret")), eq("TX-100"));
+    }
+
+    @Test
+    void rotatedCredentialWithMismatchedProofNeverIssuesRefund() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "ADMIN")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(credentialService.getCurrent(9L)).thenReturn(currentCredential("channel"));
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                5L, "channel", "secret")), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "1105", "Authentication failed", null, null, "{}"));
+        String wrongAmount = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":100}],\"refundList\":[]}]}";
+        when(client.retrieveByTransactionId(eq(new com.ruoyi.app.utils.linepay.LinePayCredential(
+                6L, "channel", "new-secret")), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null, wrongAmount));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "ADMIN"));
+
+        verify(client, never()).refundFull(any(), any());
+        verify(refundService).markUnknown(eq(88L), eq(1L), any(), any());
+    }
+
+    @Test
+    void unknownRefundIsRecoveredByRetrieveAndNeverCallsRefundAgain() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("UNKNOWN");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":280}],\"refundList\":[{\"refundTransactionId\":\"RF-100\"," 
+                + "\"transactionType\":\"PARTIAL_REFUND\",\"refundAmount\":-280}]}]}";
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null, raw));
+
+        String status = service.requestFullRefund(70L, "TASK");
+
+        assertEquals("REFUNDED", status);
+        verify(client, org.mockito.Mockito.never()).refundFull(any(), any());
+        verify(factService).applyRefundedFact(eq(88L), eq(70L), eq(0L), eq("RF-100"), any());
+    }
+
+    @Test
+    void createdRefundRetrievesBeforeIssuingExternalRefund() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "ADMIN")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(refundService.markManualReview(88L, 1L, "PROCESSING")).thenReturn(1);
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":280}],\"refundList\":[{\"refundTransactionId\":\"RF-OLD\"," 
+                + "\"transactionType\":\"PARTIAL_REFUND\",\"refundAmount\":-280}]}]}";
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null, raw));
+
+        assertEquals("REFUNDED", service.requestFullRefund(70L, "ADMIN"));
+
+        verify(client, org.mockito.Mockito.never()).refundFull(any(), any());
+        verify(factService).applyRefundedFact(eq(88L), eq(70L), eq(1L), eq("RF-OLD"), any());
+    }
+
+    @Test
+    void documentedTemporaryRefundCodeEntersControlledRetryWait() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(noRefundResponse());
+        when(client.refundFull(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "1900", "Temporary", null, null, "{}"));
+
+        assertEquals("RETRY_WAIT", service.requestFullRefund(70L, "TASK"));
+
+        verify(refundService).markRetryWait(eq(88L), eq(1L), any());
+    }
+
+    @Test
+    void existingPartialRefundRequiresManualReviewAndNeverIssuesAnotherRefund() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(refundService.markManualReview(88L, 1L, "PROCESSING")).thenReturn(1);
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":280}],\"refundList\":[{\"refundTransactionId\":\"RF-PART\"," 
+                + "\"refundAmount\":-100}]}]}";
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null, raw));
+
+        assertEquals("MANUAL_REVIEW", service.requestFullRefund(70L, "TASK"));
+
+        verify(client, never()).refundFull(any(), any());
+        verify(refundService).markManualReview(88L, 1L, "PROCESSING");
+    }
+
+    @Test
+    void inconclusiveRetrieveNeverIssuesRefundSideEffect() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "9000", "Temporary", null, null, "{}"));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "TASK"));
+
+        verify(client, never()).refundFull(any(), any());
+        verify(refundService).markUnknown(eq(88L), eq(1L), any(), any());
+    }
+
+    @Test
+    void mismatchedRetrievedPaymentNeverProvesRefundState() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"WRONG\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":280}]}]}";
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, null, raw));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "TASK"));
+        verify(client, never()).refundFull(any(), any());
+    }
+
+    @Test
+    void alreadyRefundedGatewayCodeRemainsRecoverableByRetrieve() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("CREATED");
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(refundService.claimProcessing(eq(88L), eq(0L), eq("CREATED"), any(), any())).thenReturn(1);
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(noRefundResponse());
+        when(client.refundFull(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "1165", "Already refunded", null, null, "{}"));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "TASK"));
+
+        verify(refundService, never()).markFailed(any(), any());
+        verify(refundService).markUnknown(eq(88L), eq(1L), any(), any());
+    }
+
+    @Test
+    void inconclusiveUnknownRetrieveMovesNextAttemptAndKeepsDeadline() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund refund = refund("UNKNOWN");
+        Date deadline = new Date(System.currentTimeMillis() + 60_000L);
+        refund.setReconcileDeadline(deadline);
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(refund);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "9000", "Temporary", null, null, "{}"));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "TASK"));
+
+        verify(refundService).rescheduleUnknown(eq(88L), eq(0L), any(), eq(deadline));
+    }
+
+    @Test
+    void expiredProcessingLeaseInitializesUnknownDeadlineBeforeRetrieve() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund processing = refund("PROCESSING");
+        processing.setLeaseUntil(new Date(System.currentTimeMillis() - 1_000L));
+        PosOrderLineRefund recovered = refund("UNKNOWN");
+        recovered.setVersion(1L);
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(processing);
+        when(refundService.recoverExpiredProcessing(eq(88L), eq(0L), any(), any())).thenReturn(1);
+        when(refundService.getByPaymentId(70L)).thenReturn(recovered);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "9000", "Temporary", null, null, "{}"));
+
+        assertEquals("UNKNOWN", service.requestFullRefund(70L, "TASK"));
+
+        verify(refundService).recoverExpiredProcessing(eq(88L), eq(0L), any(), any());
+        verify(refundService).rescheduleUnknown(eq(88L), eq(1L), any(), any());
+    }
+
+    @Test
+    void expiredUnknownWithInconclusiveRetrieveMovesToManualReviewWithoutRescheduling() throws Exception {
+        PosOrderLinePayment payment = payment();
+        PosOrderLineRefund expired = refund("UNKNOWN");
+        expired.setReconcileDeadline(new Date(System.currentTimeMillis() - 1_000L));
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "TASK")).thenReturn(expired);
+        when(credentialService.getById(5L)).thenReturn(credential());
+        when(client.retrieveByTransactionId(any(), eq("TX-100"))).thenReturn(
+                new LinePayResponse(200, "9000", "Temporary", null, null, "{}"));
+        when(refundService.markManualReview(88L, 0L, "UNKNOWN")).thenReturn(1);
+
+        assertEquals("MANUAL_REVIEW", service.requestFullRefund(70L, "TASK"));
+
+        verify(refundService, never()).rescheduleUnknown(any(), any(), any(), any());
+        verify(refundService).markManualReview(88L, 0L, "UNKNOWN");
+    }
+
+    private static LinePayResponse noRefundResponse() {
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
+                + "\"payInfo\":[{\"amount\":280}],\"refundList\":[]}]}";
+        return new LinePayResponse(200, "0000", "Success", null, null, raw);
+    }
+
+    private static PosOrderLinePayment payment() {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(70L);
+        payment.setDdId("DD-100");
+        payment.setLineOrderId("LP-100");
+        payment.setTransactionId("TX-100");
+        payment.setCredentialId(5L);
+        payment.setStoreId(9L);
+        payment.setAmount(280);
+        payment.setCurrency("TWD");
+        payment.setStatus("PAID");
+        return payment;
+    }
+
+    private static PosOrderLineRefund refund(String status) {
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setId(88L);
+        refund.setPaymentId(70L);
+        refund.setStatus(status);
+        refund.setVersion(0L);
+        return refund;
+    }
+
+    private static PosStoreLinePay credential() {
+        PosStoreLinePay credential = new PosStoreLinePay();
+        credential.setId(5L);
+        credential.setEnvironment("SANDBOX");
+        credential.setChannelId("channel");
+        credential.setChannelSecret("secret");
+        return credential;
+    }
+
+    private static PosStoreLinePay currentCredential(String channelId) {
+        PosStoreLinePay credential = new PosStoreLinePay();
+        credential.setId(6L);
+        credential.setStoreId(9L);
+        credential.setEnvironment("SANDBOX");
+        credential.setChannelId(channelId);
+        credential.setChannelSecret("new-secret");
+        return credential;
+    }
+}

+ 22 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReturnPageRendererTest.java

@@ -0,0 +1,22 @@
+package com.ruoyi.app.pay;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LinePayReturnPageRendererTest {
+
+    @Test
+    void fixedDeepLinkUrlEncodesOrderAndHtmlDoesNotInjectItIntoScript() {
+        LinePayReturnPageRenderer renderer = new LinePayReturnPageRenderer();
+
+        String html = renderer.render("DD-1\"</script><script>alert(1)</script>");
+
+        assertTrue(html.contains("com.twanmsdyh.app://payment/result?orderId=DD-1%22%3C%2Fscript%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"));
+        assertFalse(html.contains("</script><script>alert(1)</script>"));
+        assertTrue(html.contains("Payment is being confirmed"));
+        assertTrue(html.contains("Open app"));
+    }
+}
+

+ 90 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePaySelectionTest.java

@@ -0,0 +1,90 @@
+package com.ruoyi.app.pay;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.pay.dto.LinePayQueryResult;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.Test;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class LinePaySelectionTest {
+
+    @Test
+    void unpaidQuerySelectsUniqueActiveAttemptInsteadOfNewerHistoricalReturn() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1"); order.setUserId(7L); order.setPayStatus(0L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        PosOrderLinePayment active = attempt(2L, "WAITING_AUTH", "DD-1", 100L);
+        PosOrderLinePayment historical = attempt(3L, "FAILED", null, 200L);
+        when(payments.getByDdId("DD-1")).thenReturn(List.of(historical, active));
+        LinePayService service = new LinePayService(orders, mock(IPosStoreLinePayService.class),
+                payments, mock(IPosOrderLineRefundService.class), mock(LinePayClient.class),
+                mock(LinePayFactService.class));
+
+        LinePayQueryResult result = service.query(7L, "DD-1");
+
+        assertEquals(2L, result.getPaymentId());
+        assertEquals("WAITING_AUTH", result.getPaymentStatus());
+    }
+
+    @Test
+    void multiplePaidAttemptsReturnManualReview() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1"); order.setUserId(7L); order.setPayStatus(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(payments.getByDdId("DD-1")).thenReturn(List.of(
+                attempt(2L, "PAID", "DD-1", 100L), attempt(3L, "PAID", "DD-1", 200L)));
+        LinePayService service = new LinePayService(orders, mock(IPosStoreLinePayService.class),
+                payments, mock(IPosOrderLineRefundService.class), mock(LinePayClient.class),
+                mock(LinePayFactService.class));
+
+        assertEquals("MANUAL_REVIEW", service.query(7L, "DD-1").getPaymentStatus());
+    }
+
+    @Test
+    void paidQuerySelectsTheOnlyNotFullyRefundedAttempt() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1"); order.setUserId(7L); order.setPayStatus(1L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        PosOrderLinePayment refunded = attempt(2L, "PAID", null, 100L);
+        PosOrderLinePayment captured = attempt(3L, "PAID", "DD-1", 200L);
+        when(payments.getByDdId("DD-1")).thenReturn(List.of(captured, refunded));
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setPaymentId(2L); refund.setStatus("REFUNDED");
+        when(refunds.getByPaymentId(2L)).thenReturn(refund);
+        LinePayService service = new LinePayService(orders, mock(IPosStoreLinePayService.class),
+                payments, refunds, mock(LinePayClient.class), mock(LinePayFactService.class));
+
+        LinePayQueryResult result = service.query(7L, "DD-1");
+
+        assertEquals(3L, result.getPaymentId());
+        assertEquals("PAID", result.getPaymentStatus());
+    }
+
+    private static PosOrderLinePayment attempt(Long id, String status, String active, long time) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(id); payment.setStatus(status); payment.setActiveDdId(active);
+        payment.setCreateTime(new Date(time));
+        return payment;
+    }
+}

+ 225 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java

@@ -0,0 +1,225 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.pay.dto.LinePayCreateResult;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.dao.DuplicateKeyException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LinePayServiceCreateTest {
+
+    private IPosOrderService orderService;
+    private IPosStoreLinePayService credentialService;
+    private IPosOrderLinePaymentService paymentService;
+    private LinePayClient linePayClient;
+    private LinePayService service;
+
+    @BeforeEach
+    void setUp() {
+        orderService = mock(IPosOrderService.class);
+        credentialService = mock(IPosStoreLinePayService.class);
+        paymentService = mock(IPosOrderLinePaymentService.class);
+        linePayClient = mock(LinePayClient.class);
+        service = new LinePayService(orderService, credentialService, paymentService,
+                mock(IPosOrderLineRefundService.class), linePayClient, mock(LinePayFactService.class));
+    }
+
+    @Test
+    void duplicateCreateReusesActiveAttemptWithoutCallingLineAgain() throws Exception {
+        PosOrder order = order();
+        PosOrderLinePayment active = payment("WAITING_AUTH");
+        active.setPaymentUrlWeb("https://pay.example/existing");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.getActiveByDdId("DD-100")).thenReturn(active);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertTrue(result.isReusedAttempt());
+        assertEquals(70L, result.getPaymentId());
+        assertEquals("https://pay.example/existing", result.getPaymentUrl());
+        verify(linePayClient, never()).request(any(), any());
+        verify(paymentService, never()).createRequesting(any(), any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void newCreatePersistsIntentBeforeSavingGatewayIdentifiers() throws Exception {
+        PosOrder order = order();
+        PosOrderLinePayment requesting = payment("REQUESTING");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.getActiveByDdId("DD-100")).thenReturn(null);
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenReturn(requesting);
+        JSONObject paymentUrl = new JSONObject();
+        paymentUrl.put("web", "https://pay.example/web");
+        JSONObject info = new JSONObject();
+        info.put("transactionId", "2026081200000000001");
+        info.put("paymentUrl", paymentUrl);
+        when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
+                200, "0000", "Success", "2026081200000000001", info, "{}"));
+        when(paymentService.markRequestSucceeded(eq(70L), eq(0L),
+                eq("2026081200000000001"), eq("https://pay.example/web"),
+                any(), any(), any())).thenReturn(1);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertEquals(70L, result.getPaymentId());
+        assertEquals("WAITING_AUTH", result.getStatus());
+        assertEquals("2026081200000000001", result.getTransactionId());
+        verify(paymentService).createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any());
+        verify(paymentService).markRequestSucceeded(eq(70L), eq(0L),
+                eq("2026081200000000001"), eq("https://pay.example/web"),
+                any(), any(), any());
+    }
+
+    @Test
+    void transportUnknownKeepsSameAttemptBlockedForReadOnlyRecovery() throws Exception {
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenReturn(payment("REQUESTING"));
+        when(linePayClient.request(any(), any())).thenThrow(new IllegalStateException("timeout"));
+        when(paymentService.markRequestUnknown(eq(70L), eq(0L), any(), any())).thenReturn(1);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertEquals("REQUEST_UNKNOWN", result.getStatus());
+        verify(paymentService).markRequestUnknown(eq(70L), eq(0L), any(), any());
+        verify(paymentService, never()).markTerminal(any(), any(), any(), any());
+    }
+
+    @Test
+    void concurrentIntentNeverCallsRequestTwiceForTheExistingRow() throws Exception {
+        PosOrderLinePayment concurrent = payment("REQUESTING");
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.getActiveByDdId("DD-100")).thenReturn(null, concurrent);
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenThrow(new DuplicateKeyException("active"));
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertTrue(result.isReusedAttempt());
+        assertEquals(70L, result.getPaymentId());
+        verify(linePayClient, never()).request(any(), any());
+    }
+
+    @Test
+    void definiteRequestRejectionReleasesAttemptForAnotherPayment() throws Exception {
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenReturn(payment("REQUESTING"));
+        when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
+                200, "1104", "Invalid amount", null, null, "{}"));
+        when(paymentService.markTerminal(70L, 0L, "REQUESTING", "FAILED")).thenReturn(1);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertEquals("FAILED", result.getStatus());
+        verify(paymentService).markTerminal(70L, 0L, "REQUESTING", "FAILED");
+        verify(paymentService, never()).markRequestUnknown(any(), any(), any(), any());
+    }
+
+    @Test
+    void inProgressOrDuplicateOrderRequestNeverReleasesActiveAttempt() throws Exception {
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenReturn(payment("REQUESTING"));
+        when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
+                200, "1172", "Order already exists", null, null, "{}"));
+        when(paymentService.markRequestUnknown(eq(70L), eq(0L), any(), any())).thenReturn(1);
+
+        LinePayCreateResult result = service.create(101L, "DD-100");
+
+        assertEquals("REQUEST_UNKNOWN", result.getStatus());
+        verify(paymentService, never()).markTerminal(any(), any(), any(), any());
+        verify(paymentService).markRequestUnknown(eq(70L), eq(0L), any(), any());
+    }
+
+    @Test
+    void failedTerminalCasReturnsConcurrentDurableStateWithoutReleasingAttempt() throws Exception {
+        when(orderService.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orderService.count(any(Wrapper.class))).thenReturn(1L);
+        when(credentialService.getEnabledCurrent(9L)).thenReturn(credential());
+        when(paymentService.createRequesting(eq("DD-100"), anyString(), eq(5L), eq(9L),
+                eq(280), eq("TWD"), any())).thenReturn(payment("REQUESTING"));
+        when(linePayClient.request(any(), any())).thenReturn(new LinePayResponse(
+                200, "1104", "Invalid merchant", null, null, "{}"));
+        when(paymentService.markTerminal(70L, 0L, "REQUESTING", "FAILED")).thenReturn(0);
+        PosOrderLinePayment concurrent = payment("WAITING_AUTH");
+        when(paymentService.getById(70L)).thenReturn(concurrent);
+
+        assertEquals("WAITING_AUTH", service.create(101L, "DD-100").getStatus());
+
+        verify(paymentService, never()).markRequestUnknown(any(), any(), any(), any());
+    }
+
+    private static PosOrder order() {
+        PosOrder order = new PosOrder();
+        order.setId(10L);
+        order.setDdId("DD-100");
+        order.setParentDdId("DD-100");
+        order.setUserId(101L);
+        order.setMdId(9L);
+        order.setAmount(280);
+        order.setPayType("3");
+        order.setPayStatus(0L);
+        order.setState(0L);
+        return order;
+    }
+
+    private static PosStoreLinePay credential() {
+        PosStoreLinePay credential = new PosStoreLinePay();
+        credential.setId(5L);
+        credential.setStoreId(9L);
+        credential.setChannelId("1234567890");
+        credential.setChannelSecret("secret");
+        return credential;
+    }
+
+    private static PosOrderLinePayment payment(String status) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(70L);
+        payment.setDdId("DD-100");
+        payment.setLineOrderId("LP-100-01");
+        payment.setCredentialId(5L);
+        payment.setStoreId(9L);
+        payment.setAmount(280);
+        payment.setCurrency("TWD");
+        payment.setStatus(status);
+        payment.setActiveDdId("DD-100");
+        payment.setVersion(0L);
+        return payment;
+    }
+}

+ 103 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/task/LinePayReconcileTaskTest.java

@@ -0,0 +1,103 @@
+package com.ruoyi.app.task;
+
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.app.pay.LinePayCancellationCompensationService;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RLock;
+import org.redisson.api.RedissonClient;
+
+import java.util.Date;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.doAnswer;
+import org.springframework.test.util.ReflectionTestUtils;
+
+class LinePayReconcileTaskTest {
+
+    @Test
+    void watchdogLockRowLeasesAndSingleFailureDoNotStopBatch() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        LinePayService linePay = mock(LinePayService.class);
+        LinePayRefundService lineRefund = mock(LinePayRefundService.class);
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:line-pay:reconcile")).thenReturn(lock);
+        when(lock.tryLock()).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+
+        PosOrderLinePayment first = payment(1L);
+        PosOrderLinePayment second = payment(2L);
+        when(payments.scanDue(any(), anyInt())).thenReturn(List.of(first, second));
+        when(payments.claimReconcileLease(any(), any(), anyString(), any(), any())).thenReturn(1);
+        when(linePay.reconcilePayment(org.mockito.ArgumentMatchers.eq(1L),
+                org.mockito.ArgumentMatchers.eq("TASK"), anyString()))
+                .thenThrow(new IllegalStateException("one failure"));
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setPaymentId(9L);
+        when(refunds.scanDue(any(), anyInt())).thenReturn(List.of(refund));
+
+        when(payments.getCancelledPaidWithoutRefund(anyInt())).thenReturn(List.of());
+        new LinePayReconcileTask(payments, refunds, linePay, lineRefund, redisson,
+                mock(LinePayCancellationCompensationService.class)).reconcile();
+
+        verify(lock).tryLock();
+        verify(linePay).reconcilePayment(org.mockito.ArgumentMatchers.eq(2L),
+                org.mockito.ArgumentMatchers.eq("TASK"), anyString());
+        verify(lineRefund).requestFullRefund(9L, "TASK");
+        verify(lock).unlock();
+    }
+
+    @Test
+    void slowPaymentCannotStarveCancellationRepairOrRefundPhase() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        LinePayService linePay = mock(LinePayService.class);
+        LinePayRefundService lineRefund = mock(LinePayRefundService.class);
+        LinePayCancellationCompensationService compensation =
+                mock(LinePayCancellationCompensationService.class);
+        RedissonClient redisson = mock(RedissonClient.class);
+        RLock lock = mock(RLock.class);
+        when(redisson.getLock("lock:line-pay:reconcile")).thenReturn(lock);
+        when(lock.tryLock()).thenReturn(true);
+        when(lock.isHeldByCurrentThread()).thenReturn(true);
+        PosOrderLinePayment slow = payment(1L);
+        when(payments.scanDue(any(), anyInt())).thenReturn(List.of(slow));
+        when(payments.claimReconcileLease(any(), any(), anyString(), any(), any())).thenReturn(1);
+        doAnswer(invocation -> {
+            Thread.sleep(1_050L);
+            return "WAITING_AUTH";
+        }).when(linePay).reconcilePayment(any(), anyString(), anyString());
+        when(payments.getCancelledPaidWithoutRefund(anyInt())).thenReturn(List.of());
+        PosOrderLineRefund refund = new PosOrderLineRefund();
+        refund.setPaymentId(9L);
+        when(refunds.scanDue(any(), anyInt())).thenReturn(List.of(refund));
+        LinePayReconcileTask task = new LinePayReconcileTask(payments, refunds, linePay,
+                lineRefund, redisson, compensation);
+        ReflectionTestUtils.setField(task, "roundBudgetSeconds", 1L);
+
+        task.reconcile();
+
+        verify(compensation).createMissingRefundIntents(any());
+        verify(lineRefund).requestFullRefund(9L, "TASK");
+    }
+
+    private static PosOrderLinePayment payment(Long id) {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(id);
+        payment.setVersion(0L);
+        payment.setNextReconcileAt(new Date());
+        return payment;
+    }
+}

+ 121 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java

@@ -0,0 +1,121 @@
+package com.ruoyi.app.utils.linepay;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LinePayClientTest {
+
+    private RecordingTransport transport;
+    private LinePayClient client;
+
+    @BeforeEach
+    void setUp() {
+        transport = new RecordingTransport();
+        LinePayProperties properties = new LinePayProperties();
+        properties.setBaseUrl("https://sandbox-api-pay.line.me");
+        properties.setConfirmUrl("https://api.example.com/pay/line/confirm");
+        properties.setCancelUrl("https://api.example.com/pay/line/cancel");
+        properties.setAppPackageName("com.twanmsdyh.app");
+        client = new LinePayClient(properties, new LinePaySigner(), transport, () -> "nonce-123");
+    }
+
+    @Test
+    void requestBuildsBalancedPackageAndClientRedirectContract() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"returnMessage\":\"Success\","
+                + "\"info\":{\"transactionId\":\"2026081200000000001\","
+                + "\"paymentUrl\":{\"web\":\"https://pay.example/web\"}}}";
+
+        LinePayResponse response = client.request(credential(),
+                new LinePayRequest("LP-001", "DD-001", "Order DD-001", 280, "TWD"));
+
+        assertEquals("POST", transport.method);
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments/request", transport.url);
+        JSONObject body = JSON.parseObject(transport.body);
+        assertEquals(280, body.getIntValue("amount"));
+        assertEquals(280, body.getJSONArray("packages").getJSONObject(0).getIntValue("amount"));
+        assertEquals(280, body.getJSONArray("packages").getJSONObject(0)
+                .getJSONArray("products").getJSONObject(0).getIntValue("price"));
+        assertFalse(body.getJSONObject("redirectUrls").containsKey("confirmUrlType"));
+        assertFalse(body.getJSONObject("redirectUrls").containsKey("appPackageName"));
+        assertFalse(body.getJSONObject("options").getJSONObject("payment").containsKey("payType"));
+        assertTrue(body.getJSONObject("options").getJSONObject("payment").getBooleanValue("capture"));
+        assertEquals("2026081200000000001", response.transactionId());
+    }
+
+    @Test
+    void retrieveSignsTheSameEncodedQueryThatTransportSends() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"1150\",\"returnMessage\":\"No transaction\"}";
+
+        client.retrieveByOrderId(credential(), "LP 订单-1");
+
+        assertEquals("GET", transport.method);
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments?orderId=LP+%E8%AE%A2%E5%8D%95-1", transport.url);
+        assertEquals("Row7cngEXZA5F5yEJ722+SIXpyJMVOBsff71BZz62oU=",
+                transport.headers.get("X-LINE-Authorization"));
+    }
+
+    @Test
+    void fullRefundUsesEmptyJsonAndNeverSendsRefundAmount() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"returnMessage\":\"Success\","
+                + "\"info\":{\"refundTransactionId\":\"9001\"}}";
+
+        client.refundFull(credential(), "2026081200000000001");
+
+        assertEquals("POST", transport.method);
+        assertEquals("{}", transport.body);
+        assertFalse(transport.body.contains("refundAmount"));
+        assertEquals(20_000, transport.readTimeoutMs);
+    }
+
+    @Test
+    void confirmUsesAtLeastFortySecondReadTimeout() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"returnMessage\":\"Success\"}";
+
+        client.confirm(credential(), "2026081200000000001", 280, "TWD");
+
+        assertEquals(40_000, transport.readTimeoutMs);
+    }
+
+    @Test
+    void checkUsesRequestTransactionIdInPath() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\"}";
+
+        client.check(credential(), "2026081200000000001");
+
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments/requests/"
+                + "2026081200000000001/check", transport.url);
+    }
+
+    private static LinePayCredential credential() {
+        return new LinePayCredential(5L, "1234567890", "test-secret");
+    }
+
+    private static final class RecordingTransport implements LinePayHttpTransport {
+        String method;
+        String url;
+        String body;
+        Map<String, String> headers;
+        int readTimeoutMs;
+        String responseBody;
+
+        @Override
+        public LinePayHttpResponse execute(String method, String url, String body,
+                                           Map<String, String> headers, int readTimeoutMs) {
+            this.method = method;
+            this.url = url;
+            this.body = body;
+            this.headers = new LinkedHashMap<>(headers);
+            this.readTimeoutMs = readTimeoutMs;
+            return new LinePayHttpResponse(200, responseBody);
+        }
+    }
+}

+ 43 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePaySignerTest.java

@@ -0,0 +1,43 @@
+package com.ruoyi.app.utils.linepay;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+
+class LinePaySignerTest {
+
+    private final LinePaySigner signer = new LinePaySigner();
+
+    @Test
+    void signsExactUtf8PostBodyIncludingNonAsciiCharacters() {
+        String signature = signer.sign("test-secret", "/v4/payments/request",
+                "{\"amount\":100,\"currency\":\"TWD\",\"orderId\":\"订单-1\"}", "nonce-123");
+
+        assertEquals("UVkesLuCS7osTAd/T/YMld8uDwCicfkBHoUtcq0xtXY=", signature);
+    }
+
+    @Test
+    void signsFinalOrderedQueryStringForGet() {
+        String signature = signer.sign("test-secret", "/v4/payments",
+                "orderId=LP%2D001&fields=ORDER", "nonce-123");
+
+        assertEquals("IYNy7LPc3wdkSMgu13sQNrhKbvmMNedLQ1/lEuo8Elo=", signature);
+    }
+
+    @Test
+    void changingQueryOrderChangesSignature() {
+        String first = signer.sign("test-secret", "/v4/payments", "a=1&b=2", "nonce-123");
+        String second = signer.sign("test-secret", "/v4/payments", "b=2&a=1", "nonce-123");
+
+        assertNotEquals(first, second);
+    }
+
+    @Test
+    void emptyBodyAndFreshNonceArePartOfTheSignature() {
+        String first = signer.sign("test-secret", "/v4/payments/request", "", "nonce-1");
+        String second = signer.sign("test-secret", "/v4/payments/request", "", "nonce-2");
+
+        assertNotEquals(first, second);
+    }
+}

+ 38 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PaymentGatewayLog.java

@@ -0,0 +1,38 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+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.util.Date;
+
+/** LINE Pay 网关请求、响应和事件的追加审计日志。 */
+@Data
+@TableName("payment_gateway_log")
+public class PaymentGatewayLog {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String correlationId;
+    private Long paymentId;
+    private Long refundId;
+    private Long credentialId;
+    private Long storeId;
+    private String ddId;
+    private String gatewayOrderId;
+    private String transactionId;
+    private String action;
+    private String direction;
+    private String source;
+    private Integer httpStatus;
+    private String returnCode;
+    private String returnMessage;
+    private Integer success;
+    private Long durationMs;
+    private String payload;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+}
+

+ 1 - 1
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -272,7 +272,7 @@ public class PosOrder {
     private Date sdTime;
 
     /**
-     * 支付方式 1 到付 2 vnpay  3 zalopay 二维码支付 :4  银行卡 5
+     * 支付方式:1 到付,2 VNPAY,3 LINE Pay,4 银行卡,5 余额,7 OMG
      */
     private String payType;
 

+ 45 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLinePayment.java

@@ -0,0 +1,45 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+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.util.Date;
+
+/** 单次 LINE Pay 支付尝试;同一订单的历史尝试永不覆盖。 */
+@Data
+@TableName("pos_order_line_payment")
+public class PosOrderLinePayment {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String ddId;
+    private String lineOrderId;
+    private String transactionId;
+    private Long credentialId;
+    private Long storeId;
+    private Integer amount;
+    private String currency;
+    private String paymentUrlWeb;
+    private String paymentUrlApp;
+    private String paymentProvider;
+    private String status;
+    private String activeDdId;
+    private Long version;
+    private Date nextReconcileAt;
+    private Date reconcileDeadline;
+    private Integer reconcileCount;
+    private String leaseOwner;
+    private Date leaseUntil;
+    private Date statusChangedAt;
+    private Date authCompletedTime;
+    private Date payTime;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+}
+

+ 39 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLineRefund.java

@@ -0,0 +1,39 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+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.util.Date;
+
+/** LINE Pay 全额退款事实与恢复状态。 */
+@Data
+@TableName("pos_order_line_refund")
+public class PosOrderLineRefund {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long paymentId;
+    private String ddId;
+    private Long credentialId;
+    private String transactionId;
+    private String refundTransactionId;
+    private Integer amount;
+    private String status;
+    private String source;
+    private Long version;
+    private Date nextReconcileAt;
+    private Date reconcileDeadline;
+    private Integer reconcileCount;
+    private String leaseOwner;
+    private Date leaseUntil;
+    private Date refundTime;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+}
+

+ 39 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreLinePay.java

@@ -0,0 +1,39 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+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.util.Date;
+
+/** 门店 LINE Pay 不可变凭证版本。 */
+@Data
+@TableName("pos_store_line_pay")
+public class PosStoreLinePay {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long storeId;
+    private Integer credentialVersion;
+    private String channelId;
+    private String channelSecret;
+    private String environment;
+    private String credentialStatus;
+    private Integer isEnabled;
+    private Long currentStoreId;
+    private String verifyReturnCode;
+    private String verifyReturnMessage;
+    private Date verifiedTime;
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+    @TableField(fill = FieldFill.INSERT)
+    private String createBy;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private String updateBy;
+}
+

+ 12 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreLinePayCredentialDto.java

@@ -0,0 +1,12 @@
+package com.ruoyi.system.domain.dto;
+
+import lombok.Data;
+
+/** 平台录入门店 LINE Pay 凭证。业务校验由 Controller/Service 完成。 */
+@Data
+public class StoreLinePayCredentialDto {
+    private Long storeId;
+    private String channelId;
+    private String channelSecret;
+}
+

+ 11 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreLinePayToggleDto.java

@@ -0,0 +1,11 @@
+package com.ruoyi.system.domain.dto;
+
+import lombok.Data;
+
+/** 平台启停门店当前 LINE Pay 凭证。 */
+@Data
+public class StoreLinePayToggleDto {
+    private Long storeId;
+    private Boolean enabled;
+}
+

+ 31 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreLinePayVo.java

@@ -0,0 +1,31 @@
+package com.ruoyi.system.domain.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/** 平台门店 LINE Pay 凭证列表与详情。 */
+@Data
+public class PosStoreLinePayVo {
+    private Long storeId;
+    private String posName;
+    private Long userId;
+    private String userName;
+    private Integer isStall;
+    private Integer isNightMarket;
+    private Long nightMarketId;
+    private Long credentialId;
+    private Integer credentialVersion;
+    private String channelId;
+    private String channelSecret;
+    private String environment;
+    private String credentialStatus;
+    private Integer isEnabled;
+    private Integer hasCredential;
+    private String verifyReturnCode;
+    private String verifyReturnMessage;
+    private Date verifiedTime;
+
+    private String posNameLike;
+}
+

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PaymentGatewayLogMapper.java

@@ -0,0 +1,8 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PaymentGatewayLog;
+
+public interface PaymentGatewayLogMapper extends BaseMapper<PaymentGatewayLog> {
+}
+

+ 58 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLinePaymentMapper.java

@@ -0,0 +1,58 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface PosOrderLinePaymentMapper extends BaseMapper<PosOrderLinePayment> {
+    PosOrderLinePayment selectActiveByDdId(@Param("ddId") String ddId);
+    PosOrderLinePayment selectByLineOrderId(@Param("lineOrderId") String lineOrderId);
+    PosOrderLinePayment selectByTransactionId(@Param("transactionId") String transactionId);
+    PosOrderLinePayment selectPaymentById(@Param("id") Long id);
+    List<PosOrderLinePayment> selectByDdId(@Param("ddId") String ddId);
+    List<PosOrderLinePayment> selectCancelledPaidWithoutRefund(@Param("batchSize") int batchSize);
+    int markTerminal(@Param("id") Long id, @Param("version") Long version,
+                     @Param("expectedStatus") String expectedStatus,
+                     @Param("targetStatus") String targetStatus,
+                     @Param("updateTime") Date updateTime);
+    int markRequestSucceeded(@Param("id") Long id, @Param("version") Long version,
+                             @Param("transactionId") String transactionId,
+                             @Param("paymentUrlWeb") String paymentUrlWeb,
+                             @Param("paymentUrlApp") String paymentUrlApp,
+                             @Param("nextReconcileAt") Date nextReconcileAt,
+                             @Param("reconcileDeadline") Date reconcileDeadline,
+                             @Param("updateTime") Date updateTime);
+    int markRequestUnknown(@Param("id") Long id, @Param("version") Long version,
+                           @Param("nextReconcileAt") Date nextReconcileAt,
+                           @Param("reconcileDeadline") Date reconcileDeadline,
+                           @Param("updateTime") Date updateTime);
+    int claimConfirm(@Param("id") Long id, @Param("version") Long version,
+                     @Param("expectedStatus") String expectedStatus,
+                     @Param("leaseOwner") String leaseOwner,
+                     @Param("leaseUntil") Date leaseUntil,
+                     @Param("updateTime") Date updateTime);
+    int markAuthDoneOrderCancelled(@Param("id") Long id, @Param("version") Long version,
+                                   @Param("expectedStatus") String expectedStatus,
+                                   @Param("nextReconcileAt") Date nextReconcileAt,
+                                   @Param("reconcileDeadline") Date reconcileDeadline,
+                                   @Param("updateTime") Date updateTime);
+    int markConfirmUnknown(@Param("id") Long id, @Param("version") Long version,
+                           @Param("nextReconcileAt") Date nextReconcileAt,
+                           @Param("reconcileDeadline") Date reconcileDeadline,
+                           @Param("updateTime") Date updateTime);
+    int markPaid(@Param("id") Long id, @Param("version") Long version,
+                 @Param("expectedStatus") String expectedStatus,
+                 @Param("transactionId") String transactionId,
+                 @Param("paymentProvider") String paymentProvider,
+                 @Param("payTime") Date payTime, @Param("updateTime") Date updateTime);
+    int markManualReview(@Param("id") Long id, @Param("version") Long version,
+                         @Param("expectedStatus") String expectedStatus,
+                         @Param("updateTime") Date updateTime);
+    List<PosOrderLinePayment> scanDue(@Param("now") Date now, @Param("batchSize") int batchSize);
+    int claimReconcileLease(@Param("id") Long id, @Param("version") Long version,
+                            @Param("leaseOwner") String leaseOwner,
+                            @Param("leaseUntil") Date leaseUntil, @Param("now") Date now);
+}

+ 53 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLineRefundMapper.java

@@ -0,0 +1,53 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface PosOrderLineRefundMapper extends BaseMapper<PosOrderLineRefund> {
+    PosOrderLineRefund selectByPaymentId(@Param("paymentId") Long paymentId);
+
+    int claimProcessing(@Param("id") Long id, @Param("version") Long version,
+                        @Param("expectedStatus") String expectedStatus,
+                        @Param("leaseOwner") String leaseOwner,
+                        @Param("leaseUntil") Date leaseUntil,
+                        @Param("updateTime") Date updateTime);
+
+    int markUnknown(@Param("id") Long id, @Param("version") Long version,
+                    @Param("nextReconcileAt") Date nextReconcileAt,
+                    @Param("reconcileDeadline") Date reconcileDeadline,
+                    @Param("updateTime") Date updateTime);
+    int markRetryWait(@Param("id") Long id, @Param("version") Long version,
+                      @Param("nextReconcileAt") Date nextReconcileAt,
+                      @Param("updateTime") Date updateTime);
+    int markFailed(@Param("id") Long id, @Param("version") Long version,
+                   @Param("updateTime") Date updateTime);
+    int recoverExpiredProcessing(@Param("id") Long id, @Param("version") Long version,
+                                 @Param("nextReconcileAt") Date nextReconcileAt,
+                                 @Param("reconcileDeadline") Date reconcileDeadline,
+                                 @Param("updateTime") Date updateTime);
+    int rescheduleUnknown(@Param("id") Long id, @Param("version") Long version,
+                          @Param("nextReconcileAt") Date nextReconcileAt,
+                          @Param("reconcileDeadline") Date reconcileDeadline,
+                          @Param("updateTime") Date updateTime);
+
+    int markRefunded(@Param("id") Long id, @Param("version") Long version,
+                     @Param("refundTransactionId") String refundTransactionId,
+                     @Param("refundTime") Date refundTime,
+                     @Param("updateTime") Date updateTime);
+    int markRefundedFromEvidence(@Param("id") Long id, @Param("version") Long version,
+                                 @Param("expectedStatus") String expectedStatus,
+                                 @Param("refundTransactionId") String refundTransactionId,
+                                 @Param("refundTime") Date refundTime,
+                                 @Param("updateTime") Date updateTime);
+    List<PosOrderLineRefund> scanDue(@Param("now") Date now, @Param("batchSize") int batchSize);
+    int markManualReview(@Param("id") Long id, @Param("version") Long version,
+                         @Param("expectedStatus") String expectedStatus,
+                         @Param("updateTime") Date updateTime);
+    int markManualReviewFromEvidence(@Param("id") Long id, @Param("version") Long version,
+                                     @Param("expectedStatus") String expectedStatus,
+                                     @Param("updateTime") Date updateTime);
+}

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderMapper.java

@@ -200,4 +200,8 @@ public interface PosOrderMapper  extends BaseMapper<PosOrder>
      * @return 结果
      */
     public Double totalTurnoverByNowYear(Date nowYearFirstDay);
+
+    int markLinePaid(@Param("ddId") String ddId, @Param("paymentId") Long paymentId);
+
+    int markLineRefunded(@Param("ddId") String ddId, @Param("paymentId") Long paymentId);
 }

+ 20 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreLinePayMapper.java

@@ -0,0 +1,20 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.vo.PosStoreLinePayVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+
+public interface PosStoreLinePayMapper extends BaseMapper<PosStoreLinePay> {
+    Long lockStore(@Param("storeId") Long storeId);
+    PosStoreLinePay selectCurrentByStoreId(@Param("storeId") Long storeId);
+    Integer selectMaxVersionByStoreId(@Param("storeId") Long storeId);
+    int clearCurrent(@Param("id") Long id, @Param("storeId") Long storeId,
+                     @Param("updateTime") Date updateTime);
+    List<PosStoreLinePayVo> selectLinePayStoreList(PosStoreLinePayVo query);
+    PosStoreLinePayVo selectLinePayStoreDetail(@Param("storeId") Long storeId);
+}
+

+ 8 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPaymentGatewayLogService.java

@@ -0,0 +1,8 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PaymentGatewayLog;
+
+public interface IPaymentGatewayLogService {
+    int append(PaymentGatewayLog gatewayLog);
+}
+

+ 33 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLinePaymentService.java

@@ -0,0 +1,33 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosOrderLinePayment;
+
+import java.util.Date;
+import java.util.List;
+
+public interface IPosOrderLinePaymentService {
+    PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
+                                         Long storeId, Integer amount, String currency,
+                                         Date reconcileDeadline);
+    PosOrderLinePayment getActiveByDdId(String ddId);
+    PosOrderLinePayment getByLineOrderId(String lineOrderId);
+    PosOrderLinePayment getByTransactionId(String transactionId);
+    PosOrderLinePayment getById(Long id);
+    List<PosOrderLinePayment> getByDdId(String ddId);
+    List<PosOrderLinePayment> getCancelledPaidWithoutRefund(int batchSize);
+    int markTerminal(Long id, Long version, String expectedStatus, String targetStatus);
+    int markRequestSucceeded(Long id, Long version, String transactionId,
+                             String paymentUrlWeb, String paymentUrlApp,
+                             Date nextReconcileAt, Date reconcileDeadline);
+    int markRequestUnknown(Long id, Long version, Date nextReconcileAt, Date reconcileDeadline);
+    int claimConfirm(Long id, Long version, String expectedStatus, String leaseOwner, Date leaseUntil);
+    int markAuthDoneOrderCancelled(Long id, Long version, String expectedStatus,
+                                   Date nextReconcileAt, Date reconcileDeadline);
+    int markConfirmUnknown(Long id, Long version, Date nextReconcileAt, Date reconcileDeadline);
+    int markPaid(Long id, Long version, String expectedStatus,
+                 String transactionId, String paymentProvider, Date payTime);
+    int markManualReview(Long id, Long version, String expectedStatus);
+    List<PosOrderLinePayment> scanDue(Date now, int batchSize);
+    int claimReconcileLease(Long id, Long version, String leaseOwner, Date leaseUntil, Date now);
+    void applyActiveKeyInvariant(PosOrderLinePayment payment);
+}

+ 27 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLineRefundService.java

@@ -0,0 +1,27 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+
+import java.util.Date;
+import java.util.List;
+
+public interface IPosOrderLineRefundService {
+    PosOrderLineRefund getByPaymentId(Long paymentId);
+    PosOrderLineRefund createIfAbsent(PosOrderLinePayment payment, String source);
+    int claimProcessing(Long id, Long version, String expectedStatus,
+                        String leaseOwner, Date leaseUntil);
+    int markUnknown(Long id, Long version, Date nextReconcileAt, Date reconcileDeadline);
+    int markRetryWait(Long id, Long version, Date nextReconcileAt);
+    int markFailed(Long id, Long version);
+    int recoverExpiredProcessing(Long id, Long version, Date nextReconcileAt,
+                                 Date reconcileDeadline);
+    int rescheduleUnknown(Long id, Long version, Date nextReconcileAt,
+                          Date reconcileDeadline);
+    int markRefunded(Long id, Long version, String refundTransactionId, Date refundTime);
+    int markRefundedFromEvidence(Long id, Long version, String expectedStatus,
+                                 String refundTransactionId, Date refundTime);
+    int markManualReview(Long id, Long version, String expectedStatus);
+    int markManualReviewFromEvidence(Long id, Long version, String expectedStatus);
+    List<PosOrderLineRefund> scanDue(Date now, int batchSize);
+}

+ 18 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreLinePayService.java

@@ -0,0 +1,18 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreLinePayVo;
+
+import java.util.List;
+
+public interface IPosStoreLinePayService {
+    PosStoreLinePay getCurrent(Long storeId);
+    PosStoreLinePay getEnabledCurrent(Long storeId);
+    PosStoreLinePay getById(Long credentialId);
+    List<PosStoreLinePayVo> selectStoreList(PosStoreLinePayVo query);
+    PosStoreLinePayVo selectStoreDetail(Long storeId);
+    PosStoreLinePay saveVerifiedCredential(StoreLinePayCredentialDto dto, String environment,
+                                           String returnCode, String returnMessage);
+    int setCurrentEnabled(Long storeId, boolean enabled);
+}

+ 29 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PaymentGatewayLogServiceImpl.java

@@ -0,0 +1,29 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PaymentGatewayLog;
+import com.ruoyi.system.mapper.PaymentGatewayLogMapper;
+import com.ruoyi.system.service.IPaymentGatewayLogService;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+
+@Service
+public class PaymentGatewayLogServiceImpl implements IPaymentGatewayLogService {
+    private final PaymentGatewayLogMapper gatewayLogMapper;
+
+    public PaymentGatewayLogServiceImpl(PaymentGatewayLogMapper gatewayLogMapper) {
+        this.gatewayLogMapper = gatewayLogMapper;
+    }
+
+    @Override
+    public int append(PaymentGatewayLog gatewayLog) {
+        if (gatewayLog == null) {
+            return 0;
+        }
+        if (gatewayLog.getCreateTime() == null) {
+            gatewayLog.setCreateTime(new Date());
+        }
+        return gatewayLogMapper.insert(gatewayLog);
+    }
+}
+

+ 179 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java

@@ -0,0 +1,179 @@
+package com.ruoyi.system.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.PosOrderLinePaymentMapper;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+
+@Service
+public class PosOrderLinePaymentServiceImpl implements IPosOrderLinePaymentService {
+
+    private static final Set<String> TERMINAL_RELEASE_STATES =
+            Set.of("CANCELLED_OR_EXPIRED", "FAILED");
+    private final PosOrderLinePaymentMapper paymentMapper;
+
+    public PosOrderLinePaymentServiceImpl(PosOrderLinePaymentMapper paymentMapper) {
+        this.paymentMapper = paymentMapper;
+    }
+
+    @Override
+    public PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
+                                                Long storeId, Integer amount, String currency,
+                                                Date reconcileDeadline) {
+        if (StrUtil.isBlank(ddId) || StrUtil.isBlank(lineOrderId) || credentialId == null
+                || storeId == null || amount == null || amount <= 0 || StrUtil.isBlank(currency)) {
+            throw new ServiceException("Invalid LINE Pay request intent");
+        }
+        Date now = new Date();
+        PosOrderLinePayment row = new PosOrderLinePayment();
+        row.setDdId(ddId);
+        row.setLineOrderId(lineOrderId);
+        row.setCredentialId(credentialId);
+        row.setStoreId(storeId);
+        row.setAmount(amount);
+        row.setCurrency(currency);
+        row.setStatus("REQUESTING");
+        row.setActiveDdId(ddId);
+        row.setVersion(0L);
+        row.setNextReconcileAt(now);
+        row.setReconcileDeadline(reconcileDeadline);
+        row.setReconcileCount(0);
+        row.setStatusChangedAt(now);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        paymentMapper.insert(row);
+        return row;
+    }
+
+    @Override
+    public PosOrderLinePayment getActiveByDdId(String ddId) {
+        return StrUtil.isBlank(ddId) ? null : paymentMapper.selectActiveByDdId(ddId);
+    }
+
+    @Override
+    public PosOrderLinePayment getByLineOrderId(String lineOrderId) {
+        return StrUtil.isBlank(lineOrderId) ? null : paymentMapper.selectByLineOrderId(lineOrderId);
+    }
+
+    @Override
+    public PosOrderLinePayment getByTransactionId(String transactionId) {
+        return StrUtil.isBlank(transactionId) ? null : paymentMapper.selectByTransactionId(transactionId);
+    }
+
+    @Override
+    public PosOrderLinePayment getById(Long id) {
+        return id == null ? null : paymentMapper.selectPaymentById(id);
+    }
+
+    @Override
+    public List<PosOrderLinePayment> getByDdId(String ddId) {
+        return StrUtil.isBlank(ddId) ? Collections.emptyList() : paymentMapper.selectByDdId(ddId);
+    }
+
+    @Override
+    public List<PosOrderLinePayment> getCancelledPaidWithoutRefund(int batchSize) {
+        return paymentMapper.selectCancelledPaidWithoutRefund(Math.max(1, Math.min(batchSize, 20)));
+    }
+
+    @Override
+    public int markTerminal(Long id, Long version, String expectedStatus, String targetStatus) {
+        if (id == null || version == null || !TERMINAL_RELEASE_STATES.contains(targetStatus)) {
+            return 0;
+        }
+        return paymentMapper.markTerminal(id, version, expectedStatus, targetStatus, new Date());
+    }
+
+    @Override
+    public int markRequestSucceeded(Long id, Long version, String transactionId,
+                                    String paymentUrlWeb, String paymentUrlApp,
+                                    Date nextReconcileAt, Date reconcileDeadline) {
+        if (id == null || version == null || StrUtil.isBlank(transactionId)
+                || StrUtil.isBlank(paymentUrlWeb)) {
+            return 0;
+        }
+        return paymentMapper.markRequestSucceeded(id, version, transactionId,
+                paymentUrlWeb, paymentUrlApp, nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markRequestUnknown(Long id, Long version, Date nextReconcileAt,
+                                  Date reconcileDeadline) {
+        if (id == null || version == null) {
+            return 0;
+        }
+        return paymentMapper.markRequestUnknown(id, version, nextReconcileAt,
+                reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int claimConfirm(Long id, Long version, String expectedStatus,
+                            String leaseOwner, Date leaseUntil) {
+        if (id == null || version == null || StrUtil.isBlank(expectedStatus)
+                || StrUtil.isBlank(leaseOwner) || leaseUntil == null) {
+            return 0;
+        }
+        return paymentMapper.claimConfirm(id, version, expectedStatus, leaseOwner, leaseUntil, new Date());
+    }
+
+    @Override
+    public int markAuthDoneOrderCancelled(Long id, Long version, String expectedStatus,
+                                          Date nextReconcileAt, Date reconcileDeadline) {
+        if (id == null || version == null) {
+            return 0;
+        }
+        return paymentMapper.markAuthDoneOrderCancelled(id, version, expectedStatus,
+                nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markConfirmUnknown(Long id, Long version, Date nextReconcileAt,
+                                  Date reconcileDeadline) {
+        return id == null || version == null ? 0 : paymentMapper.markConfirmUnknown(
+                id, version, nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markPaid(Long id, Long version, String expectedStatus,
+                        String transactionId, String paymentProvider, Date payTime) {
+        return id == null || version == null || StrUtil.isBlank(expectedStatus)
+                || StrUtil.isBlank(transactionId) ? 0
+                : paymentMapper.markPaid(id, version, expectedStatus, transactionId, paymentProvider,
+                payTime == null ? new Date() : payTime, new Date());
+    }
+
+    @Override
+    public int markManualReview(Long id, Long version, String expectedStatus) {
+        return id == null || version == null || StrUtil.isBlank(expectedStatus) ? 0
+                : paymentMapper.markManualReview(id, version, expectedStatus, new Date());
+    }
+
+    @Override
+    public List<PosOrderLinePayment> scanDue(Date now, int batchSize) {
+        return paymentMapper.scanDue(now == null ? new Date() : now,
+                Math.max(1, Math.min(batchSize, 20)));
+    }
+
+    @Override
+    public int claimReconcileLease(Long id, Long version, String leaseOwner,
+                                   Date leaseUntil, Date now) {
+        if (id == null || version == null || StrUtil.isBlank(leaseOwner) || leaseUntil == null) {
+            return 0;
+        }
+        return paymentMapper.claimReconcileLease(id, version, leaseOwner,
+                leaseUntil, now == null ? new Date() : now);
+    }
+
+    @Override
+    public void applyActiveKeyInvariant(PosOrderLinePayment payment) {
+        if (payment != null && TERMINAL_RELEASE_STATES.contains(payment.getStatus())) {
+            payment.setActiveDdId(null);
+        }
+    }
+}

+ 137 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImpl.java

@@ -0,0 +1,137 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.mapper.PosOrderLineRefundMapper;
+import com.ruoyi.system.service.IPosOrderLineRefundService;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class PosOrderLineRefundServiceImpl implements IPosOrderLineRefundService {
+
+    private final PosOrderLineRefundMapper refundMapper;
+
+    public PosOrderLineRefundServiceImpl(PosOrderLineRefundMapper refundMapper) {
+        this.refundMapper = refundMapper;
+    }
+
+    @Override
+    public PosOrderLineRefund getByPaymentId(Long paymentId) {
+        return paymentId == null ? null : refundMapper.selectByPaymentId(paymentId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public PosOrderLineRefund createIfAbsent(PosOrderLinePayment payment, String source) {
+        if (payment == null || payment.getId() == null || !"PAID".equals(payment.getStatus())
+                || payment.getTransactionId() == null) {
+            throw new ServiceException("Only a captured LINE Pay payment can be refunded");
+        }
+        PosOrderLineRefund existing = refundMapper.selectByPaymentId(payment.getId());
+        if (existing != null) {
+            return existing;
+        }
+        Date now = new Date();
+        PosOrderLineRefund row = new PosOrderLineRefund();
+        row.setPaymentId(payment.getId());
+        row.setDdId(payment.getDdId());
+        row.setCredentialId(payment.getCredentialId());
+        row.setTransactionId(payment.getTransactionId());
+        row.setAmount(payment.getAmount());
+        row.setStatus("CREATED");
+        row.setSource(source);
+        row.setVersion(0L);
+        row.setReconcileCount(0);
+        row.setNextReconcileAt(now);
+        // The application service starts the deadline only after an uncertain gateway outcome.
+        row.setReconcileDeadline(null);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        try {
+            refundMapper.insert(row);
+            return row;
+        } catch (DuplicateKeyException duplicate) {
+            PosOrderLineRefund concurrent = refundMapper.selectByPaymentId(payment.getId());
+            if (concurrent != null) {
+                return concurrent;
+            }
+            throw duplicate;
+        }
+    }
+
+    @Override
+    public int claimProcessing(Long id, Long version, String expectedStatus,
+                               String leaseOwner, Date leaseUntil) {
+        return refundMapper.claimProcessing(id, version, expectedStatus,
+                leaseOwner, leaseUntil, new Date());
+    }
+
+    @Override
+    public int markUnknown(Long id, Long version, Date nextReconcileAt, Date reconcileDeadline) {
+        return refundMapper.markUnknown(id, version, nextReconcileAt,
+                reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markRetryWait(Long id, Long version, Date nextReconcileAt) {
+        return id == null || version == null ? 0
+                : refundMapper.markRetryWait(id, version, nextReconcileAt, new Date());
+    }
+
+    @Override
+    public int markFailed(Long id, Long version) {
+        return id == null || version == null ? 0 : refundMapper.markFailed(id, version, new Date());
+    }
+
+    @Override
+    public int recoverExpiredProcessing(Long id, Long version, Date nextReconcileAt,
+                                        Date reconcileDeadline) {
+        return id == null || version == null ? 0 : refundMapper.recoverExpiredProcessing(
+                id, version, nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int rescheduleUnknown(Long id, Long version, Date nextReconcileAt,
+                                 Date reconcileDeadline) {
+        return id == null || version == null ? 0 : refundMapper.rescheduleUnknown(
+                id, version, nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markRefunded(Long id, Long version, String refundTransactionId, Date refundTime) {
+        return refundMapper.markRefunded(id, version, refundTransactionId,
+                refundTime, new Date());
+    }
+
+    @Override
+    public int markRefundedFromEvidence(Long id, Long version, String expectedStatus,
+                                        String refundTransactionId, Date refundTime) {
+        return id == null || version == null ? 0
+                : refundMapper.markRefundedFromEvidence(id, version, expectedStatus,
+                refundTransactionId, refundTime, new Date());
+    }
+
+    @Override
+    public List<PosOrderLineRefund> scanDue(Date now, int batchSize) {
+        return refundMapper.scanDue(now == null ? new Date() : now,
+                Math.max(1, Math.min(batchSize, 20)));
+    }
+
+    @Override
+    public int markManualReview(Long id, Long version, String expectedStatus) {
+        return refundMapper.markManualReview(id, version, expectedStatus, new Date());
+    }
+
+    @Override
+    public int markManualReviewFromEvidence(Long id, Long version, String expectedStatus) {
+        return id == null || version == null ? 0
+                : refundMapper.markManualReviewFromEvidence(
+                id, version, expectedStatus, new Date());
+    }
+}

+ 158 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreLinePayServiceImpl.java

@@ -0,0 +1,158 @@
+package com.ruoyi.system.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreLinePayVo;
+import com.ruoyi.system.mapper.PosStoreLinePayMapper;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class PosStoreLinePayServiceImpl implements IPosStoreLinePayService {
+
+    private static final String VERIFIED = "VERIFIED";
+    private final PosStoreLinePayMapper credentialMapper;
+
+    public PosStoreLinePayServiceImpl(PosStoreLinePayMapper credentialMapper) {
+        this.credentialMapper = credentialMapper;
+    }
+
+    @Override
+    public PosStoreLinePay getCurrent(Long storeId) {
+        return storeId == null ? null : credentialMapper.selectCurrentByStoreId(storeId);
+    }
+
+    @Override
+    public PosStoreLinePay getEnabledCurrent(Long storeId) {
+        PosStoreLinePay current = getCurrent(storeId);
+        if (current == null || !VERIFIED.equals(current.getCredentialStatus())
+                || !Integer.valueOf(1).equals(current.getIsEnabled())) {
+            return null;
+        }
+        return current;
+    }
+
+    @Override
+    public PosStoreLinePay getById(Long credentialId) {
+        return credentialId == null ? null : credentialMapper.selectById(credentialId);
+    }
+
+    @Override
+    public List<PosStoreLinePayVo> selectStoreList(PosStoreLinePayVo query) {
+        return credentialMapper.selectLinePayStoreList(query == null ? new PosStoreLinePayVo() : query);
+    }
+
+    @Override
+    public PosStoreLinePayVo selectStoreDetail(Long storeId) {
+        if (storeId == null) {
+            throw new ServiceException("LINE Pay store is required");
+        }
+        PosStoreLinePayVo detail = credentialMapper.selectLinePayStoreDetail(storeId);
+        if (detail == null) {
+            throw new ServiceException("LINE Pay store not found");
+        }
+        return detail;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public PosStoreLinePay saveVerifiedCredential(StoreLinePayCredentialDto dto, String environment,
+                                                  String returnCode, String returnMessage) {
+        validate(dto, environment);
+        credentialMapper.lockStore(dto.getStoreId());
+        PosStoreLinePay current = credentialMapper.selectCurrentByStoreId(dto.getStoreId());
+        if (sameCredential(current, dto, environment)) {
+            if (!Integer.valueOf(1).equals(current.getIsEnabled())) {
+                setCurrentEnabled(dto.getStoreId(), true);
+                current.setIsEnabled(1);
+            }
+            return current;
+        }
+
+        Date now = new Date();
+        if (current != null) {
+            int cleared = credentialMapper.clearCurrent(current.getId(), current.getStoreId(), now);
+            if (cleared != 1) {
+                throw new ServiceException("LINE Pay credential changed concurrently");
+            }
+        }
+
+        Integer maxVersion = credentialMapper.selectMaxVersionByStoreId(dto.getStoreId());
+        PosStoreLinePay row = new PosStoreLinePay();
+        row.setStoreId(dto.getStoreId());
+        row.setCredentialVersion(maxVersion == null ? 1 : maxVersion + 1);
+        row.setChannelId(dto.getChannelId().trim());
+        row.setChannelSecret(dto.getChannelSecret());
+        row.setEnvironment(environment.trim().toUpperCase());
+        row.setCredentialStatus(VERIFIED);
+        row.setIsEnabled(1);
+        row.setCurrentStoreId(dto.getStoreId());
+        row.setVerifyReturnCode(truncate(returnCode, 8));
+        row.setVerifyReturnMessage(truncate(returnMessage, 255));
+        row.setVerifiedTime(now);
+        row.setCreateTime(now);
+        row.setUpdateTime(now);
+        row.setCreateBy(currentUser());
+        row.setUpdateBy(row.getCreateBy());
+        credentialMapper.insert(row);
+        return row;
+    }
+
+    @Override
+    public int setCurrentEnabled(Long storeId, boolean enabled) {
+        PosStoreLinePay current = getCurrent(storeId);
+        if (current == null || !VERIFIED.equals(current.getCredentialStatus())) {
+            throw new ServiceException("LINE Pay credential not found");
+        }
+        Date now = new Date();
+        return credentialMapper.update(null, Wrappers.<PosStoreLinePay>lambdaUpdate()
+                .eq(PosStoreLinePay::getId, current.getId())
+                .eq(PosStoreLinePay::getCurrentStoreId, storeId)
+                .set(PosStoreLinePay::getIsEnabled, enabled ? 1 : 0)
+                .set(PosStoreLinePay::getUpdateTime, now)
+                .set(PosStoreLinePay::getUpdateBy, currentUser()));
+    }
+
+    private static void validate(StoreLinePayCredentialDto dto, String environment) {
+        if (dto == null || dto.getStoreId() == null) {
+            throw new ServiceException("LINE Pay store is required");
+        }
+        if (StrUtil.isBlank(dto.getChannelId()) || StrUtil.isBlank(dto.getChannelSecret())) {
+            throw new ServiceException("LINE Pay credential is required");
+        }
+        if (StrUtil.isBlank(environment)) {
+            throw new ServiceException("LINE Pay environment is required");
+        }
+    }
+
+    private static boolean sameCredential(PosStoreLinePay current, StoreLinePayCredentialDto dto,
+                                          String environment) {
+        return current != null
+                && current.getChannelId().equals(dto.getChannelId().trim())
+                && current.getChannelSecret().equals(dto.getChannelSecret())
+                && current.getEnvironment().equalsIgnoreCase(environment.trim());
+    }
+
+    private static String truncate(String value, int maxLength) {
+        if (value == null) {
+            return null;
+        }
+        return value.length() <= maxLength ? value : value.substring(0, maxLength);
+    }
+
+    private static String currentUser() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception ignored) {
+            return "system";
+        }
+    }
+}

+ 150 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml

@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderLinePaymentMapper">
+    <sql id="paymentColumns">
+        id, dd_id AS ddId, line_order_id AS lineOrderId, transaction_id AS transactionId,
+        credential_id AS credentialId, store_id AS storeId, amount, currency,
+        payment_url_web AS paymentUrlWeb, payment_url_app AS paymentUrlApp,
+        payment_provider AS paymentProvider, status, active_dd_id AS activeDdId, version,
+        next_reconcile_at AS nextReconcileAt, reconcile_deadline AS reconcileDeadline,
+        reconcile_count AS reconcileCount, lease_owner AS leaseOwner, lease_until AS leaseUntil,
+        status_changed_at AS statusChangedAt, auth_completed_time AS authCompletedTime,
+        pay_time AS payTime, create_time AS createTime, update_time AS updateTime
+    </sql>
+
+    <select id="selectActiveByDdId" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE active_dd_id = #{ddId} LIMIT 1
+    </select>
+    <select id="selectByLineOrderId" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE line_order_id = #{lineOrderId} LIMIT 1
+    </select>
+    <select id="selectByTransactionId" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE transaction_id = #{transactionId} LIMIT 1
+    </select>
+    <select id="selectPaymentById" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE id = #{id} LIMIT 1
+    </select>
+    <select id="selectByDdId" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE dd_id = #{ddId} ORDER BY create_time DESC, id DESC
+    </select>
+    <select id="selectCancelledPaidWithoutRefund" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT p.id, p.dd_id AS ddId, p.line_order_id AS lineOrderId,
+               p.transaction_id AS transactionId, p.credential_id AS credentialId,
+               p.store_id AS storeId, p.amount, p.currency,
+               p.payment_url_web AS paymentUrlWeb, p.payment_url_app AS paymentUrlApp,
+               p.payment_provider AS paymentProvider, p.status,
+               p.active_dd_id AS activeDdId, p.version,
+               p.next_reconcile_at AS nextReconcileAt,
+               p.reconcile_deadline AS reconcileDeadline,
+               p.reconcile_count AS reconcileCount, p.lease_owner AS leaseOwner,
+               p.lease_until AS leaseUntil, p.status_changed_at AS statusChangedAt,
+               p.auth_completed_time AS authCompletedTime, p.pay_time AS payTime,
+               p.create_time AS createTime, p.update_time AS updateTime
+        FROM pos_order_line_payment p
+        JOIN pos_order o ON o.dd_id = p.dd_id
+        WHERE p.status = 'PAID' AND o.state = 4 AND o.pay_status = 1
+          AND NOT EXISTS (SELECT 1 FROM pos_order_line_refund r WHERE r.payment_id = p.id)
+        ORDER BY p.id ASC
+        LIMIT #{batchSize}
+    </select>
+
+    <select id="scanDue" resultType="com.ruoyi.system.domain.PosOrderLinePayment">
+        SELECT <include refid="paymentColumns"/> FROM pos_order_line_payment
+        WHERE active_dd_id IS NOT NULL
+          AND status IN ('REQUESTING','REQUEST_UNKNOWN','WAITING_AUTH','READY_CONFIRM',
+                         'AUTH_DONE_ORDER_CANCELLED','CONFIRMING','CONFIRM_UNKNOWN')
+          AND next_reconcile_at IS NOT NULL AND next_reconcile_at &lt;= #{now}
+          AND (lease_until IS NULL OR lease_until &lt; #{now})
+        ORDER BY next_reconcile_at ASC, id ASC
+        LIMIT #{batchSize}
+    </select>
+
+    <update id="markTerminal">
+        UPDATE pos_order_line_payment
+        SET status = #{targetStatus}, active_dd_id = NULL,
+            status_changed_at = #{updateTime}, update_time = #{updateTime},
+            lease_owner = NULL, lease_until = NULL, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND #{targetStatus} IN ('CANCELLED_OR_EXPIRED', 'FAILED')
+    </update>
+
+    <update id="markRequestSucceeded">
+        UPDATE pos_order_line_payment
+        SET transaction_id = #{transactionId}, payment_url_web = #{paymentUrlWeb},
+            payment_url_app = #{paymentUrlApp}, status = 'WAITING_AUTH',
+            next_reconcile_at = #{nextReconcileAt}, reconcile_deadline = #{reconcileDeadline},
+            status_changed_at = #{updateTime}, update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = 'REQUESTING'
+          AND active_dd_id IS NOT NULL
+    </update>
+
+    <update id="markRequestUnknown">
+        UPDATE pos_order_line_payment
+        SET status = 'REQUEST_UNKNOWN', next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = #{reconcileDeadline}, status_changed_at = #{updateTime},
+            update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = 'REQUESTING'
+          AND active_dd_id IS NOT NULL
+    </update>
+
+    <update id="claimConfirm">
+        UPDATE pos_order_line_payment
+        SET status = 'CONFIRMING', lease_owner = #{leaseOwner}, lease_until = #{leaseUntil},
+            auth_completed_time = COALESCE(auth_completed_time, #{updateTime}),
+            status_changed_at = #{updateTime}, update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND active_dd_id IS NOT NULL
+          AND (lease_until IS NULL OR lease_until &lt; #{updateTime} OR lease_owner = #{leaseOwner})
+    </update>
+
+    <update id="markAuthDoneOrderCancelled">
+        UPDATE pos_order_line_payment
+        SET status = 'AUTH_DONE_ORDER_CANCELLED', next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = #{reconcileDeadline}, status_changed_at = #{updateTime},
+            update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+    </update>
+
+    <update id="markConfirmUnknown">
+        UPDATE pos_order_line_payment
+        SET status = 'CONFIRM_UNKNOWN', next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = #{reconcileDeadline}, lease_owner = NULL, lease_until = NULL,
+            status_changed_at = #{updateTime}, update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id} AND version = #{version} AND status = 'CONFIRMING'
+    </update>
+
+    <update id="markPaid">
+        UPDATE pos_order_line_payment
+        SET status = 'PAID', transaction_id = #{transactionId},
+            payment_provider = #{paymentProvider}, pay_time = #{payTime},
+            next_reconcile_at = NULL, reconcile_deadline = NULL,
+            lease_owner = NULL, lease_until = NULL, status_changed_at = #{updateTime},
+            update_time = #{updateTime}, version = version + 1
+        WHERE id = #{id}
+          AND version = #{version} AND status = #{expectedStatus}
+          AND (transaction_id IS NULL OR transaction_id = #{transactionId})
+    </update>
+
+    <update id="markManualReview">
+        UPDATE pos_order_line_payment
+        SET status = 'MANUAL_REVIEW', version = version + 1,
+            next_reconcile_at = NULL, lease_owner = NULL, lease_until = NULL,
+            status_changed_at = #{updateTime}, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+    </update>
+
+    <update id="claimReconcileLease">
+        UPDATE pos_order_line_payment
+        SET lease_owner = #{leaseOwner}, lease_until = #{leaseUntil},
+            next_reconcile_at = #{leaseUntil}, reconcile_count = reconcile_count + 1,
+            version = version + 1, update_time = #{now}
+        WHERE id = #{id} AND version = #{version} AND active_dd_id IS NOT NULL
+          AND next_reconcile_at IS NOT NULL AND next_reconcile_at &lt;= #{now}
+          AND (lease_until IS NULL OR lease_until &lt; #{now})
+    </update>
+</mapper>

+ 118 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderLineRefundMapper.xml

@@ -0,0 +1,118 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderLineRefundMapper">
+    <select id="selectByPaymentId" resultType="com.ruoyi.system.domain.PosOrderLineRefund">
+        SELECT id, payment_id AS paymentId, dd_id AS ddId, credential_id AS credentialId,
+               transaction_id AS transactionId, refund_transaction_id AS refundTransactionId,
+               amount, status, source, version, next_reconcile_at AS nextReconcileAt,
+               reconcile_deadline AS reconcileDeadline, reconcile_count AS reconcileCount,
+               lease_owner AS leaseOwner, lease_until AS leaseUntil,
+               refund_time AS refundTime, create_time AS createTime, update_time AS updateTime
+        FROM pos_order_line_refund WHERE payment_id = #{paymentId} LIMIT 1
+    </select>
+
+    <select id="scanDue" resultType="com.ruoyi.system.domain.PosOrderLineRefund">
+        SELECT id, payment_id AS paymentId, dd_id AS ddId, credential_id AS credentialId,
+               transaction_id AS transactionId, refund_transaction_id AS refundTransactionId,
+               amount, status, source, version, next_reconcile_at AS nextReconcileAt,
+               reconcile_deadline AS reconcileDeadline, reconcile_count AS reconcileCount,
+               lease_owner AS leaseOwner, lease_until AS leaseUntil,
+               refund_time AS refundTime, create_time AS createTime, update_time AS updateTime
+        FROM pos_order_line_refund
+        WHERE status IN ('CREATED','UNKNOWN','RETRY_WAIT','PROCESSING')
+          AND next_reconcile_at IS NOT NULL AND next_reconcile_at &lt;= #{now}
+          AND (lease_until IS NULL OR lease_until &lt; #{now})
+        ORDER BY next_reconcile_at ASC, id ASC
+        LIMIT #{batchSize}
+    </select>
+
+    <update id="claimProcessing">
+        UPDATE pos_order_line_refund
+        SET status = 'PROCESSING', version = version + 1,
+            lease_owner = #{leaseOwner}, lease_until = #{leaseUntil},
+            reconcile_count = reconcile_count + 1, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND (lease_until IS NULL OR lease_until &lt; #{updateTime})
+    </update>
+
+    <update id="recoverExpiredProcessing">
+        UPDATE pos_order_line_refund
+        SET status = 'UNKNOWN', version = version + 1,
+            next_reconcile_at = #{nextReconcileAt}, lease_owner = NULL, lease_until = NULL,
+            reconcile_deadline = COALESCE(reconcile_deadline, #{reconcileDeadline}),
+            update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = 'PROCESSING'
+          AND lease_until IS NOT NULL AND lease_until &lt; #{updateTime}
+    </update>
+
+    <update id="rescheduleUnknown">
+        UPDATE pos_order_line_refund
+        SET next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = COALESCE(reconcile_deadline, #{reconcileDeadline}),
+            reconcile_count = reconcile_count + 1, version = version + 1,
+            lease_owner = NULL, lease_until = NULL, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = 'UNKNOWN'
+    </update>
+
+    <update id="markUnknown">
+        UPDATE pos_order_line_refund
+        SET status = 'UNKNOWN', version = version + 1,
+            next_reconcile_at = #{nextReconcileAt}, reconcile_deadline = #{reconcileDeadline},
+            lease_owner = NULL, lease_until = NULL, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = 'PROCESSING'
+    </update>
+
+    <update id="markRetryWait">
+        UPDATE pos_order_line_refund
+        SET status = 'RETRY_WAIT', version = version + 1,
+            next_reconcile_at = #{nextReconcileAt}, lease_owner = NULL, lease_until = NULL,
+            update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = 'PROCESSING'
+    </update>
+
+    <update id="markFailed">
+        UPDATE pos_order_line_refund
+        SET status = 'FAILED', version = version + 1,
+            next_reconcile_at = NULL, lease_owner = NULL, lease_until = NULL,
+            update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = 'PROCESSING'
+    </update>
+
+    <update id="markRefunded">
+        UPDATE pos_order_line_refund
+        SET status = 'REFUNDED', refund_transaction_id = #{refundTransactionId},
+            refund_time = #{refundTime}, version = version + 1,
+            next_reconcile_at = NULL, reconcile_deadline = NULL,
+            lease_owner = NULL, lease_until = NULL, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version}
+          AND status IN ('CREATED', 'PROCESSING', 'UNKNOWN', 'RETRY_WAIT')
+    </update>
+
+    <update id="markManualReview">
+        UPDATE pos_order_line_refund
+        SET status = 'MANUAL_REVIEW', version = version + 1,
+            next_reconcile_at = NULL, lease_owner = NULL, lease_until = NULL,
+            update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND status IN ('CREATED', 'PROCESSING', 'UNKNOWN', 'RETRY_WAIT')
+    </update>
+
+    <update id="markRefundedFromEvidence">
+        UPDATE pos_order_line_refund
+        SET status = 'REFUNDED', refund_transaction_id = #{refundTransactionId},
+            refund_time = #{refundTime}, version = version + 1,
+            next_reconcile_at = NULL, reconcile_deadline = NULL,
+            lease_owner = NULL, lease_until = NULL, update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND status IN ('CREATED', 'PROCESSING', 'UNKNOWN', 'RETRY_WAIT', 'FAILED')
+    </update>
+
+    <update id="markManualReviewFromEvidence">
+        UPDATE pos_order_line_refund
+        SET status = 'MANUAL_REVIEW', version = version + 1,
+            next_reconcile_at = NULL, lease_owner = NULL, lease_until = NULL,
+            update_time = #{updateTime}
+        WHERE id = #{id} AND version = #{version} AND status = #{expectedStatus}
+          AND status IN ('CREATED', 'PROCESSING', 'UNKNOWN', 'RETRY_WAIT', 'FAILED')
+    </update>
+</mapper>

+ 84 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreLinePayMapper.xml

@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosStoreLinePayMapper">
+
+    <sql id="credentialColumns">
+        id, store_id AS storeId, credential_version AS credentialVersion,
+        channel_id AS channelId, channel_secret AS channelSecret, environment,
+        credential_status AS credentialStatus, is_enabled AS isEnabled,
+        current_store_id AS currentStoreId, verify_return_code AS verifyReturnCode,
+        verify_return_message AS verifyReturnMessage, verified_time AS verifiedTime,
+        create_time AS createTime, update_time AS updateTime,
+        create_by AS createBy, update_by AS updateBy
+    </sql>
+
+    <select id="lockStore" resultType="java.lang.Long">
+        SELECT id FROM pos_store WHERE id = #{storeId} FOR UPDATE
+    </select>
+
+    <select id="selectCurrentByStoreId" resultType="com.ruoyi.system.domain.PosStoreLinePay">
+        SELECT <include refid="credentialColumns"/>
+        FROM pos_store_line_pay
+        WHERE current_store_id = #{storeId}
+        LIMIT 1
+    </select>
+
+    <select id="selectMaxVersionByStoreId" resultType="java.lang.Integer">
+        SELECT MAX(credential_version)
+        FROM pos_store_line_pay
+        WHERE store_id = #{storeId}
+    </select>
+
+    <update id="clearCurrent">
+        UPDATE pos_store_line_pay
+        SET current_store_id = NULL, is_enabled = 0,
+            update_time = #{updateTime}
+        WHERE id = #{id} AND current_store_id = #{storeId}
+    </update>
+
+    <select id="selectLinePayStoreList" parameterType="com.ruoyi.system.domain.vo.PosStoreLinePayVo"
+            resultType="com.ruoyi.system.domain.vo.PosStoreLinePayVo">
+        SELECT s.id AS storeId, s.pos_name AS posName, s.user_id AS userId,
+               u.user_name AS userName, s.is_stall AS isStall,
+               s.is_night_market AS isNightMarket, s.night_market_id AS nightMarketId,
+               c.id AS credentialId, c.credential_version AS credentialVersion,
+               c.channel_id AS channelId, c.environment,
+               c.credential_status AS credentialStatus, IFNULL(c.is_enabled, 0) AS isEnabled,
+               CASE WHEN c.id IS NULL THEN 0 ELSE 1 END AS hasCredential,
+               c.verify_return_code AS verifyReturnCode,
+               c.verify_return_message AS verifyReturnMessage,
+               c.verified_time AS verifiedTime
+        FROM pos_store s
+        LEFT JOIN pos_store_line_pay c ON c.current_store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        <where>
+            s.del_flag = '0'
+            <if test="posNameLike != null and posNameLike != ''">
+                AND s.pos_name LIKE CONCAT('%', #{posNameLike}, '%')
+            </if>
+            <if test="isStall != null">AND s.is_stall = #{isStall}</if>
+            <if test="credentialStatus != null and credentialStatus != ''">
+                AND c.credential_status = #{credentialStatus}
+            </if>
+            <if test="isEnabled != null">AND IFNULL(c.is_enabled, 0) = #{isEnabled}</if>
+        </where>
+        ORDER BY s.id DESC
+    </select>
+
+    <select id="selectLinePayStoreDetail" resultType="com.ruoyi.system.domain.vo.PosStoreLinePayVo">
+        SELECT s.id AS storeId, s.pos_name AS posName, s.user_id AS userId,
+               u.user_name AS userName, s.is_stall AS isStall,
+               s.is_night_market AS isNightMarket, s.night_market_id AS nightMarketId,
+               c.id AS credentialId, c.credential_version AS credentialVersion,
+               c.channel_id AS channelId, c.channel_secret AS channelSecret, c.environment,
+               c.credential_status AS credentialStatus, IFNULL(c.is_enabled, 0) AS isEnabled,
+               CASE WHEN c.id IS NULL THEN 0 ELSE 1 END AS hasCredential,
+               c.verify_return_code AS verifyReturnCode,
+               c.verify_return_message AS verifyReturnMessage,
+               c.verified_time AS verifiedTime
+        FROM pos_store s
+        LEFT JOIN pos_store_line_pay c ON c.current_store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        WHERE s.id = #{storeId} AND s.del_flag = '0'
+    </select>
+</mapper>

+ 23 - 0
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -371,4 +371,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             </if>
         </where>
     </select>
+    <update id="markLinePaid">
+        UPDATE pos_order SET pay_status = 1
+        WHERE dd_id = #{ddId} AND pay_type = '3' AND pay_status = 0
+          AND state IN (0, 1, 2, 4)
+          AND EXISTS (SELECT 1 FROM pos_order_line_payment p
+                      WHERE p.id = #{paymentId} AND p.dd_id = pos_order.dd_id
+                        AND p.status = 'PAID')
+    </update>
+
+    <update id="markLineRefunded">
+        UPDATE pos_order SET pay_status = 2
+        WHERE dd_id = #{ddId} AND pay_type = '3' AND pay_status = 1
+          AND EXISTS (SELECT 1 FROM pos_order_line_payment p
+                      JOIN pos_order_line_refund r ON r.payment_id = p.id
+                      WHERE p.id = #{paymentId} AND p.dd_id = pos_order.dd_id
+                        AND p.status = 'PAID' AND r.status = 'REFUNDED')
+          AND NOT EXISTS (SELECT 1 FROM pos_order_line_payment unpaid_refund
+                          WHERE unpaid_refund.dd_id = pos_order.dd_id
+                            AND unpaid_refund.status = 'PAID'
+                            AND NOT EXISTS (SELECT 1 FROM pos_order_line_refund completed_refund
+                                            WHERE completed_refund.payment_id = unpaid_refund.id
+                                              AND completed_refund.status = 'REFUNDED'))
+    </update>
 </mapper>

+ 81 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImplTest.java

@@ -0,0 +1,81 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.mapper.PosOrderLinePaymentMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PosOrderLinePaymentServiceImplTest {
+
+    @Mock
+    private PosOrderLinePaymentMapper paymentMapper;
+
+    @InjectMocks
+    private PosOrderLinePaymentServiceImpl paymentService;
+
+    @Test
+    void newRequestingAttemptOwnsTheOrderActiveKey() {
+        when(paymentMapper.insert(any(PosOrderLinePayment.class))).thenAnswer(invocation -> {
+            PosOrderLinePayment row = invocation.getArgument(0);
+            row.setId(70L);
+            return 1;
+        });
+
+        PosOrderLinePayment row = paymentService.createRequesting(
+                "DD-100", "LP-100-01", 5L, 9L, 280, "TWD", new Date(1_000_000L));
+
+        assertEquals(70L, row.getId());
+        assertEquals("REQUESTING", row.getStatus());
+        assertEquals("DD-100", row.getActiveDdId());
+        assertEquals(0L, row.getVersion());
+        assertEquals(0, row.getReconcileCount());
+    }
+
+    @Test
+    void explicitTerminalStateReleasesActiveKeyUsingCompareAndSet() {
+        when(paymentMapper.markTerminal(eq(70L), eq(4L), eq("WAITING_AUTH"),
+                eq("FAILED"), any())).thenReturn(1);
+
+        int updated = paymentService.markTerminal(70L, 4L, "WAITING_AUTH", "FAILED");
+
+        assertEquals(1, updated);
+        verify(paymentMapper).markTerminal(eq(70L), eq(4L), eq("WAITING_AUTH"),
+                eq("FAILED"), any());
+    }
+
+    @Test
+    void unknownStateNeverReleasesActiveKey() {
+        PosOrderLinePayment row = new PosOrderLinePayment();
+        row.setActiveDdId("DD-100");
+        row.setStatus("CONFIRM_UNKNOWN");
+
+        paymentService.applyActiveKeyInvariant(row);
+
+        assertEquals("DD-100", row.getActiveDdId());
+    }
+
+    @Test
+    void onlyExplicitFailedOrCancelledAttemptCanHaveNoActiveKey() {
+        PosOrderLinePayment row = new PosOrderLinePayment();
+        row.setActiveDdId("DD-100");
+        row.setStatus("CANCELLED_OR_EXPIRED");
+
+        paymentService.applyActiveKeyInvariant(row);
+
+        assertNull(row.getActiveDdId());
+    }
+}

+ 73 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImplTest.java

@@ -0,0 +1,73 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosOrderLineRefund;
+import com.ruoyi.system.mapper.PosOrderLineRefundMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PosOrderLineRefundServiceImplTest {
+
+    @Mock
+    private PosOrderLineRefundMapper refundMapper;
+
+    @InjectMocks
+    private PosOrderLineRefundServiceImpl refundService;
+
+    @Test
+    void createIfAbsentReusesExistingRefundIntentForPayment() {
+        PosOrderLineRefund existing = new PosOrderLineRefund();
+        existing.setId(88L);
+        existing.setPaymentId(70L);
+        existing.setStatus("UNKNOWN");
+        when(refundMapper.selectByPaymentId(70L)).thenReturn(existing);
+
+        PosOrderLineRefund result = refundService.createIfAbsent(payment(), "USER_CANCEL");
+
+        assertSame(existing, result);
+        verify(refundMapper, never()).insert(any(PosOrderLineRefund.class));
+    }
+
+    @Test
+    void newRefundIntentCopiesImmutablePaymentFacts() {
+        when(refundMapper.selectByPaymentId(70L)).thenReturn(null);
+        when(refundMapper.insert(any(PosOrderLineRefund.class))).thenAnswer(invocation -> {
+            PosOrderLineRefund row = invocation.getArgument(0);
+            row.setId(88L);
+            return 1;
+        });
+
+        PosOrderLineRefund result = refundService.createIfAbsent(payment(), "STORE_CANCEL");
+
+        assertEquals(88L, result.getId());
+        assertEquals(70L, result.getPaymentId());
+        assertEquals("TX-100", result.getTransactionId());
+        assertEquals("CREATED", result.getStatus());
+        assertEquals(280, result.getAmount());
+        assertEquals("STORE_CANCEL", result.getSource());
+        assertNull(result.getReconcileDeadline());
+    }
+
+    private static PosOrderLinePayment payment() {
+        PosOrderLinePayment payment = new PosOrderLinePayment();
+        payment.setId(70L);
+        payment.setDdId("DD-100");
+        payment.setCredentialId(5L);
+        payment.setTransactionId("TX-100");
+        payment.setAmount(280);
+        payment.setStatus("PAID");
+        return payment;
+    }
+}

+ 92 - 0
ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosStoreLinePayServiceImplTest.java

@@ -0,0 +1,92 @@
+package com.ruoyi.system.service.impl;
+
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.domain.dto.StoreLinePayCredentialDto;
+import com.ruoyi.system.mapper.PosStoreLinePayMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PosStoreLinePayServiceImplTest {
+
+    @Mock
+    private PosStoreLinePayMapper credentialMapper;
+
+    @InjectMocks
+    private PosStoreLinePayServiceImpl credentialService;
+
+    @Test
+    void savingSameCurrentCredentialIsIdempotent() {
+        StoreLinePayCredentialDto dto = credential(9L, "1234567890", "same-secret");
+        PosStoreLinePay current = current(41L, 9L, 3, "1234567890", "same-secret");
+        when(credentialMapper.lockStore(9L)).thenReturn(9L);
+        when(credentialMapper.selectCurrentByStoreId(9L)).thenReturn(current);
+
+        PosStoreLinePay saved = credentialService.saveVerifiedCredential(
+                dto, "SANDBOX", "1150", "No transaction history");
+
+        assertSame(current, saved);
+        verify(credentialMapper, never()).insert(any(PosStoreLinePay.class));
+        verify(credentialMapper, never()).clearCurrent(any(), any(), any());
+    }
+
+    @Test
+    void rotatingCredentialCreatesNewVersionWithoutOverwritingOldRow() {
+        StoreLinePayCredentialDto dto = credential(9L, "1234567890", "new-secret");
+        PosStoreLinePay current = current(41L, 9L, 3, "1234567890", "old-secret");
+        when(credentialMapper.lockStore(9L)).thenReturn(9L);
+        when(credentialMapper.selectCurrentByStoreId(9L)).thenReturn(current);
+        when(credentialMapper.selectMaxVersionByStoreId(9L)).thenReturn(3);
+        when(credentialMapper.clearCurrent(any(), any(), any())).thenReturn(1);
+        when(credentialMapper.insert(any(PosStoreLinePay.class))).thenAnswer(invocation -> {
+            PosStoreLinePay row = invocation.getArgument(0);
+            row.setId(42L);
+            return 1;
+        });
+
+        PosStoreLinePay saved = credentialService.saveVerifiedCredential(
+                dto, "SANDBOX", "1150", "No transaction history");
+
+        ArgumentCaptor<PosStoreLinePay> captor = ArgumentCaptor.forClass(PosStoreLinePay.class);
+        verify(credentialMapper).insert(captor.capture());
+        assertEquals(4, captor.getValue().getCredentialVersion());
+        assertEquals(9L, captor.getValue().getCurrentStoreId());
+        assertEquals("old-secret", current.getChannelSecret());
+        assertEquals(42L, saved.getId());
+    }
+
+    private static StoreLinePayCredentialDto credential(Long storeId, String channelId, String secret) {
+        StoreLinePayCredentialDto dto = new StoreLinePayCredentialDto();
+        dto.setStoreId(storeId);
+        dto.setChannelId(channelId);
+        dto.setChannelSecret(secret);
+        return dto;
+    }
+
+    private static PosStoreLinePay current(Long id, Long storeId, int version,
+                                           String channelId, String secret) {
+        PosStoreLinePay row = new PosStoreLinePay();
+        row.setId(id);
+        row.setStoreId(storeId);
+        row.setCredentialVersion(version);
+        row.setChannelId(channelId);
+        row.setChannelSecret(secret);
+        row.setEnvironment("SANDBOX");
+        row.setCredentialStatus("VERIFIED");
+        row.setIsEnabled(1);
+        row.setCurrentStoreId(storeId);
+        return row;
+    }
+}
+

+ 221 - 0
specs/019-line-pay/brainstorm.md

@@ -0,0 +1,221 @@
+# LINE Pay Sandbox 接入头脑风暴记录
+
+**日期**:2026-08-11  
+**状态**:暂停,待继续设计评审  
+**目标**:以 spec-kit 流程接入 LINE Pay Online API v4 Sandbox,并在设计批准后依次完成 `specify -> plan -> tasks -> implement`。
+
+> 本文只记录已确认决策和待评审设计,不代表实现已获完整批准。不得在文档、源码、配置文件或日志中写入真实 Channel Secret。
+
+## 1. 已确认的业务决策
+
+| 议题 | 决策 |
+|---|---|
+| 与现有 OMG 的关系 | 方案 A:LINE Pay 与 OMG 并存,不替换 OMG |
+| 支付方式编号 | 暂定 `payType=8`,最终在规格阶段核对现有枚举后固化 |
+| API 版本 | Online API v4,Request、Check、Confirm、Retrieve、Refund 全部统一使用 `/v4` |
+| 环境 | 首版接入 LINE Pay Sandbox |
+| 凭证模式 | 商家独立凭证,每个门店使用自己的 Channel ID / Channel Secret |
+| 凭证归属 | 与现有 OMG 一致,按 `pos_store` 一店一套;订单通过 `PosOrder.mdId` 定位凭证 |
+| 凭证维护者 | 平台管理员;商家端不录入、不查看 Channel Secret |
+| 付款确认 | `confirmUrlType=CLIENT` |
+| 请款方式 | Confirm 时自动请款,不实现授权/请款分离、Capture 或 Void |
+| 退款范围 | 首版只支持全额退款 |
+| 币种与金额 | 固定 `TWD`,整数金额;金额仅取服务端订单,Request/Confirm/Refund 必须一致 |
+| 实现范围 | `foodie_server` 后端 + `foodie-admin-vue` 平台管理前端;用户端只交付 API 契约 |
+| Sandbox 凭证 | 已具备;后续通过管理页面安全录入,不在本文记录 |
+| 公网后端地址 | `https://foodieapi.waimai-paotui.com` |
+| 用户端结果地址 | 暂留空占位,后续补充;不得硬编码虚假地址 |
+
+## 2. LINE Pay 回跳地址
+
+LINE Pay 配置使用以下两个后端 HTTPS 地址:
+
+```text
+confirmUrl: https://foodieapi.waimai-paotui.com/pay/line/confirm
+cancelUrl:  https://foodieapi.waimai-paotui.com/pay/line/cancel
+```
+
+它们不是用户端最终页面:
+
+- `confirmUrl` 只表示用户已完成 LINE Pay 认证,后端仍须调用 Confirm 或查询 API,不能直接把订单标为已支付。
+- `cancelUrl` 只结束本次支付尝试,不直接取消外卖订单。
+- 后端处理完成后应 302 跳至固定配置的用户端结果地址;该地址尚未确定。
+- 结果地址为空时,不进行开放重定向,也不接受请求参数提供跳转目标;实现应返回安全的中性提示页。
+
+项目已有 App Scheme `com.twanmsdyh.app`,曾提议以后使用统一入口:
+
+```text
+com.twanmsdyh.app://payment/result?status=<success|failed|cancelled>&orderId=<orderId>
+```
+
+该路径尚未由用户端确认,当前仅作为候选,不得直接视为有效契约。
+
+## 3. 官方文档核验结论
+
+### 3.1 版本与 Sandbox
+
+- 台湾新接入优先使用 Online API v4。v4 于 2025-11 增加 `paymentProvider`(`TSP` / `EPI`)。
+- 官方基础付款指南仍含 v3 示例,不能因此混用 v3;实现路径必须全部为 `/v4`。
+- Sandbox 主机:`https://sandbox-api-pay.line.me`。
+- Production 主机:`https://api-pay.line.me`,本阶段不启用。
+- Sandbox Online 支付只能验证 Web 收银台,必须使用 `info.paymentUrl.web`;不能以 Sandbox 验证 App payment URL。
+- Sandbox 的 `paymentProvider` 固定为 `TSP`,无法模拟 EPI;EPI 响应兼容必须列为生产前独立验收项。
+
+### 3.2 v4 核心 API
+
+```text
+POST /v4/payments/request
+GET  /v4/payments/requests/{transactionId}/check
+POST /v4/payments/{transactionId}/confirm
+GET  /v4/payments
+POST /v4/payments/{transactionId}/refund
+```
+
+建议最短 Read timeout:Request 10 秒、Check/Retrieve/Refund 20 秒、Confirm 40 秒。HTTP 200 不代表业务成功,必须判断 `returnCode`。
+
+### 3.3 HMAC 签名
+
+请求头:
+
+```text
+Content-Type: application/json
+X-LINE-ChannelId
+X-LINE-Authorization
+X-LINE-Authorization-Nonce
+```
+
+签名使用 HMAC-SHA256,key 为 Channel Secret,结果 Base64:
+
+```text
+GET:  channelSecret + apiPath + queryString + nonce
+POST: channelSecret + apiPath + exactRequestBody + nonce
+```
+
+关键约束:
+
+- POST JSON 必须只序列化一次;用于签名的字符串与实际发送内容必须完全一致。
+- GET query 的参数顺序、重复参数、编码和值必须与实际 URL 完全一致。
+- `apiPath` 包含准确的 `/v4` 路径和路径参数,不包含 scheme 或 host。
+- nonce 使用 UUID v4;同一次请求的签名和请求头必须使用同一值。
+- 必须为 POST 精确 JSON、GET query、空 body 和非 ASCII 内容编写签名契约测试。
+
+### 3.4 交易事实与幂等
+
+- `confirmUrl` 是无签名的浏览器 GET 回跳,不是可信支付成功通知。
+- LINE Pay 未提供可直接作为最终支付事实的签名 webhook。
+- 本地必须先校验 `orderId + transactionId + 门店 + 金额 + 币种`,再由服务器 Confirm/查询取证。
+- LINE Pay `orderId` 必须全局唯一;每次新的支付尝试使用独立 LINE orderId,不能直接复用外卖订单号。
+- `transactionId` 为 19 位数字,全链路按字符串存储和返回,避免 JavaScript 精度丢失。
+- 重复回跳、用户刷新、乱序回跳和并发 Confirm 只能有一个执行者,其余返回已有结果。
+- Confirm/Refund 超时、`1198` 或临时错误后先调用 Retrieve 对账,不盲目重复有副作用的请求。
+- `cancelUrl` 或 Check `0121` 不能覆盖已通过 Confirm/Retrieve 证实的支付终态。
+
+## 4. 对抗复核必须覆盖的风险
+
+实现与测试至少要回答以下问题:
+
+1. 所有 API 是否统一使用 v4,是否完整保存 v4 的 `paymentProvider`。
+2. 签名 JSON/Query 是否与实际发送内容逐字节一致。
+3. `orderId` 是否永久唯一,`transactionId` 是否始终按字符串处理。
+4. Request、Confirm、订单和退款金额是否全部来自同一服务端事实。
+5. 是否错误地把 HTTP 200、`confirmUrl` 到达或 `cancelUrl` 到达视为最终支付状态。
+6. Confirm/Refund 已被 LINE 执行但响应丢失时,是否能通过 Retrieve 恢复最终事实。
+7. Sandbox 无法测试 EPI 和 App payment URL 时,是否在上线清单中单独保留生产验收。
+8. 用户取消订单与迟到的支付成功发生竞态时,是否先记录真实支付,再自动全额退款或进入人工核对,而不是丢弃付款事实。
+9. 重复回跳、重复退款、定时对账和人工操作之间是否使用数据库条件更新/锁保证幂等。
+10. 日志、接口响应和平台页面是否始终隐藏 Channel Secret、HMAC 原文及敏感 token。
+
+## 5. 已比较的实现路线
+
+### 方案 1:独立 LINE Pay 模块(已批准)
+
+新建 LINE Pay 凭证、支付和退款流水。只在订单取消、退款和后台查询处增加很薄的支付方式分派,不重构 OMG。
+
+优点:边界清楚、对现有 OMG 风险较低,并能完整处理幂等、对账和审计。
+
+### 方案 2:统一支付框架(未采用)
+
+建立通用 `PaymentProvider` 接口,并同时迁移 OMG。长期结构整洁,但超出本次范围,会扩大已运行 OMG 的回归风险。
+
+### 方案 3:Controller 直接接入(拒绝)
+
+不建独立支付/退款账本,只把交易号写入订单。无法可靠处理回跳重复、网络超时、退款恢复和审计,不满足支付安全要求。
+
+## 6. 已批准的架构边界
+
+### 6.1 `ruoyi-system`
+
+负责纯数据库能力,不引入 LINE Pay HTTP 客户端或 `com.ruoyi.app.*`:
+
+- `pos_store_line_pay`:每个 `pos_store` 一套加密凭证。
+- `pos_order_line_payment`:每次 Request/Confirm 的独立支付流水。
+- `pos_order_line_refund`:每次全额退款操作的独立流水。
+- 相应 Entity、Mapper XML、Service。
+
+### 6.2 `ruoyi-admin`
+
+- `LinePayClient`:v4 HTTP、精确 JSON/Query 签名、超时及结果码解析。
+- `LinePayService`:创建、Confirm、查询、全额退款和状态机。
+- `LinePayController`:用户支付接口及匿名 CLIENT 回跳接口。
+- `PosStoreLinePayController`:平台管理员维护门店凭证和启用状态。
+- `LinePayReconcileTask`:恢复漏回跳、Confirm 未知和 Refund 未知状态。
+- 订单取消入口按 `payType` 分派至 OMG 或 LINE Pay;不引用废弃支付 Controller。
+
+预定用户支付接口:
+
+```text
+POST /pay/line/create
+GET  /pay/line/confirm
+GET  /pay/line/cancel
+POST /pay/line/query
+POST /pay/line/refund
+```
+
+- `create/query/refund` 使用明确 DTO;需要登录的接口通过 `@RequestHeader String token` 获取 token。
+- `confirm/cancel` 使用显式 `@RequestParam`,允许匿名 GET,并按重复、乱序请求设计。
+- Sandbox 的创建响应只向调用方提供 `paymentUrl.web`。
+
+### 6.3 `foodie-admin-vue`
+
+新增“LINE Pay 门店管理”,交互风格参考现有 OMG 页面,但数据和状态完全独立:
+
+- 平台管理员分页查看门店开通/启用状态。
+- 录入或轮换 Channel ID / Channel Secret。
+- Secret 只允许写入,不允许读取回显;详情仅返回 `hasSecret` 等脱敏状态。
+- 启停门店 LINE Pay。
+- 新增用户可见文本必须同步 `zh/tw/en/vi` 四个 i18n 文件。
+
+## 7. 安全设计方向(待详细评审)
+
+- Channel Secret 使用 AES-256-GCM 加密落库,保存随机 nonce、密文和版本;主密钥只从服务端环境变量读取。
+- Channel ID 可查询但默认脱敏展示;Channel Secret 永不回显。
+- 不把 Secret、签名原文、Authorization、paymentAccessToken 写入日志。
+- 凭证更新与启用分开;是否加入无扣款的在线探测仍需在详细设计中确定,不能依赖未被 LINE 官方保证的响应语义。
+- 外部回跳只能使用服务端固定配置的结果地址,禁止请求参数控制 302 目标。
+- 所有数据库迁移 SQL 只写入 `updatesql/sql.md`,不直接执行。
+
+## 8. 明天继续的设计评审顺序
+
+1. 数据表字段、索引、状态枚举和凭证加密/轮换。
+2. Request -> CLIENT 回跳 -> Confirm -> PAID 的事务边界与幂等。
+3. Confirm/Refund 超时、迟到成功、取消竞态和定时对账。
+4. Controller 契约、平台管理 API 与 `foodie-admin-vue` 页面。
+5. 四语 i18n、错误映射、日志脱敏与权限。
+6. Sandbox 自动化测试、真实凭证联调及生产前 EPI/App 验收。
+7. 全部设计获批后创建正式 `spec.md`,自审并等待批准,再生成 `plan.md`、`tasks.md`,最后按 TDD 实现。
+
+## 9. 官方资料
+
+- [LINE Pay 开发者中心](https://developers-pay.line.me/zh/)
+- [Sandbox](https://developers-pay.line.me/zh/sandbox)
+- [线上支付前置条件与签名](https://developers-pay.line.me/zh/online/prerequisites)
+- [Online API v4](https://developers-pay.line.me/zh/online-api-v4)
+- [v4 Request](https://developers-pay.line.me/zh/online-api-v4/request-payment)
+- [v4 Request 状态](https://developers-pay.line.me/zh/online-api-v4/check-payment-request-status)
+- [v4 Confirm](https://developers-pay.line.me/zh/online-api-v4/confirm-payment)
+- [v4 Retrieve](https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details)
+- [v4 Refund](https://developers-pay.line.me/zh/online-api-v4/refund)
+- [重定向页面](https://developers-pay.line.me/zh/online-api-v4/merchant/redirection-pages/)
+- [FAQ](https://developers-pay.line.me/zh/faq)
+- [API 变更日志](https://developers-pay.line.me/zh/api-change-log)
+

+ 6 - 4
specs/019-line-pay/checklists/requirements.md

@@ -25,6 +25,8 @@
 - [x] Confirm/Refund 未知结果禁止盲目重试
 - [x] 历史 `payType=3` 不会仅凭编号触发 LINE 资金操作
 - [x] 凭证轮换后旧交易仍能使用原凭证版本
+- [x] 支付尝试采用 `1:N` 永不覆盖模型,重复点击复用活跃行,明确终止后重新支付才新增行
+- [x] App、回跳、任务、退款和平台历史均有明确且稳定的查询定位规则
 - [x] 范围明确排除 OMG 表/日志迁移、部分退款、多门店父单和废弃 ZaloPay 逻辑
 - [x] 依赖、假设、Sandbox 限制和生产前真机验收已列出
 
@@ -38,13 +40,13 @@
 ## Feature Readiness
 
 - [x] 完整规格可进入用户一次性审阅
-- [ ] 用户已批准整份书面规格
-- [ ] 已生成 `plan.md`
-- [ ] 已生成 `tasks.md`
+- [x] 用户已批准整份书面规格(2026-08-12)
+- [x] 已生成 `plan.md`、`research.md`、`data-model.md`、`contracts/api.md` 和 `quickstart.md`
+- [x] 已生成 `tasks.md`
 - [ ] 实现和验证已完成
 
 ## Notes
 
 - `1150` 凭证探测不是 LINE 官方专用校验接口;自动启用是用户已批准的业务规则,真实支付能力仍需 Sandbox 端到端验证。
 - 回跳页面约 20 秒响应约束与 Confirm 至少 40 秒读取超时存在冲突,因此规格固定为快速中间页 + 异步 Confirm/App 查询。
-- 本次只写规格与检查表,未修改业务代码、前端或 SQL
+- 当前已完成 specification、plan 和 tasks,下一阶段按 TDD 实现业务代码、前端和 SQL 脚本

+ 227 - 0
specs/019-line-pay/contracts/api.md

@@ -0,0 +1,227 @@
+# LINE Pay HTTP Contracts
+
+所有 JSON 业务响应沿用项目 `AjaxResult` 外壳。交易号字段始终是 JSON string。业务错误文本从 `MessageUtils.message(...)` 获取。
+
+## 1. App API
+
+### `POST /pay/line/create`
+
+Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestBody LinePayOrderRequest`.
+
+Request:
+
+```json
+{
+  "ddId": "202608120001"
+}
+```
+
+Success `data`:
+
+```json
+{
+  "ddId": "202608120001",
+  "paymentId": 42,
+  "lineOrderId": "LP20260812...",
+  "transactionId": "2026081200000000001",
+  "paymentUrl": "https://sandbox-web-pay.line.me/...",
+  "status": "WAITING_AUTH",
+  "reusedAttempt": false
+}
+```
+
+Rules:
+
+- token 用户必须拥有 ddId 对应订单。
+- 只接受订单当前 `payType="3"`,单门店、未支付、合法非终态、门店当前 LINE 版本已验证并启用。
+- 同 ddId 有阻断性活跃尝试时返回该行,`reusedAttempt=true`,不再 Request。
+- 只有最新尝试已明确 `CANCELLED_OR_EXPIRED/FAILED` 才新增行。
+- 父单跨多门店时返回业务错误,不拆分多次支付。
+
+### `POST /pay/line/query`
+
+Annotations 与 create 相同。
+
+Request:
+
+```json
+{
+  "ddId": "202608120001"
+}
+```
+
+Success `data`:
+
+```json
+{
+  "ddId": "202608120001",
+  "paymentId": 42,
+  "orderPayStatus": 1,
+  "paymentStatus": "PAID",
+  "refundStatus": null,
+  "transactionId": "2026081200000000001",
+  "updatedAt": "2026-08-12T12:34:56+08:00"
+}
+```
+
+该接口只读本地状态,不在 App 请求线程中调用 LINE。
+
+## 2. LINE redirect endpoints
+
+### `GET /pay/line/confirm?orderId={lineOrderId}&transactionId={transactionId}`
+
+- `orderId` 是 LINE Request 的 `line_order_id`,不是 ddId。
+- 两个参数都用显式 `@RequestParam`;按 lineOrderId 定位并校验 transactionId。
+- 只登记回跳/触发可恢复处理,不同步等待 Confirm。
+- 返回 `text/html;charset=UTF-8` 和:
+  - `Cache-Control: no-store, no-cache, must-revalidate`
+  - `Pragma: no-cache`
+  - `Referrer-Policy: no-referrer`
+  - `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
+- 页面尝试 `com.twanmsdyh.app://payment/result?orderId=<urlEncodedDdId>`,并显示手动按钮和“正在确认支付,请回 App 查询”的安全提示。
+
+### `GET /pay/line/cancel`
+
+Optional explicit query parameters: `orderId`, `transactionId`.
+
+- 只记录浏览器事件并触发只读核对;不能把到达 cancelUrl 当成取消终态。
+- 返回与 confirm 相同安全页面/响应头。
+
+## 3. Platform credential API
+
+Base path: `/system/storeLinePay`
+
+### `GET /list`
+
+Permission: `chanting:storeLinePay:list`.
+
+Query uses explicit optional `@RequestParam`: `posName`, `isStall`, `credentialStatus`, `isEnabled`.
+
+Rows include store identity/current version/status/channelId/hasCredential/verifiedTime, never include `channelSecret`.
+
+### `GET /{storeId}`
+
+Permission: `chanting:storeLinePay:query`.
+
+Returns current credential detail. Per approved product decision this privileged response may contain plaintext `channelSecret`.
+
+### `PUT /saveCredentials`
+
+Permission: `chanting:storeLinePay:saveCredentials`.
+
+```json
+{
+  "storeId": 123,
+  "channelId": "2000000000",
+  "channelSecret": "plain-secret"
+}
+```
+
+Business validation is in Controller/Service without `@Valid`. Candidate is verified before an immutable version becomes current and enabled. Failed/unknown probe leaves existing current version untouched.
+
+### `PUT /toggleEnable`
+
+Permission: `chanting:storeLinePay:toggleEnable`.
+
+```json
+{
+  "storeId": 123,
+  "enabled": false
+}
+```
+
+Only changes whether the current verified version may serve new payments; old transactions remain queryable/refundable through their `credential_id`.
+
+## 4. Platform order API
+
+### `GET /system/order/{id}/status-context`
+
+Extend existing response with:
+
+```json
+{
+  "linePaymentId": 42,
+  "linePaymentStatus": "PAID",
+  "lineRefundStatus": null,
+  "lineTransactionId": "2026081200000000001"
+}
+```
+
+Only populate LINE fields when a matching `pos_order_line_payment` exists. Never infer LINE solely from `payType="3"`.
+
+### `POST /system/order/{id}/line-payment/reconcile`
+
+Permission: `system:order:linePaymentReconcile`. Uses the selected paymentId from stable server-side rules, takes a row lease, then performs Retrieve/Check recovery.
+
+### `POST /system/order/{id}/line-refund`
+
+Permission: `system:order:lineRefund`. Creates/uses the unique full-refund row for the selected `PAID` payment. UNKNOWN only retrieves; it never directly repeats Refund.
+
+## 5. LINE v4 outbound contracts
+
+### Request
+
+`POST /v4/payments/request`, 10 second timeout.
+
+```json
+{
+  "amount": 100,
+  "currency": "TWD",
+  "orderId": "LP20260812...",
+  "packages": [{
+    "id": "202608120001",
+    "amount": 100,
+    "products": [{"name": "Order 202608120001", "quantity": 1, "price": 100}]
+  }],
+  "redirectUrls": {
+    "confirmUrl": "https://api.example/pay/line/confirm",
+    "cancelUrl": "https://api.example/pay/line/cancel"
+  },
+  "options": {
+    "payment": {"capture": true}
+  }
+}
+```
+
+The total amount must equal package amount and product `price * quantity` sum.
+
+### Check
+
+`GET /v4/payments/requests/{transactionId}/check`, 20 second timeout.
+
+### Confirm
+
+`POST /v4/payments/{transactionId}/confirm`, 40 second read timeout or greater.
+
+```json
+{"amount": 100, "currency": "TWD"}
+```
+
+Confirm is sent only after local CAS claim and amount/currency/order/store/credential/active checks.
+
+### Retrieve
+
+`GET /v4/payments?orderId={encodedLineOrderId}` or `?transactionId={encodedTransactionId}`, 20 second timeout. The exact ordered query string is included in the HMAC input.
+
+### Refund
+
+`POST /v4/payments/{transactionId}/refund`, 20 second timeout, body `{}`. `refundAmount` is intentionally omitted for full refund; there is no currency request field.
+
+### Required headers
+
+```text
+X-LINE-ChannelId: <channel id>
+X-LINE-Authorization-Nonce: <unique nonce>
+X-LINE-Authorization: Base64(HMAC-SHA256(channelSecret, channelSecret + URI + bodyOrQuery + nonce))
+Content-Type: application/json
+```
+
+## 6. App deep-link contract
+
+The mobile application must register and handle:
+
+```text
+com.twanmsdyh.app://payment/result?orderId=<ddId>
+```
+
+On open, the App reads its normal login token and calls `/pay/line/query`. It must not trust Scheme parameters as payment success. Auto-open and fallback button require production-device acceptance testing; Sandbox web payment cannot prove LINE App handoff behavior.

+ 211 - 0
specs/019-line-pay/data-model.md

@@ -0,0 +1,211 @@
+# LINE Pay Data Model
+
+## Relationship Overview
+
+```text
+pos_store 1 ── N pos_store_line_pay (immutable credential versions)
+                       │
+                       └── current_store_id UNIQUE: one current version/store
+
+pos_order 1 ── N pos_order_line_payment (append-only attempts)
+                       │
+                       ├── credential_id -> exact credential version
+                       ├── active_dd_id UNIQUE: one blocking attempt/order
+                       └── 1 ── 0..1 pos_order_line_refund
+
+payment_gateway_log ── optional payment_id/refund_id/credential_id references
+```
+
+不声明物理外键,保持与现有业务表部署习惯一致;Service 在事务内保证逻辑引用存在。
+
+## 1. `pos_store_line_pay`
+
+不可变门店凭证版本。Secret 按用户决定以明文保存。普通列表仅返回 `hasCredential`,平台详情接口可返回当前版本 Secret。
+
+| Column | Type | Null | Notes |
+|---|---|---:|---|
+| `id` | BIGINT | no | auto increment |
+| `store_id` | BIGINT | no | `pos_store.id` |
+| `credential_version` | INT | no | store 内从 1 递增 |
+| `channel_id` | VARCHAR(50) ASCII | no | LINE Channel ID;业务层按字符串处理 |
+| `channel_secret` | VARCHAR(255) | no | 明文 |
+| `environment` | VARCHAR(16) | no | `SANDBOX/PRODUCTION` |
+| `credential_status` | VARCHAR(32) | no | `VERIFIED/VERIFY_FAILED`;仅 VERIFIED 可成为 current |
+| `is_enabled` | TINYINT | no | current 版本的新支付开关 |
+| `current_store_id` | BIGINT | yes | current 时等于 `store_id`,历史版本 NULL |
+| `verify_return_code` | VARCHAR(8) | yes | 最后探测结果码 |
+| `verify_return_message` | VARCHAR(255) | yes | 摘要 |
+| `verified_time` | DATETIME | yes | 验证通过时间 |
+| `create_time/update_time` | DATETIME | no | 审计时间 |
+| `create_by/update_by` | VARCHAR(64) | yes | 管理员 |
+
+Constraints and indexes:
+
+- `UNIQUE(store_id, credential_version)`
+- `UNIQUE(current_store_id)`;MySQL 允许多个 NULL。
+- `INDEX(store_id, create_time)`
+- 当前版本切换使用事务:锁定当前行/版本,插入新行,再把旧 `current_store_id` 置 NULL,新行置 storeId。
+- 相同 Channel ID、Secret 和 environment 重复保存时直接返回现有当前版本,不创建重复行。
+
+## 2. `pos_order_line_payment`
+
+每次真正发起的 LINE Request 对应一行,历史永不覆盖。行内更新仅用于同一尝试的状态推进和补充网关标识。
+
+| Column | Type | Null | Notes |
+|---|---|---:|---|
+| `id` | BIGINT | no | `paymentId` |
+| `dd_id` | VARCHAR(64) | no | `pos_order.dd_id` |
+| `line_order_id` | VARCHAR(100) ASCII BINARY | no | Request 前生成,永久唯一 |
+| `transaction_id` | VARCHAR(32) ASCII BINARY | yes | Request 成功后获得,禁止 Long |
+| `credential_id` | BIGINT | no | 发起时选中的不可变凭证版本 |
+| `store_id` | BIGINT | no | 门店快照 |
+| `amount` | INT | no | TWD 整数金额 |
+| `currency` | CHAR(3) ASCII | no | 首版 `TWD` |
+| `payment_url_web` | VARCHAR(1000) | yes | Sandbox/浏览器跳转 URL |
+| `payment_url_app` | VARCHAR(1000) | yes | 生产 LINE App URL,可空 |
+| `payment_provider` | VARCHAR(16) | yes | 原始值可空;展示归一为 TSP |
+| `status` | VARCHAR(40) | no | 下方状态机 |
+| `active_dd_id` | VARCHAR(64) | yes | 阻断新尝试时等于 ddId,明确终止后 NULL |
+| `version` | BIGINT | no | CAS,从 0 递增 |
+| `next_reconcile_at` | DATETIME | yes | 下次扫描 |
+| `reconcile_deadline` | DATETIME | yes | 当前恢复阶段截止 |
+| `reconcile_count` | INT | no | 默认 0 |
+| `lease_owner` | VARCHAR(64) | yes | 行租约节点/批次 ID |
+| `lease_until` | DATETIME | yes | 租约过期时间 |
+| `status_changed_at` | DATETIME | no | 状态变化时间 |
+| `auth_completed_time` | DATETIME | yes | Check 0110 时间 |
+| `pay_time` | DATETIME | yes | Capture 时间 |
+| `create_time/update_time` | DATETIME | no | 审计时间 |
+
+Constraints and indexes:
+
+- `UNIQUE(line_order_id)`
+- `UNIQUE(transaction_id)`;允许多个 NULL。
+- `UNIQUE(active_dd_id)`;允许历史多 NULL。
+- `INDEX(dd_id, create_time, id)`
+- `INDEX(status, next_reconcile_at, id)`
+- `INDEX(credential_id, create_time)`
+- 所有状态更新条件至少包含 `id + expected status + version`,领取租约还需 `lease_until IS NULL OR lease_until < NOW()`。
+
+Payment states:
+
+```text
+REQUESTING
+  ├─ WAITING_AUTH
+  ├─ REQUEST_UNKNOWN
+  └─ FAILED (only explicit no-side-effect failure; releases active key)
+
+WAITING_AUTH / REQUEST_UNKNOWN
+  ├─ READY_CONFIRM
+  ├─ PAID
+  ├─ CANCELLED_OR_EXPIRED (releases active key)
+  ├─ FAILED (releases active key)
+  └─ MANUAL_REVIEW
+
+READY_CONFIRM
+  ├─ CONFIRMING
+  └─ AUTH_DONE_ORDER_CANCELLED
+
+CONFIRMING
+  ├─ PAID
+  └─ CONFIRM_UNKNOWN
+
+CONFIRM_UNKNOWN
+  ├─ PAID
+  └─ MANUAL_REVIEW
+```
+
+`PAID`、UNKNOWN、`MANUAL_REVIEW` 不释放 `active_dd_id`。旧尝试迟到证实 `PAID` 时照实记录,并阻断/退款,不覆盖当前尝试。
+
+## 3. `pos_order_line_refund`
+
+首版只支持每笔已付款交易一次全额退款。
+
+| Column | Type | Null | Notes |
+|---|---|---:|---|
+| `id` | BIGINT | no | refund id |
+| `payment_id` | BIGINT | no | payment,唯一 |
+| `dd_id` | VARCHAR(64) | no | 查询快照 |
+| `credential_id` | BIGINT | no | 默认 payment 的凭证版本 |
+| `transaction_id` | VARCHAR(32) ASCII BINARY | no | 原支付交易号 |
+| `refund_transaction_id` | VARCHAR(32) ASCII BINARY | yes | LINE refund transaction |
+| `amount` | INT | no | 本地全额审计;调用时不传 refundAmount |
+| `status` | VARCHAR(32) | no | `CREATED/PROCESSING/UNKNOWN/RETRY_WAIT/REFUNDED/FAILED/MANUAL_REVIEW` |
+| `source` | VARCHAR(32) | no | `USER_CANCEL/STORE_CANCEL/ADMIN/TASK` |
+| `version` | BIGINT | no | CAS |
+| `next_reconcile_at/reconcile_deadline` | DATETIME | yes | 恢复调度 |
+| `reconcile_count` | INT | no | 默认 0 |
+| `lease_owner/lease_until` | VARCHAR(64)/DATETIME | yes | 行租约 |
+| `refund_time` | DATETIME | yes | 确证全退时间 |
+| `create_time/update_time` | DATETIME | no | 审计时间 |
+
+Constraints and indexes:
+
+- `UNIQUE(payment_id)`
+- `UNIQUE(refund_transaction_id)`;允许多个 NULL。
+- `INDEX(status, next_reconcile_at, id)`
+- `INDEX(dd_id, create_time, id)`
+
+Refund transitions:
+
+```text
+CREATED / RETRY_WAIT --CAS claim--> PROCESSING
+PROCESSING ├─ REFUNDED
+           ├─ UNKNOWN
+           ├─ RETRY_WAIT (only documented explicit retryable result)
+           └─ FAILED (explicit terminal failure)
+UNKNOWN    ├─ REFUNDED (Retrieve refundList proves it)
+           └─ MANUAL_REVIEW (deadline)
+```
+
+## 4. `payment_gateway_log`
+
+仅记录 LINE Pay;不迁移 OMG。日志采用追加写,不用于决定当前支付状态。
+
+| Column | Type | Null | Notes |
+|---|---|---:|---|
+| `id` | BIGINT | no | auto increment |
+| `correlation_id` | VARCHAR(64) ASCII | no | 同次 request/response 关联 |
+| `payment_id/refund_id/credential_id` | BIGINT | yes | 结构化关联 |
+| `store_id` | BIGINT | yes | 凭证验证也可关联门店 |
+| `dd_id` | VARCHAR(64) | yes | 业务订单 |
+| `gateway_order_id` | VARCHAR(100) ASCII BINARY | yes | line_order_id |
+| `transaction_id` | VARCHAR(32) ASCII BINARY | yes | 网关交易号 |
+| `action` | VARCHAR(32) | no | `CREDENTIAL_VERIFY/REQUEST/CHECK/CONFIRM/RETRIEVE/REFUND/REDIRECT` |
+| `direction` | VARCHAR(8) | no | `REQUEST/RESPONSE/EVENT` |
+| `source` | VARCHAR(32) | no | `APP/CALLBACK/TASK/ADMIN/CANCEL` |
+| `http_status` | INT | yes | HTTP status |
+| `return_code` | VARCHAR(8) | yes | LINE code |
+| `return_message` | VARCHAR(255) | yes | LINE message |
+| `success` | TINYINT | no | transport/business action summary |
+| `duration_ms` | BIGINT | yes | elapsed |
+| `payload` | LONGTEXT | yes | raw JSON/body/error summary |
+| `create_time` | DATETIME | no | append time |
+
+Indexes:
+
+- `(payment_id, create_time)`
+- `(refund_id, create_time)`
+- `(gateway_order_id, create_time)`
+- `(transaction_id, create_time)`
+- `(correlation_id)`
+- `(store_id, create_time)`
+
+## 5. Order integration
+
+不新增 `pos_order` 字段。使用现有:
+
+- `pay_type="3"`: 新 LINE 订单选择;历史同值不自动视为 LINE,必须存在匹配 Line payment。
+- `pay_status=0/1/2`: 未付/已付/已确认全额退款。
+- `pay_url`: 当前活跃尝试的 web URL,可作为兼容展示;支付选择以 Line payment 为准。
+- `state=4`: 已取消;仍允许记录迟到 `PAID`,随后创建退款 intent。
+
+## 6. Query selection invariants
+
+1. App 以 `ddId` 查询时先读取订单 `pay_status`。
+2. 未付款优先唯一 `active_dd_id=ddId`;没有则返回最新终态尝试。
+3. 已付款选唯一尚未证实全退的 `PAID`;已退款选带 `REFUNDED` 的付款/退款组合。
+4. 出现多条未全退 `PAID` 不自动任选,返回 `MANUAL_REVIEW`。
+5. 回跳只按唯一 `line_order_id` 定位,transactionId 存在时必须匹配。
+6. 定时/平台动作只按领取到的 `paymentId/refundId` 操作。
+

+ 161 - 0
specs/019-line-pay/plan.md

@@ -0,0 +1,161 @@
+# LINE Pay 直连支付 Implementation Plan
+
+> **For agentic workers:** 按 `tasks.md` 顺序逐项执行;每个实现任务先补失败测试,再写最小实现并运行对应验证。不得跳过状态机 CAS、外部调用前持久化 intent 或最终 `git diff` 检查。
+
+**Goal:** 为 `payType="3"` 接入 LINE Pay Online API v4,提供门店凭证版本管理、追加式支付尝试、异步确认、主动补单、自动全额退款和平台运维能力。
+
+**Architecture:** `ruoyi-system` 只承载实体、MyBatis Mapper 和持久化状态机;`ruoyi-admin` 承载 LINE HTTP/HMAC、订单编排、回跳页面、定时任务和 Controller。支付、确认、退款均采用“本地先提交 intent/claim → 外部 HTTP → 独立本地 CAS 落事实”,不依赖一个跨网络事务。支付尝试为订单 1:N 且历史永不覆盖,稳定键为 `paymentId`、`line_order_id`、`transaction_id` 和可空唯一 `active_dd_id`。
+
+**Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Apache HttpClient 4、fastjson2、Redisson、Vue 2、Element UI、JUnit 5/Mockito。
+
+---
+
+## Summary
+
+LINE Pay 普通付款省略未被当前公开契约保证的 `payType/confirmUrlType/appPackageName`,仅显式 `capture=true` 并使用默认浏览器回跳。Request 成功只代表已创建付款请求;回跳快速登记后返回内嵌安全中间页,Confirm 由可恢复服务异步完成。任务每分钟按行租约扫描,先 Retrieve,再按需 Check;只有 Check `0110` 且本地订单仍可付款时才能 Confirm。支付经严格匹配的 Retrieve/Confirm 证实后,支付行永久保持 `PAID`,退款事实只写退款表。
+
+## Technical Context
+
+**Language/Version**: Java 21;Vue 2  
+**Primary Dependencies**: Spring Boot、MyBatis、MyBatis-Plus、Apache HttpClient 4.5.14、fastjson2、Redisson、Element UI  
+**Storage**: MySQL;新增 4 张 LINE 专用表,不执行在线迁移  
+**Testing**: Maven Surefire + JUnit 5/Mockito;前端 ESLint/production build  
+**Target Platform**: Windows 开发;Spring Boot 多实例部署;LINE Pay Sandbox/Production  
+**Project Type**: 多模块后端 + 独立平台 Vue 项目  
+**Performance Goals**: App 查询只做本地数据库读取;定时任务单轮最多 20 笔并有时间预算;关键扫描走 `(status,next_reconcile_at,id)`  
+**Constraints**: 不修改 OMG 表结构或迁移 OMG 日志;不修改废弃 ZaloPay/PayController/TestTask/OrderAppeal 业务;Secret 明文存储和平台详情回显为用户已接受方案;支付/退款副作用未知时不得盲重试  
+**Scale/Scope**: 门店级凭证;单门店子订单;每订单多次历史尝试但最多一条阻断性尝试;首版只支持全额退款
+
+## Constitution Check
+
+`.specify/memory/constitution.md` 尚未定义项目条款,因此以下仓库规则作为强制门禁:
+
+- [x] 依赖方向保持 `ruoyi-admin -> ruoyi-system`;LINE HTTP 集成只放 `ruoyi-admin`。
+- [x] Controller 使用明确 DTO、`@RequestBody/@RequestParam/@RequestHeader/@PathVariable`,不新增 Map 入参或 Bean Validation。
+- [x] 所有 DDL/权限 SQL 只追加到 `updatesql/sql.md`,不直接执行数据库变更。
+- [x] 不修改废弃支付与任务代码;仅在当前有效订单入口接入支付/退款门禁。
+- [x] 平台新增文本四语同步,保留 Vue 文件 CRLF。
+- [x] 修复/功能均先测试再实现,交付前编译、定向测试、前端校验和 diff 审核。
+
+## Project Structure
+
+### Documentation
+
+```text
+specs/019-line-pay/
+├── spec.md
+├── plan.md
+├── research.md
+├── data-model.md
+├── quickstart.md
+├── tasks.md
+├── contracts/
+│   └── api.md
+└── checklists/
+    └── requirements.md
+```
+
+### Source Code
+
+```text
+foodie_server/
+├── ruoyi-system/src/main/java/com/ruoyi/system/
+│   ├── domain/{PosStoreLinePay,PosOrderLinePayment,PosOrderLineRefund,PaymentGatewayLog}.java
+│   ├── domain/dto/{StoreLinePayCredentialDto,StoreLinePayToggleDto}.java
+│   ├── domain/vo/PosStoreLinePayVo.java
+│   ├── mapper/*Line*.java
+│   └── service/{I*,impl/*}.java
+├── ruoyi-system/src/main/resources/mapper/chanting/*Line*.xml
+├── ruoyi-admin/src/main/java/com/ruoyi/app/
+│   ├── utils/linepay/{LinePayProperties,LinePaySigner,LinePayHttpTransport,ApacheLinePayHttpTransport,LinePayClient}.java
+│   ├── pay/{LinePayController,LinePayService,LinePayGatewayAuditService,PaymentCreateGuardService}.java
+│   ├── pay/dto/*LinePay*.java
+│   ├── mendian/PosStoreLinePayController.java
+│   └── task/LinePayReconcileTask.java
+├── ruoyi-admin/src/test/java/com/ruoyi/app/{utils/linepay,pay,task}/*Test.java
+├── ruoyi-system/src/test/java/com/ruoyi/system/service/impl/*Line*Test.java
+└── updatesql/sql.md
+
+foodie-admin-vue/
+└── src/
+    ├── api/chanting/storeLinePay.js
+    ├── api/system/order.js
+    ├── views/mendian/storePayment/{index.vue,components/LinePayTab.vue}
+    ├── views/system/order/index.vue
+    └── api/language/language.{zh_CN,zh_TW,en_US,vi}.js
+```
+
+## Component Design
+
+### Persistence boundary (`ruoyi-system`)
+
+- `IPosStoreLinePayService` 负责版本号分配、验证成功后的当前版本原子切换、启停和列表/详情。
+- `IPosOrderLinePaymentService` 负责创建 `REQUESTING`、稳定键查询、App 选择规则、CAS 状态推进、活跃键释放、扫描和行租约。
+- `IPosOrderLineRefundService` 负责 `UNIQUE(payment_id)` 的 insert-if-absent、CAS claim、扫描和退款事实。
+- `IPaymentGatewayLogService` 只追加审计数据;日志不是状态机事实,日志写入失败不回滚资金事实。
+- `PosOrderMapper` 新增条件更新:只有未付款、合法订单态且当前 `pay_type` 与目标渠道一致时,才允许领取支付渠道或更新支付 URL。
+
+### LINE gateway boundary (`ruoyi-admin`)
+
+- `LinePaySigner` 严格按 `channelSecret + URI + body/query + nonce` 计算 Base64 HMAC-SHA256;GET 使用最终原始 query 字符串,POST 使用最终 UTF-8 JSON 字节。
+- `LinePayClient` 暴露 `request/check/confirm/retrieve/refund/verifyCredential`;交易号始终为字符串。端点超时分别按 Request 10 秒、Check/Retrieve/Refund 20 秒、Confirm 40 秒配置。
+- `LinePayHttpTransport` 隔离实际 Apache HttpClient,便于不联网测试精确 method/URI/body/header。
+- `LinePayGatewayAuditService` 记录 correlationId、方向、action、HTTP/LINE 结果和 payload;调用方捕获日志异常后继续处理资金事实。
+
+### Orchestration boundary (`ruoyi-admin`)
+
+- `PaymentCreateGuardService` 使用共享 `pay:create:<ddId>` Redisson watchdog 锁和订单条件更新,LINE 与 OMG create 都经过同一门禁;OMG 仅做这处最小代码调整。
+- `LinePayService.create` 在锁内校验用户、订单、单门店、金额、当前凭证和历史 payType=3 归属,然后复用活跃尝试或先提交新 `REQUESTING`。Request HTTP 在事务外执行,结果以 CAS 落为 `WAITING_AUTH` 或 `REQUEST_UNKNOWN`。
+- `LinePayService.onConfirmRedirect` 只按 `line_order_id` 登记并尝试把行推进到可处理状态,不同步等待 Confirm;Controller 返回安全 HTML。
+- `LinePayService.reconcilePayment` 始终优先 Retrieve;无付款事实才 Check。副作用调用前先 CAS claim,超时进入 UNKNOWN,后续只读恢复。
+- `LinePayService.applyPaidFact` 在一笔本地事务内写 `PAID`、锁定订单并更新 `pay_status=1`;订单已取消则 insert-if-absent 创建退款 intent。
+- `LinePayService.requestFullRefund` 只针对 `PAID` 行,省略 `refundAmount`;成功后退款表为 `REFUNDED` 且订单 `pay_status=2`,支付表保持 `PAID`。
+- `LinePayReconcileTask` 使用进程级 watchdog 锁减少重复扫描,但每行仍通过 `lease_owner/lease_until/version` 领取;失败一笔不终止整批。
+
+### Platform and App boundary
+
+- `/pay/line/create`、`/query` 使用 `{ddId}` DTO 和现有 `@Auth + @Anonymous + token header` 组合;confirm/cancel 仅为匿名 GET 回跳。
+- HTML 页面由 Controller 自包含返回,不依赖单独服务器部署;固定 App Scheme,不接受客户端 redirect 参数,并设置 no-store/no-referrer/CSP/frame 限制。
+- 平台凭证页可查看当前版本详情、验证并切换新版本、启停;普通列表不携带 Secret,拥有详情权限的页面按用户决定可回显。
+- 订单平台页显示 LINE 支付/退款状态,并提供人工查询与全额退款;资金动作使用独立权限。
+
+## Error and Recovery Policy
+
+| 场景 | 本地处理 | 后续动作 |
+|---|---|---|
+| Request 超时/响应丢失 | `REQUEST_UNKNOWN`,保留活跃键 | Retrieve by `line_order_id`,必要时 Check |
+| Check `0000` | 保持等待 | 退避后再查 |
+| Check `0110` | 本地门禁通过后 CAS `CONFIRMING` | Confirm;超时转 `CONFIRM_UNKNOWN` |
+| Check `0121/0122` | 先 Retrieve 排除已付款 | 明确未付款才终止并释放活跃键 |
+| Check `0123` | 不直接判已付 | Retrieve 验证唯一 `PAYMENT + CAPTURE` |
+| Confirm 非明确结果/超时 | `CONFIRM_UNKNOWN` | 只读 Retrieve/Check,不盲 Confirm |
+| 取消后迟到付款 | 支付仍写 `PAID` | 唯一退款 intent + 自动全退 |
+| Refund 超时/未知 | `UNKNOWN` | Retrieve 原支付并检查 `refundList` |
+| 到达恢复截止仍未知 | `MANUAL_REVIEW` | 停止自动副作用,平台人工处理 |
+
+## Implementation Order
+
+1. 先落实 SQL 模型、实体/Mapper/Service 和状态 CAS 测试。
+2. 实现 HMAC、HTTP 契约和凭证验证测试,再接凭证管理接口。
+3. 实现 create/query、回跳页面和追加式尝试选择规则。
+4. 实现 Retrieve/Check/Confirm 恢复、支付事实事务和取消竞态。
+5. 实现全额退款与定时任务,再接两个真实取消入口和接单门禁。
+6. 最小修改 OMG create 共享渠道门禁。
+7. 完成平台前端、四语和权限 SQL,最后做全栈验证。
+
+## Verification Gates
+
+- `mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false test`
+- `mvn -pl ruoyi-admin -am -Dtest='*LinePay*Test,OrderLifecycleServiceTest,OmgPayControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test`
+- `mvn -pl ruoyi-admin -am -DskipTests package`
+- 在 `foodie-admin-vue` 运行 `npm run lint` 和 `npm run build:prod`;若仓库已有无关 lint 错误,记录并对变更文件运行 ESLint。
+- `git diff --check`、`git status --short`、逐文件核对换行/编码、确认未触碰废弃代码和用户已有 OMG SQL。
+
+## Complexity Tracking
+
+| Decision | Why Needed | Simpler Alternative Rejected Because |
+|---|---|---|
+| 订单 1:N 支付尝试 + 可空唯一活跃键 | 保留每次网关 orderId/transactionId/凭证与未知状态,避免迟到回跳污染新支付 | 覆盖单行会丢失恢复和退款所需键值 |
+| 不可变凭证版本 + payment.credential_id | 轮换后历史交易仍能找到原渠道身份 | 一店一行覆盖会让旧交易失去凭证归属 |
+| 支付与退款分表 | `PAID` 是资金事实,退款是另一事实与状态机 | 在支付状态写 REFUNDED 会掩盖曾经扣款 |
+| durable intent + 外部调用 + CAS | 数据库事务无法覆盖 LINE 网络副作用 | 单个 `@Transactional` 无法修复响应丢失和本地提交失败 |

+ 95 - 0
specs/019-line-pay/quickstart.md

@@ -0,0 +1,95 @@
+# LINE Pay Implementation Quickstart
+
+## 1. Prerequisites
+
+- JDK 21: `C:\Users\qmj\.jdks\graalvm-jdk-21.0.7`
+- MySQL schema changes from `updatesql/sql.md` applied manually by the developer
+- Redis/Redisson available
+- LINE Pay Sandbox Channel ID/Secret
+- Public HTTPS API base URL reachable by LINE redirect
+- Platform frontend: `E:\QtwCode\foodie\foodie-admin-vue`
+
+Do not execute schema migration from Codex. Do not use the deprecated `TestTask`, `ZaloPayController`, `PayController`, or `OrderAppealController` flows.
+
+## 2. Runtime configuration
+
+`ruoyi-admin/src/main/resources/application.yml` contains non-secret defaults:
+
+```yaml
+line-pay:
+  environment: sandbox
+  base-url: https://sandbox-api-pay.line.me
+  confirm-url: https://api.example.com/pay/line/confirm
+  cancel-url: https://api.example.com/pay/line/cancel
+  reconcile:
+    fixed-delay-ms: 60000
+    initial-delay-ms: 60000
+    batch-size: 20
+    row-lease-seconds: 120
+    round-budget-seconds: 45
+    auth-deadline-minutes: 30
+    unknown-deadline-hours: 24
+```
+
+Channel credentials are saved per store through the platform API; they are not placed in application.yml.
+
+## 3. Local verification
+
+Use JDK 21 only in the current PowerShell process:
+
+```powershell
+$env:JAVA_HOME='C:\Users\qmj\.jdks\graalvm-jdk-21.0.7'
+$env:PATH="$env:JAVA_HOME\bin;$env:PATH"
+mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -Dtest='*LinePay*Test,OrderLifecycleServiceTest,OmgPayControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
+Frontend:
+
+```powershell
+Set-Location E:\QtwCode\foodie\foodie-admin-vue
+npm run lint
+npm run build:prod
+```
+
+Repository checks:
+
+```powershell
+Set-Location E:\QtwCode\foodie\foodie_server
+git diff --check
+git status --short
+```
+
+## 4. Sandbox acceptance flow
+
+1. Platform opens Store Payment → LINE Pay.
+2. Enter store Channel ID/Secret; verify the detail shows a new current version and enabled state.
+3. Create one single-store order with `payType=3`.
+4. Call `/pay/line/create`; assert payment row is `WAITING_AUTH` and response uses `paymentUrl.web`.
+5. Repeat create before completion; assert the same `paymentId/lineOrderId` is returned with `reusedAttempt=true`.
+6. Complete Sandbox web authorization and allow confirm redirect.
+7. Confirm page returns quickly and App query eventually reports `PAID`.
+8. Query DB read-only and verify payment remains `PAID`, order `pay_status=1`, and gateway logs contain REQUEST/CHECK/CONFIRM/RETRIEVE events.
+9. Cancel a paid order; verify exactly one refund row is created, eventually `REFUNDED`, while payment remains `PAID` and order becomes `pay_status=2`.
+10. Create a second attempt only after the first is explicitly cancelled/expired/failed; verify both rows remain and only the second has `active_dd_id`.
+
+## 5. Required concurrency tests
+
+- Two simultaneous create calls for the same LINE order return one attempt.
+- LINE create and OMG create for the same order cannot both claim the payment channel.
+- confirm redirect, scheduled task and admin reconcile cannot Confirm twice.
+- user cancellation before late Capture still records PAID and creates one refund intent.
+- store cancellation and late Capture produce the same result and enforce store ownership.
+- credential rotation during a waiting/paid transaction does not change its `credential_id`; old transaction remains queryable/refundable.
+- UNKNOWN and MANUAL_REVIEW never release `active_dd_id` or directly repeat side effects.
+
+## 6. Production acceptance items
+
+Sandbox cannot prove every App handoff behavior. Before production enablement, verify on real iOS and Android devices:
+
+- LINE payment browser returns to the intermediate page and the fixed Scheme handoff works.
+- `com.twanmsdyh.app://payment/result` is registered and handled.
+- auto-open works where allowed; the manual button works in LINE WebView and external browsers.
+- App always calls `/query` and never trusts the deep-link as success.
+- current production credentials complete an end-to-end TWD payment and full refund.

+ 106 - 0
specs/019-line-pay/research.md

@@ -0,0 +1,106 @@
+# LINE Pay Research and Decisions
+
+**Date**: 2026-08-12  
+**API baseline**: LINE Pay Online API v4
+
+## R1. LINE 请求字段与内部 payType 分离
+
+**Decision**: 项目订单 `payType="3"` 仅表示 LINE Pay 渠道,不发送给 LINE。普通付款请求发送 `capture=true`,省略当前公开 v4 Request 页面未明确保证的 `payType/confirmUrlType/appPackageName`,使用默认浏览器回跳。
+
+**Rationale**: 避免依赖当前公开契约未列出的枚举和字段;内部枚举始终与 LINE 请求字段无关。
+
+**Rejected**: 把数字 `3` 写入 LINE 请求;这会违反官方契约。
+
+**Source**: https://developers-pay.line.me/zh/online-api-v4/request-payment
+
+## R2. 凭证验证与自动启用
+
+**Decision**: LINE 没有专门的无副作用 credential validation API。保存前使用候选 Channel ID/Secret 对随机、不存在的 `orderId` 调 Retrieve;Sandbox 接受签名且返回预期 `1150`(或结构完整的 `0000`)视为“凭证已被环境接受”,随后原子创建并切换为当前启用版本。网络异常、`9000` 或认证错误不切换当前版本。
+
+**Rationale**: `1150` 的官方含义只是无交易记录,不能证明币种、门店归属或真实付款能力。用户已批准把该探测作为自动启用门禁,Sandbox 端到端付款仍为上线验收项。
+
+**Rejected**: 保存后不校验;使用 Request 创建真实付款做凭证探测(有副作用)。
+
+**Source**: https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details
+
+## R3. 凭证采用不可变版本
+
+**Decision**: 每次换 Channel ID 或 Secret 且验证成功后新建版本;旧版本不覆盖并由支付行保存 `credential_id`。相同值重复提交采用幂等返回,不额外生成版本。每门店仅一个 `current_store_id=store_id` 的当前版本。
+
+旧版本仍优先用于交易恢复;若其返回明确凭证鉴权失败,系统只允许尝试同门店、同环境且 Channel ID 完全相同的当前版本,并必须先用 Retrieve 严格匹配 transactionId、orderId、currency、amount 与退款证据。只读证据成立后,本轮才可使用当前版本继续相应支付事实恢复或退款;不同 Channel、不同环境或证据不完整一律保持 UNKNOWN/人工核对。
+
+**Rationale**: Confirm/Retrieve/Refund 必须知道付款发起时的渠道身份;版本关系也提供完整审计。若 LINE 后台使旧 Secret 立即失效,原凭证调用失败后只允许在相同 Channel ID 的当前版本上先做只读 Retrieve 证明交易归属,再使用当前版本继续恢复;不同 Channel ID 不自动替代。
+
+**Rejected**: 一店一行直接覆盖;在每笔支付快照 Secret(重复敏感数据且难轮换)。
+
+## R4. 支付尝试为追加式账本
+
+**Decision**: `pos_order_line_payment` 对订单是 1:N。重复点击复用唯一活跃行;只有网关明确取消/过期/失败后才把 `active_dd_id` 置 NULL,重新支付插入新行。PAID、UNKNOWN、MANUAL_REVIEW 都继续阻断新尝试。
+
+**Rationale**: LINE 回跳和网关恢复依赖旧 `line_order_id/transaction_id`;覆盖会导致迟到事件写到新交易并让退款不可追溯。
+
+**Rejected**: 每订单固定一行覆盖失败/取消尝试;仅 `UNIQUE(dd_id,is_active)` 且历史写 0(会限制只能一条 inactive)。
+
+## R5. 浏览器回跳快速返回
+
+**Decision**: `/pay/line/confirm` 只持久化回跳事实并唤醒异步处理,快速返回自包含 HTML;页面尝试固定 App Scheme 并提供点击兜底,App 通过 `/query` 读取最终状态。
+
+**Rationale**: 官方重定向页面的 confirmURL read timeout 约 20 秒,而 Confirm read timeout 要至少 40 秒,同步等待存在确定的超时冲突。
+
+**Rejected**: 回跳 Controller 内同步 Confirm 后才输出 HTML;独立部署静态安全页。
+
+**Sources**: https://developers-pay.line.me/zh/online-api-v4/merchant/redirection-pages/ ; https://developers-pay.line.me/zh/online-api-v4/confirm-payment
+
+## R6. Retrieve 是支付事实来源,Check 是恢复提示
+
+**Decision**: 恢复先 Retrieve。只有 `returnCode=0000` 且 `info[]` 唯一匹配 orderId/transactionId/currency、`transactionType=PAYMENT`、`payInfo` 金额合计才判已支付;不依赖当前公开契约未保证的 `payStatus`。Retrieve `1150` 时再 Check:`0000` 等待、`0110` 通过本地门禁后 Confirm、`0121/0122` 在再次排除付款事实后终止、`0123` 回 Retrieve。
+
+**Rationale**: Check `0123` 仍要求查询实际付款详情;Retrieve `0000` 仅表示查询成功,不能单独表示已 Capture。
+
+**Rejected**: 把 Check `0000/0123` 或 Retrieve `0000` 直接映射为已支付。
+
+**Sources**: https://developers-pay.line.me/zh/online-api-v4/check-payment-request-status ; https://developers-pay.line.me/zh/online-api-v4/retrieve-payment-details
+
+## R7. 全额退款单独建模
+
+**Decision**: 每笔 `PAID` payment 最多一条退款行;全额退款调用省略 `refundAmount`。Refund `0000` 保存 `refundTransactionId`;未知结果只通过 Retrieve 的 `refundList` 恢复。支付行永不改成 REFUNDED。
+
+**Rationale**: 官方用省略 `refundAmount` 表达全额退款;付款和退款是两个独立资金事实。
+
+**Rejected**: 发送等于订单金额的 `refundAmount` 并假设一定等价;Refund UNKNOWN 直接重发。
+
+**Source**: https://developers-pay.line.me/zh/online-api-v4/refund
+
+## R8. 外部副作用必须有 durable intent
+
+**Decision**: Request 前先提交 `REQUESTING + line_order_id`;Confirm/Refund 前先 CAS 到 PROCESSING 状态;HTTP 后另事务写事实。超时进入 UNKNOWN,不能回退为可直接重复副作用的状态。
+
+**Rationale**: 数据库事务不能回滚 LINE 已执行的网络操作,响应丢失必须能用稳定键查询恢复。
+
+**Rejected**: 把数据库写和 HTTP 包在同一个 `@Transactional` 方法中。
+
+## R9. 跨渠道创建门禁
+
+**Decision**: LINE 与 OMG create 共用 `pay:create:<ddId>` watchdog 锁和 `pos_order` 条件更新。LINE 还必须存在匹配支付行,不能仅凭历史 `payType=3` 识别 LINE。
+
+**Rationale**: LINE 表上的唯一活跃键只能阻止 LINE 对 LINE,无法阻止 OMG 与 LINE 并发;历史 `payType=3` 可能属于已下线的 ZaloPay。
+
+**Rejected**: 仅在 LINE 表加唯一索引;迁移或修改 OMG 表。
+
+## R10. 安全中间页与 Sandbox 限制
+
+**Decision**: Controller 返回固定模板 HTML,动态内容做 HTML/JS/URL 编码;响应包含 `Cache-Control: no-store`、`Referrer-Policy: no-referrer` 和严格 CSP。Sandbox 使用 web paymentUrl;LINE App 内唤起、`appPackageName` 和自定义 Scheme 在生产真机验收。
+
+**Rationale**: 浏览器/WebView 和 App 安装状态决定是否能自动唤起;官方不保证自定义 Scheme 一定被放行,Sandbox 也不能完整模拟 App 环境。
+
+**Rejected**: 接受请求传入 redirect URL;假设自动唤起必然成功。
+
+**Sources**: https://developers-pay.line.me/zh/faq ; https://developers-pay.line.me/zh/api-change-log
+
+## R11. 日志与状态职责
+
+**Decision**: `payment_gateway_log` 仅记录 LINE 请求/响应追加审计,OMG 不迁移;支付/退款业务表仍保存规范状态、稳定键和调度字段。Secret 明文与管理员详情回显风险按用户决定接受,不额外扩大到普通列表或 App 接口。
+
+**Rationale**: 原始日志不能提供可靠的 CAS、唯一性、扫描索引或当前状态;同时普通列表不需要批量传输 Secret。
+
+**Rejected**: 把全部状态只存日志;让 OMG 同步迁移到新日志表。

+ 42 - 22
specs/019-line-pay/spec.md

@@ -4,7 +4,7 @@
 
 **Created**: 2026-08-12
 
-**Status**: Draft — 完整设计待一次性批准
+**Status**: Approved — 2026-08-12
 
 **Input**: 在餐饮订单中新增 LINE Pay 直连支付,使用 `payType="3"`,支持门店级凭证、支付确认、全额退款、主动状态查询、平台管理和 App 回跳。
 
@@ -22,7 +22,7 @@
 | 凭证粒度 | 每个 `pos_store` 独立 Channel ID / Channel Secret;订单按 `PosOrder.mdId` 选择凭证 |
 | 凭证验证 | 保存时使用无扣款 Retrieve 探测;探测通过后保存为新版本并自动启用,失败时保留旧版本 |
 | Secret 策略 | 按已批准风险,Channel Secret 首版明文保存;平台管理员详情页/查询接口允许回显;不设“日志、异常、响应必须隐藏 Secret/HMAC”的验收限制 |
-| 确认与请款 | `confirmUrlType=CLIENT`,普通支付,Confirm 自动请款;不实现授权/请款分离、Capture 或 Void 操作 |
+| 确认与请款 | 使用普通支付默认浏览器回跳并快速返回安全提示页,Confirm 自动请款;不实现授权/请款分离、Capture 或 Void 操作 |
 | 退款 | 仅全额退款;用户不直接调用独立退款接口,用户/商家取消订单后自动退款;平台管理员可人工全额退款 |
 | 主动查询 | 新建独立定时任务主动 Check/Retrieve/Confirm 并更新支付、退款及订单状态;不使用废弃的 `TestTask` |
 | App 回跳 | 固定 `com.twanmsdyh.app://payment/result?orderId=<ddId>`;App 打开后必须调用查询接口取得最终状态,不信任 Scheme 参数判断支付结果 |
@@ -86,7 +86,7 @@
 1. **Given** 正确 Sandbox 凭证,**When** 管理员保存,**Then** Retrieve 返回 `1150`,或返回结构有效的 `0000`,系统标记认证探测通过、保存新版本并自动启用。
 2. **Given** 返回 `1104`、`1105`、`1106` 或明确鉴权失败,**When** 管理员保存,**Then** 拒绝新版本,当前版本保持不变。
 3. **Given** 网络超时、`9000` 或结果未知,**When** 管理员保存,**Then** 返回“验证结果未知”,不保存、不切换、不停用旧凭证。
-4. **Given** 旧版本仍有关联交易,**When** 新版本启用,**Then** 旧交易的 Confirm、Retrieve 和 Refund 仍使用其原 `credential_id`
+4. **Given** 旧版本仍有关联交易,**When** 新版本启用,**Then** 旧交易优先使用其原 `credential_id`;若旧版本明确鉴权失败,只允许同门店、同环境、同 Channel ID 的当前版本先经严格 Retrieve 证明交易归属,再用于本轮恢复,绝不尝试不同 Channel 的凭证
 
 > LINE Pay 没有专用的凭证校验接口。上述 `1150`/`0000` 仅表示该签名与 Channel 组合被网关接受,不证明门店归属、TWD/Online 权限或真实扣款能力。自动启用是本项目已批准的业务策略;真实支付能力仍须 Sandbox 端到端验收。
 
@@ -150,7 +150,7 @@
 只承载实体、Mapper XML、Service 和数据库条件更新,不依赖 LINE Pay HTTP 客户端,不导入 `com.ruoyi.app.*`:
 
 - `pos_store_line_pay`:门店凭证的不可变版本。
-- `pos_order_line_payment`:每次 LINE Request/Confirm 支付尝试的规范状态
+- `pos_order_line_payment`:每次真正发起的新 LINE Request 各保存一条支付尝试;历史尝试永不覆盖
 - `pos_order_line_refund`:每笔已付款交易最多一条全额退款状态。
 - `payment_gateway_log`:LINE Pay 原始交互与结果码的追加日志。
 
@@ -204,12 +204,14 @@
 
 只保存支付状态事实和恢复调度字段,不保存每次网关 returnCode 或整段原始响应。
 
+该表与订单是 `1:N`:同一 `dd_id` 可以有多条历史支付尝试,但任意时刻最多只有一条当前活跃尝试。用户对仍在等待或处理中的尝试重复点击支付时复用原记录;只有原尝试已被 LINE 明确判定取消、过期或失败后,重新支付才插入新记录。旧记录及其 `line_order_id`、`transaction_id`、`credential_id` 永不被新尝试覆盖。
+
 关键字段:
 
 | 字段 | 约束与含义 |
 |---|---|
 | `id` | 主键 |
-| `dd_id` | `pos_order.dd_id`,字符串业务订单号 |
+| `dd_id` | `pos_order.dd_id`,字符串业务订单号;普通索引、允许重复,不设唯一约束 |
 | `line_order_id` | Request 前生成并提交,`VARCHAR(100)`、ASCII/BINARY 比较,非空永久唯一,实际值不超过 100 字符 |
 | `transaction_id` | LINE 19 位交易号,`VARCHAR`/Java String/JS String,可空唯一,禁止 Long/Number |
 | `credential_id` | 不可变引用 `pos_store_line_pay.id` |
@@ -241,6 +243,8 @@ MANUAL_REVIEW
 
 `PAID`、所有 `UNKNOWN` 和 `MANUAL_REVIEW` 继续占用 `active_dd_id`;只有网关明确 `0121`、`0122` 或明确无副作用失败才能释放。支付一旦 `PAID` 永不改写为退款状态。
 
+支付行只允许按照状态机 CAS 推进状态和补充本次尝试的网关结果;不得把另一支付尝试的交易号、凭证或支付地址写入旧行,也不得通过覆盖旧行实现“重新支付”。
+
 > 禁止使用 `UNIQUE(dd_id,is_active)` 并把历史行写成 `0`,因为这会导致同一订单只能保存一条历史失效记录。DDL 使用可空 `active_dd_id` 唯一索引或等价生成列。
 
 ### 5.3 `pos_order_line_refund`
@@ -296,17 +300,15 @@ POST /v4/payments/{transactionId}/refund
 ```
 
 - Sandbox base URL 固定为 `https://sandbox-api-pay.line.me`,不可由请求参数控制。
-- `payType="3"` 只属于本项目,绝不发送给 LINE。LINE 的 `options.payment.payType` 使用 `NORMAL` 或省略
+- `payType="3"` 只属于本项目,绝不发送给 LINE;普通支付省略 LINE 的 `options.payment.payType`
 - 自动请款使用默认行为或显式 `options.payment.capture=true`。
-- Request 显式发送根级
+- Request 发送官方公开契约中的回跳字段;普通支付省略未被当前 v4 公共页面明确保证的 `payType`、`confirmUrlType` 和 `appPackageName`,使用默认浏览器回跳
 
 ```json
 {
   "redirectUrls": {
     "confirmUrl": "https://foodieapi.waimai-paotui.com/pay/line/confirm",
-    "cancelUrl": "https://foodieapi.waimai-paotui.com/pay/line/cancel",
-    "confirmUrlType": "CLIENT",
-    "appPackageName": "com.twanmsdyh.app"
+    "cancelUrl": "https://foodieapi.waimai-paotui.com/pay/line/cancel"
   }
 }
 ```
@@ -339,7 +341,7 @@ POST: channelSecret + apiPath + exactRequestBody + nonce
 `returnCode=0000` 只表示查询成功,不等于已付款。必须在 `info[]` 中找到唯一匹配本地 `line_order_id + transaction_id + currency` 的记录,并确认:
 
 - `transactionType=PAYMENT`
-- `payStatus=CAPTURE`
+- `payInfo[].amount` 合计与本地金额一致;Online v4 当前公开 Retrieve 契约未保证 `payStatus` 字段,因此不依赖该字段
 - 金额与本地服务端金额相符
 - 门店与 `credential_id` 相符
 
@@ -363,7 +365,7 @@ POST: channelSecret + apiPath + exactRequestBody + nonce
 - `WAITING_AUTH`:查询现有尝试后返回同一有效 `paymentUrl`。
 - `READY_CONFIRM/CONFIRMING/UNKNOWN`:返回“处理中”,不创建新尝试。
 - `PAID`:直接返回已支付。
-- `CANCELLED_OR_EXPIRED/FAILED`:可生成新的永久唯一 `line_order_id`
+- `CANCELLED_OR_EXPIRED/FAILED`:旧行保留并释放 `active_dd_id`;重新支付插入新行并生成新的永久唯一 `line_order_id`,不更新或覆盖旧行
 
 ### 7.2 confirm/cancel 回跳和中间页
 
@@ -394,7 +396,7 @@ com.twanmsdyh.app://payment/result?orderId=<URL-encoded ddId>
 - 若订单仍处于合法未完成状态,CAS 更新 `pos_order.pay_type=3,pay_status=1`,并只触发一次现有履约/推送副作用。堂食初始 `state=1` 也允许核销。
 - 若订单已经 `state=4`,仍先记录 `PAID` 和订单已付款事实,并在同一事务插入唯一退款意图;不触发履约。
 
-商家接单以及仍有效的 `/setorderuzt` 必须阻止 `payType=3,payStatus=0` 的订单进入履约,避免未付款接单
+商家接单必须阻止真实 LINE 未付款订单进入履约。真实 LINE 订单禁止走旧 `/setorderuzt`,只能使用当前用户、商家、骑手专用订单操作入口;历史 `payType=3` 且没有 LINE 流水的订单不按新 LINE 订单处理
 
 ### 7.4 主动查询状态映射
 
@@ -417,14 +419,29 @@ com.twanmsdyh.app://payment/result?orderId=<URL-encoded ddId>
 3. 退款执行者 CAS 到 `PROCESSING`,先 Retrieve 排除已经退款,再调用一次省略 `refundAmount` 的全额 Refund。
 4. Refund `0000` 且返回 `refundTransactionId` 后置 `REFUNDED`,再更新订单 `payStatus=2` 和既有退款后状态/积分。
 5. 超时或未知置 `UNKNOWN`;通过原 PAYMENT 的 `refundList` 或 refundTransactionId Retrieve 恢复,禁止直接重发 Refund。
+6. 管理退款在调用网关前,必须在订单行锁事务内创建唯一退款意图;全部真实 LINE 完成入口以同一条件更新拒绝存在非 `FAILED` 退款意图的订单,确保“完成订单”和“开始退款”最多只有一方成功占位,已经退款的订单也不能再完成。
+7. 租约过期的 `PROCESSING` 恢复为 `UNKNOWN` 时必须初始化未知截止时间;截止前每次无结论 Retrieve 必须以版本 CAS 后移 `next_reconcile_at`,截止时的最后一次 Retrieve 不再重排,而是以当前版本可靠落入 `MANUAL_REVIEW`,避免旧未知记录长期占据批次队首或永久占用退款意图。
+8. `REFUNDED` 是不可降级终态;通用人工核对 CAS 永远不得覆盖它。只有 Retrieve 已严格证实存在部分或歧义退款证据时,专用证据 CAS 才可将 `FAILED` 等非退款终态升级为 `MANUAL_REVIEW`,阻止订单在已出现退款事实时继续履约;Retrieve 严格证实全额退款时,另一专用证据 CAS 可将 `FAILED` 升级为 `REFUNDED`,但不得覆盖既有 `MANUAL_REVIEW/REFUNDED`。
 6. 平台人工退款与自动退款复用同一唯一退款行和状态机,不能绕过幂等门禁。
 
+### 7.6 查询定位规则
+
+查询不依赖覆盖旧记录,而是按查询目的使用稳定键:
+
+1. **App 按 `ddId` 查询**:先读取订单资金状态。订单 `payStatus=1` 时选择唯一一条尚未证实全额退款的 `PAID` 支付行;`payStatus=2` 时返回已证实全额退款的支付/退款事实;未支付时查询唯一的 `active_dd_id=ddId`;没有活跃行时返回 `create_time,id` 倒序的最近一条终态尝试。若出现多条尚未全额退款的 `PAID`,则不自动任选,返回人工核对状态。
+2. **LINE confirm/cancel 回跳**:按唯一 `line_order_id` 精确定位;同时带有 `transactionId` 时还必须与同一行匹配。历史回跳只处理历史行,不得更新当前活跃行。
+3. **定时任务和人工查询**:按支付主键领取行租约,再使用该行自己的 `line_order_id/transaction_id/credential_id` 查询 LINE。
+4. **退款**:只按已确认的 `PAID payment_id` 创建或读取唯一退款行,不按“最近一条支付”猜测。
+5. **平台历史**:按 `dd_id` 查询全部尝试并按 `create_time,id` 倒序展示。
+
+若已释放的历史尝试后来被 Retrieve 证实实际支付成功,系统必须在该历史行记录真实 `PAID`。订单尚未支付时,以该事实核销订单并阻止其他尝试 Confirm;订单已由另一尝试支付时,把迟到的重复付款转入自动全额退款或人工核对,不能覆盖任一支付行。
+
 ## 8. 定时任务设计
 
 `LinePayReconcileTask` 位于 `ruoyi-admin/src/main/java/com/ruoyi/app/task`,默认每 60 秒启动一轮:
 
 - 使用独立 Redisson 锁 key 和 watchdog 自动续租,不使用易在长 Confirm 中过期的固定短 lease。
-- 每轮分页选择 `next_reconcile_at <= now` 的非终态支付/退款,默认批量上限 20,并设置单轮时间预算;一笔失败不影响其他记录。
+- 每轮分页选择 `next_reconcile_at <= now` 的非终态支付/退款,默认批量上限 20,并为支付、取消补偿和退款保留独立处理机会;支付行领取租约时同步后移 `next_reconcile_at`,避免慢响应记录反复占据队首,一笔失败不影响其他记录。
 - 每行使用 `lease_owner/lease_until/version` CAS claim。即使全局锁失效或人工操作并发,也只有一个副作用执行者。
 - 索引覆盖 `(status,next_reconcile_at,id)`;按状态退避并递增 `reconcile_count`。
 - 等待认证默认追踪 30 分钟。到期前最后一次 Retrieve/Check;若仍无明确终态,进入 `MANUAL_REVIEW` 并保留 `active_dd_id`。
@@ -450,7 +467,7 @@ GET  /pay/line/cancel
 - Controller 入参禁止 Map;DTO 不使用 Bean Validation 注解,业务校验通过项目 i18n 机制返回。
 - 不提供用户直接退款 API。
 
-创建响应包含:`ddId`、`lineOrderId`、字符串 `transactionId`、`paymentUrl`、规范支付状态、`reusedAttempt`。查询响应包含订单支付状态、LINE 规范支付状态、退款状态和更新时间;不以 Scheme 或回跳参数作为结果。
+创建响应包含:`ddId`、`paymentId`、`lineOrderId`、字符串 `transactionId`、`paymentUrl`、规范支付状态、`reusedAttempt`。查询响应按 7.6 的规则包含所选 `paymentId`、订单支付状态、LINE 规范支付状态、退款状态和更新时间;不以 Scheme 或回跳参数作为结果。
 
 ### 9.2 平台接口和权限
 
@@ -464,8 +481,8 @@ chanting:storeLinePay:list
 chanting:storeLinePay:query
 chanting:storeLinePay:saveCredentials
 chanting:storeLinePay:toggleEnable
-system:order:linePayQuery
-system:order:linePayRefund
+system:order:linePaymentReconcile
+system:order:lineRefund
 ```
 
 现有支付管理菜单入口从仅 OMG 权限调整为公共 `storePayment:list`,避免只有 LINE 权限时进不了页面。所有 SQL 只写入 `updatesql/sql.md`。
@@ -476,7 +493,7 @@ system:order:linePayRefund
 - **FR-002**:系统 MUST 仅允许订单本人对单门店、未支付、未取消、未完成的餐饮订单发起 LINE Pay;MUST 拒绝多门店父单。
 - **FR-003**:系统 MUST 按订单 `mdId` 使用当前已启用且探测通过的门店凭证版本,金额固定取服务端订单的整数 TWD。
 - **FR-004**:系统 MUST 使用 Online API v4,并保证签名 JSON/query 与实际发送字节一致;MUST NOT 把本地 `payType=3` 发送为 LINE API 的 `options.payment.payType`。
-- **FR-005**:系统 MUST 在调用 Request 前持久化永久唯一 `line_order_id` 和支付意图,并用唯一索引、分布式订单锁和数据库 CAS 保证重复/并发 create 不生成多个有效尝试
+- **FR-005**:系统 MUST 在调用 Request 前插入并持久化永久唯一 `line_order_id` 和支付意图;同一订单允许多条历史支付尝试但最多一条活跃尝试。重复点击 MUST 复用活跃行,只有旧尝试被明确终止后才插入新行,任何重新支付 MUST NOT 覆盖旧行
 - **FR-006**:系统 MUST 把 confirm/cancel 当作不可信浏览器事件;confirm MUST 快速返回服务端 HTML 中间页,不同步等待 Confirm,不直接宣告支付成功。
 - **FR-007**:系统 MUST 由独立定时任务主动执行 Retrieve、Check 和必要的 Confirm,并严格按 `0000/0110/0121/0122/0123` 映射推进状态。
 - **FR-008**:系统 MUST 仅在 Confirm 成功响应通过核对,或 Retrieve 唯一证实 `PAYMENT/CAPTURE` 后记录已支付;Check、HTTP 200 和回跳到达均不是最终资金事实。
@@ -484,11 +501,12 @@ system:order:linePayRefund
 - **FR-010**:系统 MUST 处理取消与迟到支付竞态;订单已取消时仍记录真实付款,并创建唯一自动全额退款意图。
 - **FR-011**:系统 MUST 仅支持全额退款,调用 Refund 时省略 `refundAmount`;退款未知时 MUST Retrieve,禁止盲目重复 Refund,只有证实退款后才更新订单已退款状态。
 - **FR-012**:系统 MUST 以不可变版本保存门店凭证,支付流水引用 `credential_id`;新凭证探测失败或未知不得覆盖当前版本,旧交易继续使用原版本。
+- **FR-012A**:旧凭证明确鉴权失败时,系统 MUST 仅对同门店、同环境、同 Channel ID 的当前版本执行严格只读 Retrieve;证据完整匹配后才可继续本轮恢复,不得改写支付流水原 `credential_id`。
 - **FR-013**:系统 MUST 将 LINE Pay 交互追加到 `payment_gateway_log`,原始结果码不塞入支付/退款业务表;该日志首版 MUST NOT 接管或迁移 OMG 日志。
 - **FR-014**:系统 MUST 提供平台门店凭证管理、启停、订单人工查询与全额退款,并用独立权限控制;Secret 列表不批量返回,详情可按已批准策略回显。
 - **FR-015**:平台新增可见文本 MUST 使用 `$t()` 并同步简中、繁中、英文、越南文四份实际语言文件,key 使用 `storeLinePay` 下有意义的英文驼峰名称。
 - **FR-016**:系统 MUST 保持 OMG 三张业务表、OMG `ipn_log` 和既有原始回调结构不变;只允许为防跨渠道并发,对 OMG create 增加相同订单级锁和渠道一致性门禁。
-- **FR-017**:所有 LINE 查询、补单和退款 MUST 同时要求存在匹配的 LINE 支付流水;MUST NOT 仅凭历史 `payType=3` 操作资金
+- **FR-017**:所有 LINE 查询、补单和退款 MUST 同时要求存在匹配的 LINE 支付流水;回跳按 `line_order_id/transaction_id`、任务按 `payment_id`、退款按已付 `payment_id` 精确处理,MUST NOT 仅凭历史 `payType=3` 或“最近一条”猜测资金记录
 - **FR-018**:所有 DDL、权限和菜单 SQL MUST 只追加到 `updatesql/sql.md`,不得由实现过程直接执行。
 - **FR-019**:系统 MUST 按已批准风险明文保存并允许平台权限详情回显 Secret;本期不增加 Secret/HMAC 强制脱敏验收,但不得把真实凭证硬编码进源码或测试数据。
 - **FR-020**:App Scheme 只负责返回 App;App MUST 使用 token 和 `ddId` 调查询接口取得最终支付/退款状态,不得信任 URL 参数得出支付结果。
@@ -509,7 +527,8 @@ system:order:linePayRefund
 - DTO:19 位 `transactionId` 在 Java/JSON/前端全程保持字符串。
 - 金额:订单、Request package/product、Confirm 均为同一整数 TWD;全额 Refund 省略 `refundAmount`。
 - 状态机:重复 create、重复/乱序回跳、并发 Confirm、Confirm 超时、Refund 超时、任务与人工操作并发。
-- 数据约束:同订单只一条阻断性 LINE 流水、同支付只一条退款、凭证并发轮换只有一个当前版本。
+- 数据约束:同订单允许多条永不覆盖的历史尝试但只一条阻断性 LINE 流水、同支付只一条退款、凭证并发轮换只有一个当前版本。
+- 查询定位:App、回跳、定时任务、退款和历史查询分别按 7.6 的稳定键选中正确尝试;旧回跳不得污染新尝试。
 - 订单竞态:支付先成功再取消、取消先发生再迟到付款,最终只一次全额退款。
 - 订单类型:外送 `state=0` 和堂食 `state=1` 均可正确核销;未付 LINE 订单不得接单。
 - 历史兼容:没有 LINE 流水的历史 `payType=3` 不触发 LINE 查询或退款。
@@ -521,7 +540,7 @@ system:order:linePayRefund
 
 - 正确/错误凭证保存探测与自动启用。
 - Request 返回 `paymentUrl.web`,完成 Web 收银台认证。
-- CLIENT 回跳快速显示中间页,异步 Confirm 后 App 查询接口返回已支付。
+- 浏览器回跳快速显示中间页,异步 Confirm 后 App 查询接口返回已支付。
 - 主动 Check 的 `0000/0110/0121/0122/0123` 映射和 Retrieve 二次核实。
 - 全额 Refund 和退款后 Retrieve/refundList 核实。
 - 服务重启、回跳丢失和外部响应超时后的恢复。
@@ -532,12 +551,13 @@ Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在
 
 - iOS、Android 与 LINE 内置浏览器的 Scheme 自动唤起和手动按钮兜底。
 - App 已注册并处理 `/payment/result`,打开后带 token 查询服务端最终状态。
-- `appPackageName` 和 CLIENT 回跳行为。
+- LINE 内置浏览器回跳与自定义 Scheme 行为。
 - v4 `paymentProvider` 的 TSP/EPI 兼容。
 
 ## 13. 成功标准
 
 - 重复回跳、重复任务和重复人工操作不会造成第二次 Confirm 或第二次全额退款。
+- 重新支付会新增尝试并完整保留旧行;订单查询仍能稳定返回已付、当前活跃或最近终态记录。
 - 回跳接口在 2 秒内返回中间页,不受 Confirm 40 秒读取超时影响。
 - 正常情况下,主动任务在两个调度周期内把可确认或可查询交易推进到最新可证实状态。
 - 任何支付/退款未知结果在截止前持续 Retrieve,截止后进入人工核对且不自动释放订单支付占用。

+ 92 - 0
specs/019-line-pay/tasks.md

@@ -0,0 +1,92 @@
+# Tasks: LINE Pay 直连支付
+
+**Input**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/api.md`  
+**Tests**: 本功能涉及支付、凭证、外部 HTTP 和并发状态,所有实现任务必须先添加失败测试。
+
+## Phase 1: Planning and safety gates
+
+- [x] T001 将 `.specify/feature.json` 指向 `specs/019-line-pay`,并把 `spec.md` 标记为 2026-08-12 已批准
+- [x] T002 完成 `research.md`、`data-model.md`、`contracts/api.md`、`quickstart.md` 和 `plan.md`
+- [x] T003 阅读并执行支付安全、TDD 和执行计划技能;运行 spec-kit prerequisite 检查并记录初始 dirty worktree,确保不覆盖用户的 `updatesql/sql.md` 和 observation 变更
+
+## Phase 2: Persistence foundation (ruoyi-system)
+
+- [x] T004 [P] 在 `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosStoreLinePayServiceImplTest.java` 写失败测试:相同凭证幂等、轮换新版本、并发只一个 current、失败验证不切换
+- [x] T005 [P] 在 `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImplTest.java` 写失败测试:创建 REQUESTING、同订单唯一活跃、稳定键查询、CAS 状态、只有明确终态释放、App 选择多 PAID 转人工核对
+- [x] T006 [P] 在 `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImplTest.java` 写失败测试:paymentId 唯一、claim/UNKNOWN/REFUNDED CAS 和扫描租约
+- [x] T007 在 `ruoyi-system/src/main/java/com/ruoyi/system/domain/` 新建 4 个实体,在 `domain/dto/` 新建 `StoreLinePayCredentialDto.java`、`StoreLinePayToggleDto.java`,在 `domain/vo/` 新建 `PosStoreLinePayVo.java`
+- [x] T008 在 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/` 与 `ruoyi-system/src/main/resources/mapper/chanting/` 新建凭证、支付、退款、网关日志 Mapper/XML;所有状态推进使用 expected status + version CAS,扫描使用覆盖索引条件
+- [x] T009 在 `ruoyi-system/src/main/java/com/ruoyi/system/service/` 和 `service/impl/` 实现 4 个持久化服务,使 T004-T006 通过
+- [x] T010 在 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderMapper.java` 与 `resources/mapper/system/PosOrderMapper.xml` 添加订单支付渠道领取、支付事实、退款事实的条件更新,并补相应 Mapper/Service 测试
+- [x] T011 运行 `mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false test`
+
+## Phase 3: LINE v4 client and credential management (ruoyi-admin)
+
+- [x] T012 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePaySignerTest.java` 写官方格式的 POST JSON、GET 精确 query、非 ASCII、空 body 和 nonce 签名失败测试
+- [x] T013 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java` 写 method/URI/body/header、字符串 transactionId、超时分档、Request packages、默认浏览器 redirect、full refund `{}` 契约测试
+- [x] T014 在 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/` 实现 properties、signer、transport 和 client;固定 base URL 配置,不允许请求控制目标地址
+- [x] T015 在 `ruoyi-admin/src/test/java/com/ruoyi/app/mendian/PosStoreLinePayControllerTest.java` 写凭证探测 `1150/0000/认证错误/网络未知`、详情权限、普通列表不含 Secret 的失败测试
+- [x] T016 在 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreLinePayController.java` 实现列表/详情/save/toggle,使用明确 DTO 和业务 i18n;在 `application.yml` 添加 LINE 非秘密配置并移除有效 Zalo sandbox 配置块
+- [x] T017 在 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayGatewayAuditService.java` 实现追加日志包装,测试日志失败不回滚/改变支付事实
+
+## Phase 4: Create, query and redirect page
+
+- [x] T018 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java` 写失败测试:用户归属、payType=3、历史 Zalo 无 Line 行不识别、单门店、金额、凭证、重复点击复用、明确终态新增行、Request UNKNOWN
+- [x] T019 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePaySelectionTest.java` 写 App 查询选择规则与旧回跳不污染新尝试测试
+- [x] T020 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReturnPageRendererTest.java` 写 HTML/JS/URL 转义、固定 Scheme、安全响应头、无同步 Confirm 测试
+- [x] T021 在 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/PaymentCreateGuardService.java` 实现共享 watchdog 锁 + 订单条件领取,并对 `OmgPayController#create` 做仅限 create 门禁的最小调整及回归测试
+- [x] T022 在 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java` 实现 create/query 与独立事务 intent/result;在 `pay/dto/` 新建请求/响应 DTO
+- [x] T023 在 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayController.java` 实现 create/query/confirm/cancel;confirm/cancel 快速返回 self-contained HTML,cancel 不写终态
+- [x] T024 修改 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java` 和 `ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderPositionInfo.java` 的有效 payType 注释为 `3=LINE Pay`,不修改废弃 Zalo 实体/Controller
+
+## Phase 5: Reconcile, payment facts and refund
+
+- [x] T025 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayReconcileTest.java` 覆盖 Retrieve 唯一 CAPTURE、1150→Check、0000/0110/0121/0122/0123、Confirm UNKNOWN、deadline→MANUAL_REVIEW
+- [x] T026 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java` 覆盖支付先成功后取消、取消先发生后迟到成功、堂食 state=1、退款 intent 唯一
+- [x] T027 [P] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayRefundTest.java` 覆盖全额退款省略 amount、UNKNOWN 只 Retrieve、refundList 恢复、PAID 不改写、订单 payStatus=2
+- [x] T028 在 `LinePayService` 实现 Retrieve-first 状态机、Confirm 本地门禁、支付事实事务、退款状态机与凭证版本解析,使 T025-T027 通过
+- [x] T029 在 `ruoyi-admin/src/test/java/com/ruoyi/app/task/LinePayReconcileTaskTest.java` 写 watchdog 锁、batch=20、时间预算、行租约、单笔失败继续和取消订单不排除测试
+- [x] T030 在 `ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java` 实现每分钟支付/退款恢复任务,不调用 Controller,不修改 `TestTask.java`
+- [x] T031 修改 `UserOrderController` 和 `PosOrderShOprateController` 的真实取消入口:取消后调用 create-refund-if-absent;商家入口先按 userType/shId/mdId 鉴权
+- [x] T032 修改 `PosOrderShOprateController` 接单入口并禁止真实 LINE 订单走 `PosOrderController#/setorderuzt`;历史 payType=3 且无 Line payment 不进入新 LINE 资金动作
+- [x] T033 扩展 `OrderLifecycleService`、`AdminOrderStatusContext` 和 `PosOrderController`:显示 LINE 上下文,提供独立权限的人工 reconcile/full refund,并补现有 OrderLifecycle 测试
+- [x] T033A 管理全额退款先在订单行锁内创建唯一退款意图;用户、商家、骑手真实 LINE 完成入口统一使用拒绝非终态退款意图的 CAS,并补并发回归测试
+- [x] T033B 退款 PROCESSING 崩溃恢复时初始化 UNKNOWN 截止时间,UNKNOWN 无结论查询使用版本 CAS 退避重排,避免批次队首饥饿
+- [x] T033C 定时恢复为支付、取消补偿和退款保留独立执行机会,支付领取租约时同步重排;真实 LINE 用户完成统一触发结算副作用;骑手接单、取餐、送达统一校验骑手角色、合法状态和订单归属
+- [x] T033D 退款 UNKNOWN 到达截止时间后执行最后一次只读 Retrieve,不再先重排版本;人工核对 CAS 检查影响行数并重读真实状态,避免接口状态与数据库不一致
+- [x] T033E 旧凭证明确定鉴权失败时,仅以同门店/环境/Channel 当前版本执行严格 Retrieve 证明后恢复;不同 Channel 或证据不符禁止 Confirm/Refund;REFUNDED 在应用层和 SQL 层永久禁止降级为人工核对
+- [x] T033F 为严格 Retrieve 已证实的部分/歧义退款提供专用证据 CAS,允许 FAILED 升级人工核对但永久排除 REFUNDED,避免资金证据被通用状态白名单吞掉
+- [x] T033G 为严格 Retrieve 已证实的全额退款提供专用证据 CAS,允许 FAILED 升级已退款但排除 MANUAL_REVIEW/REFUNDED,避免外部全退事实被历史本地失败状态吞掉
+
+## Phase 6: SQL, messages and platform Vue
+
+- [x] T034 在 `updatesql/sql.md` 末尾追加 2026-08-12 LINE DDL、索引、页面公共 `chanting:storePayment:list` 和 LINE list/query/save/toggle、订单 reconcile/refund 权限 SQL;不改写既有 OMG SQL
+- [x] T035 在 `ruoyi-admin/src/main/resources/i18n/messages.properties` 及 `messages_{zh_CN,zh_TW,en_US,vi}.properties` 添加相同 LINE 业务错误 key
+- [x] T036 在 `foodie-admin-vue/src/api/chanting/storeLinePay.js` 新建凭证 API;在 `views/mendian/storePayment/components/LinePayTab.vue` 实现列表、详情、凭证验证/轮换和启停;修改 `index.vue` 替换占位页并保持 CRLF
+- [x] T037 在平台四个 `src/api/language/language.*.js` 文件添加完全相同的 `storeLinePay` key,并调整 `storePayment` 文本,不硬编码用户可见中文
+- [x] T038 修改平台 `src/api/system/order.js` 与 `src/views/system/order/index.vue`,显示 LINE 状态并按独立权限提供查询/全额退款;UNKNOWN/MANUAL_REVIEW 明确提示不可重复副作用
+
+## Phase 7: Full verification and delivery
+
+- [x] T039 运行所有 LINE 定向测试和 `OrderLifecycleServiceTest,PosOrderAdminStatusControllerTest` 回归(99 项通过);OMG 全量测试仍有与本功能无关的既有日志脱敏期望差异,未修改 OMG
+- [x] T040 使用 JDK 21 运行 `mvn -pl ruoyi-admin -am -DskipTests package`
+- [x] T041 在 `foodie-admin-vue` 对 LINE 新增文件运行 lint、运行 production build、核对四语 `storeLinePay` 32 个子 key 集合和 Vue CRLF
+- [x] T042 运行 `git diff --check`、检查 `git status --short` 和 diff 统计;确认无数据库执行、无废弃代码改动、无用户文件覆盖
+- [x] T043 对照 `quickstart.md` 完成可在本地模拟的验收;真实 Sandbox、LINE App 和商户 App Scheme 真机验证留在交付说明
+
+## Dependencies
+
+- T004-T006 可并行写测试;T007-T010 使其通过后才能开始业务编排。
+- T012-T013 可并行;T014 完成后执行 T015-T017。
+- T018-T020 可并行;T021-T024 完成 create/query/redirect。
+- T025-T027 可并行;T028 完成后再接任务和订单入口 T029-T033。
+- 后端契约稳定后执行 T034-T038;最后统一 T039-T043。
+
+## Definition of Done
+
+- 同一订单可保留多条历史 LINE 尝试,但只有一条阻断性活跃行。
+- 凭证轮换不覆盖历史版本,每笔支付持有 credentialId。
+- 所有支付/确认/退款未知结果都可只读恢复,绝不盲重试副作用。
+- 取消竞态最终只一次全额退款;payment 保持 PAID,退款成功后 order.payStatus=2。
+- App 查询、回跳、任务、平台动作都通过稳定键定位正确尝试。
+- 平台四语、权限、SQL 和最小 OMG create 门禁完成;目标测试、编译与前端检查通过。

+ 129 - 0
updatesql/sql.md

@@ -676,3 +676,132 @@ WHERE p.pay_status=0;
 SELECT dd_id, COUNT(*) AS cnt FROM pos_order_omg_payment WHERE is_active=1 AND pay_status=0
 GROUP BY dd_id HAVING COUNT(*)>1;
 ```
+
+## 2026-08-12 LINE Pay 直连支付(019-line-pay)
+
+```sql
+-- 仅记录脚本,不在 Codex 会话中执行。MySQL 5.7+;凭证使用不可变版本,Secret 按产品决定明文保存。
+CREATE TABLE pos_store_line_pay (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  store_id BIGINT NOT NULL,
+  credential_version INT NOT NULL,
+  channel_id VARCHAR(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  channel_secret VARCHAR(255) NOT NULL,
+  environment VARCHAR(16) NOT NULL,
+  credential_status VARCHAR(32) NOT NULL,
+  is_enabled TINYINT NOT NULL DEFAULT 1,
+  current_store_id BIGINT NULL,
+  verify_return_code VARCHAR(8) NULL,
+  verify_return_message VARCHAR(255) NULL,
+  verified_time DATETIME NULL,
+  create_by VARCHAR(64) NULL,
+  update_by VARCHAR(64) NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_line_credential_version (store_id, credential_version),
+  UNIQUE KEY uk_line_current_store (current_store_id),
+  KEY idx_line_credential_store_time (store_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LINE Pay门店不可变凭证版本';
+
+CREATE TABLE pos_order_line_payment (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  dd_id VARCHAR(64) NOT NULL,
+  line_order_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  transaction_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
+  credential_id BIGINT NOT NULL,
+  store_id BIGINT NOT NULL,
+  amount INT NOT NULL,
+  currency CHAR(3) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'TWD',
+  payment_url_web VARCHAR(1000) NULL,
+  payment_url_app VARCHAR(1000) NULL,
+  payment_provider VARCHAR(16) NULL,
+  status VARCHAR(40) NOT NULL,
+  active_dd_id VARCHAR(64) NULL,
+  version BIGINT NOT NULL DEFAULT 0,
+  next_reconcile_at DATETIME NULL,
+  reconcile_deadline DATETIME NULL,
+  reconcile_count INT NOT NULL DEFAULT 0,
+  lease_owner VARCHAR(64) NULL,
+  lease_until DATETIME NULL,
+  status_changed_at DATETIME NOT NULL,
+  auth_completed_time DATETIME NULL,
+  pay_time DATETIME NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_line_order_id (line_order_id),
+  UNIQUE KEY uk_line_transaction_id (transaction_id),
+  UNIQUE KEY uk_line_active_dd (active_dd_id),
+  KEY idx_line_payment_dd_time (dd_id, create_time, id),
+  KEY idx_line_payment_due (status, next_reconcile_at, id),
+  KEY idx_line_payment_credential (credential_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LINE Pay追加式支付尝试';
+
+CREATE TABLE pos_order_line_refund (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  payment_id BIGINT NOT NULL,
+  dd_id VARCHAR(64) NOT NULL,
+  credential_id BIGINT NOT NULL,
+  transaction_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  refund_transaction_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
+  amount INT NOT NULL,
+  status VARCHAR(32) NOT NULL,
+  source VARCHAR(32) NOT NULL,
+  version BIGINT NOT NULL DEFAULT 0,
+  next_reconcile_at DATETIME NULL,
+  reconcile_deadline DATETIME NULL,
+  reconcile_count INT NOT NULL DEFAULT 0,
+  lease_owner VARCHAR(64) NULL,
+  lease_until DATETIME NULL,
+  refund_time DATETIME NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_line_refund_payment (payment_id),
+  UNIQUE KEY uk_line_refund_transaction (refund_transaction_id),
+  KEY idx_line_refund_due (status, next_reconcile_at, id),
+  KEY idx_line_refund_dd_time (dd_id, create_time, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LINE Pay全额退款状态与事实';
+
+CREATE TABLE payment_gateway_log (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  correlation_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+  payment_id BIGINT NULL,
+  refund_id BIGINT NULL,
+  credential_id BIGINT NULL,
+  store_id BIGINT NULL,
+  dd_id VARCHAR(64) NULL,
+  gateway_order_id VARCHAR(100) CHARACTER SET ascii COLLATE ascii_bin NULL,
+  transaction_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL,
+  action VARCHAR(32) NOT NULL,
+  direction VARCHAR(8) NOT NULL,
+  source VARCHAR(32) NOT NULL,
+  http_status INT NULL,
+  return_code VARCHAR(8) NULL,
+  return_message VARCHAR(255) NULL,
+  success TINYINT NOT NULL,
+  duration_ms BIGINT NULL,
+  payload LONGTEXT NULL,
+  create_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  KEY idx_gateway_log_payment (payment_id, create_time),
+  KEY idx_gateway_log_refund (refund_id, create_time),
+  KEY idx_gateway_log_order (gateway_order_id, create_time),
+  KEY idx_gateway_log_transaction (transaction_id, create_time),
+  KEY idx_gateway_log_correlation (correlation_id),
+  KEY idx_gateway_log_store (store_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LINE Pay网关追加审计日志';
+
+-- 把门店支付页入口改为公共权限,避免只拥有 LINE 权限时无法进入页面。
+UPDATE sys_menu SET perms='chanting:storePayment:list', remark='门店支付配置(OMG/LINE Pay)'
+WHERE path='storePayment' AND component='mendian/storePayment/index';
+SET @storePaymentMenuId = (SELECT menu_id FROM sys_menu WHERE path='storePayment' AND component='mendian/storePayment/index' LIMIT 1);
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES
+('LINE Pay列表', @storePaymentMenuId, 11, '#', '', 'F', '0', '0', 'chanting:storeLinePay:list', '#', 'admin', NOW(), ''),
+('LINE Pay详情', @storePaymentMenuId, 12, '#', '', 'F', '0', '0', 'chanting:storeLinePay:query', '#', 'admin', NOW(), ''),
+('LINE Pay凭证保存', @storePaymentMenuId, 13, '#', '', 'F', '0', '0', 'chanting:storeLinePay:saveCredentials', '#', 'admin', NOW(), ''),
+('LINE Pay启停', @storePaymentMenuId, 14, '#', '', 'F', '0', '0', 'chanting:storeLinePay:toggleEnable', '#', 'admin', NOW(), ''),
+('LINE Pay主动查询', @storePaymentMenuId, 15, '#', '', 'F', '0', '0', 'system:order:linePaymentReconcile', '#', 'admin', NOW(), ''),
+('LINE Pay全额退款', @storePaymentMenuId, 16, '#', '', 'F', '0', '0', 'system:order:lineRefund', '#', 'admin', NOW(), '');
+```