Browse Source

完成 LINE Pay 商家扫码支付后端接入

qmj 1 month ago
parent
commit
1c080d1612
47 changed files with 2391 additions and 86 deletions
  1. 2 2
      CLAUDE.md
  2. 52 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderLinePayOfflineController.java
  3. 29 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  4. 24 1
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java
  5. 32 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayGatewayAuditService.java
  6. 531 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java
  7. 20 9
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayRefundService.java
  8. 9 3
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  9. 11 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflinePayRequest.java
  10. 16 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflineResult.java
  11. 20 1
      ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java
  12. 47 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayClient.java
  13. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/LinePayOfflineRequest.java
  14. 9 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  15. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  16. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  17. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  18. 9 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  19. 165 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java
  20. 38 1
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java
  21. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayGatewayAuditServiceTest.java
  22. 417 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java
  23. 75 0
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayRefundTest.java
  24. 8 8
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayServiceCreateTest.java
  25. 28 0
      ruoyi-admin/src/test/java/com/ruoyi/app/task/LinePayReconcileTaskTest.java
  26. 65 0
      ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java
  27. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  28. 2 1
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLinePayment.java
  29. 18 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLinePaymentMapper.java
  30. 10 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLinePaymentService.java
  31. 45 1
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java
  32. 8 2
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImpl.java
  33. 44 3
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml
  34. 5 1
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  35. 67 0
      ruoyi-system/src/test/java/com/ruoyi/system/mapper/LinePayOfflinePersistenceContractTest.java
  36. 38 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImplTest.java
  37. 13 0
      ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImplTest.java
  38. 2 2
      specs/019-line-pay/checklists/requirements.md
  39. 112 5
      specs/019-line-pay/contracts/api.md
  40. 45 5
      specs/019-line-pay/data-model.md
  41. 3 2
      specs/019-line-pay/offline-merchant-scan-design.md
  42. 98 22
      specs/019-line-pay/plan.md
  43. 36 4
      specs/019-line-pay/quickstart.md
  44. 64 2
      specs/019-line-pay/research.md
  45. 19 9
      specs/019-line-pay/spec.md
  46. 70 2
      specs/019-line-pay/tasks.md
  47. 15 0
      updatesql/sql.md

+ 2 - 2
CLAUDE.md

@@ -1,6 +1,6 @@
 # foodie_server Development Guidelines
 
-Auto-generated from all feature plans. Last updated: 2026-05-15
+Auto-generated from all feature plans. Last updated: 2026-08-18
 
 ## Tech Stack
 
@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/016-omg-payment/plan.md`
+shell commands, and other important information, read the current plan: `specs/019-line-pay/plan.md`
 <!-- SPECKIT END -->

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

@@ -0,0 +1,52 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.pay.LinePayOfflineService;
+import com.ruoyi.app.pay.dto.LinePayOfflinePayRequest;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
+import org.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;
+
+@RestController
+@RequestMapping("/system/orderShOprate/linePay/offline")
+public class PosOrderLinePayOfflineController {
+    private final LinePayOfflineService offlineService;
+
+    public PosOrderLinePayOfflineController(LinePayOfflineService offlineService) {
+        this.offlineService = offlineService;
+    }
+
+    @Anonymous
+    @Auth
+    @PostMapping("/pay")
+    public AjaxResult pay(@RequestHeader String token,
+                          @RequestBody(required = false) LinePayOfflinePayRequest request) {
+        if (request == null || request.getDdId() == null || request.getDdId().trim().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("line.pay.order.required"));
+        }
+        if (request.getOneTimeKey() == null
+                || !request.getOneTimeKey().matches("\\d{18}")) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.mycode.invalid"));
+        }
+        Long merchantId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(offlineService.pay(merchantId, request.getDdId().trim(),
+                request.getOneTimeKey()));
+    }
+
+    @Anonymous
+    @Auth
+    @GetMapping("/status")
+    public AjaxResult status(@RequestHeader String token, @RequestParam String ddId) {
+        Long merchantId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(offlineService.status(merchantId, ddId));
+    }
+}

+ 29 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -110,6 +110,9 @@ public class PosOrderShOprateController extends BaseController {
     {
         JwtUtil jwtUtil = new JwtUtil();
         String id = jwtUtil.getusid(token);
+        InfoUser merchant = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
+                .eq(InfoUser::getUserId, Long.valueOf(id)));
+        validateMerchantOrderInput(merchant, input);
         String shdz=null;
         createOrderParent(input,Long.valueOf(id),shdz);
         createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz);
@@ -180,6 +183,7 @@ public class PosOrderShOprateController extends BaseController {
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             // 设置支付相关信息(从父订单继承)
             posOrder.setPayType(input.getPaymentMethod());
+            posOrder.setOrderSource("MERCHANT");
             //设置桌号(从父订单继承)
             posOrder.setTableId(input.getTableId());
             posOrder.setTableNo(input.getTableNo());
@@ -208,6 +212,31 @@ public class PosOrderShOprateController extends BaseController {
         }
     }
 
+    void validateMerchantOrderInput(InfoUser merchant, OrderCreateInput input) {
+        if (merchant == null || merchant.getUserId() == null
+                || "0".equals(merchant.getUserType()) || "2".equals(merchant.getUserType())) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.merchant.required"));
+        }
+        if (input == null || input.getItems() == null || input.getItems().size() != 1) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.single.store.required"));
+        }
+        OrderCreatItem item = input.getItems().get(0);
+        if (item == null || item.getMdId() == null || item.getAmount() == null
+                || item.getAmount() <= 0) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.invalid"));
+        }
+        boolean directMerchant = "1".equals(merchant.getUserType())
+                || "3".equals(merchant.getUserType());
+        PosStore store = directMerchant ? posStoreService.getById(item.getMdId()) : null;
+        boolean owned = directMerchant
+                ? store != null && merchant.getUserId().equals(item.getShId())
+                && merchant.getUserId().equals(store.getUserId())
+                : merchant.getStoreId() != null && merchant.getStoreId().equals(item.getMdId());
+        if (!owned) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.forbidden"));
+        }
+    }
+
     //生成取餐码,根据门店id,当天取餐码从1开始累加
     private void setPickUpNum(PosOrder order) {
         if (order == null || order.getMdId() == null) {

+ 24 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java

@@ -124,6 +124,25 @@ public class LinePayFactService {
         }
     }
 
+    /** Persists captured mismatch evidence and its unique refund intent atomically. */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean applyAmountMismatchFact(Long paymentId, Long version, String expectedStatus,
+                                           String transactionId, String paymentProvider,
+                                           Integer capturedAmount, Date payTime) {
+        int marked = paymentService.markOfflineEvidence(paymentId, version, expectedStatus,
+                "AMOUNT_MISMATCH", transactionId, paymentProvider, capturedAmount, payTime);
+        if (marked != 1) {
+            return false;
+        }
+        PosOrderLinePayment current = paymentService.getById(paymentId);
+        if (current == null || !"AMOUNT_MISMATCH".equals(current.getStatus())
+                || !capturedAmount.equals(current.getCapturedAmount())) {
+            throw new ServiceException("LINE Pay captured amount mismatch state is unavailable");
+        }
+        refundService.createIfAbsent(current, "AMOUNT_MISMATCH");
+        return true;
+    }
+
     private boolean isOnlyRecordedLinePayment(PosOrder order, PosOrderLinePayment payment) {
         if (!"3".equals(order.getPayType()) || !Long.valueOf(1L).equals(order.getPayStatus())) {
             return false;
@@ -136,9 +155,13 @@ public class LinePayFactService {
     @Transactional(rollbackFor = Exception.class)
     public void applyRefundFact(Long paymentId) {
         PosOrderLinePayment payment = paymentService.getById(paymentId);
-        if (payment == null || !"PAID".equals(payment.getStatus())) {
+        if (payment == null || !("PAID".equals(payment.getStatus())
+                || "AMOUNT_MISMATCH".equals(payment.getStatus()))) {
             throw new ServiceException("LINE Pay payment not found or not captured");
         }
+        if ("AMOUNT_MISMATCH".equals(payment.getStatus())) {
+            return;
+        }
         if (orderMapper != null) {
             int orderMarked = orderMapper.markLineRefunded(payment.getDdId(), payment.getId());
             PosOrder latest = orderService.getOne(new QueryWrapper<PosOrder>()

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

@@ -41,6 +41,33 @@ public class LinePayGatewayAuditService {
         }
     }
 
+    /**
+     * Offline Pay audit boundary. The method intentionally accepts no request body or DTO, so the
+     * one-time My Code cannot cross into persistence or exception formatting.
+     */
+    public <T> T executeOffline(String source, Long paymentId, Long credentialId,
+                                Long storeId, String ddId, String gatewayOrderId,
+                                GatewayCall<T> call) throws Exception {
+        long started = System.currentTimeMillis();
+        String correlationId = UUID.randomUUID().toString();
+        append(correlationId, "REQUEST", source, paymentId, null, credentialId,
+                storeId, ddId, gatewayOrderId, null, 0, 0L, null, "REQUEST");
+        try {
+            T result = call.call();
+            LinePayResponse response = result instanceof LinePayResponse
+                    ? offlineResponseMetadata((LinePayResponse) result) : null;
+            append(correlationId, "REQUEST", source, paymentId, null, credentialId,
+                    storeId, ddId, gatewayOrderId, null, 1,
+                    System.currentTimeMillis() - started, response, "RESPONSE");
+            return result;
+        } catch (Exception exception) {
+            append(correlationId, "REQUEST", source, paymentId, null, credentialId,
+                    storeId, ddId, gatewayOrderId, null, 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,
@@ -98,6 +125,11 @@ public class LinePayGatewayAuditService {
         }
     }
 
+    private static LinePayResponse offlineResponseMetadata(LinePayResponse response) {
+        return new LinePayResponse(response.httpStatus(), response.returnCode(), null,
+                response.transactionId(), null, null);
+    }
+
     @FunctionalInterface
     public interface GatewayCall<T> {
         T call() throws Exception;

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

@@ -0,0 +1,531 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.pay.dto.LinePayOfflineResult;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayCredential;
+import com.ruoyi.app.utils.linepay.LinePayOfflineRequest;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+/** Merchant-presented LINE Pay Offline My Code orchestration. */
+@Service
+public class LinePayOfflineService {
+    private static final String MODE_OFFLINE = "OFFLINE";
+    private static final Set<String> AUTH_REQUIRED_CODES = Set.of("1145", "1169");
+    private static final Set<String> UNCERTAIN_CODES = Set.of(
+            "1152", "1172", "1198", "1199", "9000");
+    private static final Set<String> DEFINITE_PAY_FAILURE_CODES = Set.of(
+            "1101", "1102", "1104", "1105", "1106", "1110", "1124", "1133",
+            "1141", "1142");
+
+    @Value("${line-pay.reconcile.auth-deadline-minutes:30}")
+    private long authDeadlineMinutes = 30L;
+    @Value("${line-pay.reconcile.unknown-deadline-hours:24}")
+    private long unknownDeadlineHours = 24L;
+
+    private final IPosOrderService orderService;
+    private final IInfoUserService userService;
+    private final IPosStoreService storeService;
+    private final IPosStoreLinePayService credentialService;
+    private final IPosOrderLinePaymentService paymentService;
+    private final LinePayClient linePayClient;
+    private final LinePayFactService factService;
+    private final LinePayGatewayAuditService auditService;
+    private final PaymentCreateGuardService createGuard;
+
+    public LinePayOfflineService(IPosOrderService orderService,
+                                 IInfoUserService userService,
+                                 IPosStoreService storeService,
+                                 IPosStoreLinePayService credentialService,
+                                 IPosOrderLinePaymentService paymentService,
+                                 LinePayClient linePayClient,
+                                 LinePayFactService factService) {
+        this(orderService, userService, storeService, credentialService, paymentService, linePayClient,
+                factService, null, null);
+    }
+
+    @Autowired
+    public LinePayOfflineService(IPosOrderService orderService,
+                                 IInfoUserService userService,
+                                 IPosStoreService storeService,
+                                 IPosStoreLinePayService credentialService,
+                                 IPosOrderLinePaymentService paymentService,
+                                 LinePayClient linePayClient,
+                                 LinePayFactService factService,
+                                 LinePayGatewayAuditService auditService,
+                                 PaymentCreateGuardService createGuard) {
+        this.orderService = orderService;
+        this.userService = userService;
+        this.storeService = storeService;
+        this.credentialService = credentialService;
+        this.paymentService = paymentService;
+        this.linePayClient = linePayClient;
+        this.factService = factService;
+        this.auditService = auditService;
+        this.createGuard = createGuard;
+    }
+
+    public LinePayOfflineResult pay(Long merchantId, String ddId, String oneTimeKey) {
+        if (oneTimeKey == null || !oneTimeKey.matches("\\d{18}")) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.mycode.invalid"));
+        }
+        if (createGuard == null) {
+            return payUnderLock(merchantId, normalizeDdId(ddId), oneTimeKey);
+        }
+        String normalized = normalizeDdId(ddId);
+        return createGuard.withLock(normalized,
+                () -> payUnderLock(merchantId, normalized, oneTimeKey));
+    }
+
+    private LinePayOfflineResult payUnderLock(Long merchantId, String ddId, String oneTimeKey) {
+        PosOrder order = requireMerchantOrder(merchantId, ddId, true);
+        PosOrderLinePayment active = paymentService.getActiveByDdId(ddId);
+        if (active != null) {
+            if (!MODE_OFFLINE.equals(active.getPaymentMode())) {
+                throw new ServiceException(MessageUtils.message("line.pay.offline.attempt.blocked"));
+            }
+            return result(active, true);
+        }
+        PosStoreLinePay credential = credentialService.getEnabledCurrent(order.getMdId());
+        if (credential == null) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.not.enabled"));
+        }
+        IntentSelection selection = createIntent(order, credential);
+        PosOrderLinePayment intent = selection.payment();
+        if (!selection.created() || !"REQUESTING".equals(intent.getStatus())) {
+            return result(intent, true);
+        }
+        LinePayCredential gatewayCredential = toCredential(credential);
+        try {
+            LinePayOfflineRequest request = new LinePayOfflineRequest(intent.getLineOrderId(),
+                    ddId, "Order " + ddId, intent.getAmount(), intent.getCurrency(), oneTimeKey);
+            LinePayResponse response = auditService == null
+                    ? linePayClient.payOffline(gatewayCredential, request)
+                    : auditService.executeOffline("APP", intent.getId(), credential.getId(),
+                    intent.getStoreId(), intent.getDdId(), intent.getLineOrderId(),
+                    () -> linePayClient.payOffline(gatewayCredential, request));
+            return handlePayResponse(intent, response);
+        } catch (Exception unknown) {
+            int changed = paymentService.markRequestUnknown(intent.getId(), intent.getVersion(),
+                    shortly(), unknownDeadline());
+            if (changed != 1) {
+                return result(reload(intent), false);
+            }
+            intent.setStatus("REQUEST_UNKNOWN");
+            intent.setUpdateTime(new Date());
+            return result(intent, false);
+        }
+    }
+
+    public LinePayOfflineResult status(Long merchantId, String ddId) {
+        PosOrder order = requireMerchantOrder(merchantId, normalizeDdId(ddId), false);
+        PosOrderLinePayment selected = paymentService.getByDdId(String.valueOf(order.getDdId())).stream()
+                .filter(payment -> MODE_OFFLINE.equals(payment.getPaymentMode()))
+                .max(Comparator.comparing(PosOrderLinePayment::getCreateTime,
+                                Comparator.nullsFirst(Date::compareTo))
+                        .thenComparing(PosOrderLinePayment::getId,
+                                Comparator.nullsFirst(Long::compareTo)))
+                .orElseThrow(() -> new ServiceException(
+                        MessageUtils.message("line.pay.offline.attempt.not.found")));
+        return result(selected, null);
+    }
+
+    public String reconcilePayment(Long paymentId, String source) {
+        PosOrderLinePayment payment = paymentService.getById(paymentId);
+        if (payment == null || !MODE_OFFLINE.equals(payment.getPaymentMode())) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.attempt.not.found"));
+        }
+        PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
+        if (credential == null) {
+            int changed = paymentService.markManualReview(
+                    payment.getId(), payment.getVersion(), payment.getStatus());
+            return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
+        }
+        boolean expired = payment.getReconcileDeadline() != null
+                && !new Date().before(payment.getReconcileDeadline());
+        try {
+            LinePayCredential gatewayCredential = toCredential(credential);
+            LinePayResponse response = auditService == null
+                    ? linePayClient.checkOffline(gatewayCredential, payment.getLineOrderId())
+                    : auditService.execute("CHECK", source == null ? "TASK" : source,
+                    payment.getId(), null, credential.getId(), payment.getStoreId(),
+                    payment.getDdId(), payment.getLineOrderId(), payment.getTransactionId(),
+                    () -> linePayClient.checkOffline(gatewayCredential, payment.getLineOrderId()));
+            return handleCheckResponse(payment, response, expired);
+        } catch (Exception unknown) {
+            if (expired) {
+                int changed = paymentService.markManualReview(
+                        payment.getId(), payment.getVersion(), payment.getStatus());
+                return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
+            }
+            int changed = paymentService.rescheduleOffline(
+                    payment.getId(), payment.getVersion(), payment.getStatus(),
+                    shortly(), payment.getReconcileDeadline() == null
+                            ? unknownDeadline() : payment.getReconcileDeadline());
+            return changed == 1 ? payment.getStatus() : reload(payment).getStatus();
+        }
+    }
+
+    private LinePayOfflineResult handlePayResponse(PosOrderLinePayment payment,
+                                                    LinePayResponse response) {
+        if (response != null && AUTH_REQUIRED_CODES.contains(response.returnCode())) {
+            int changed = paymentService.markOfflineWaitingAuth(payment.getId(), payment.getVersion(),
+                    response.transactionId(), shortly(), waitingAuthDeadline(payment));
+            if (changed != 1) {
+                return result(reload(payment), false);
+            }
+            payment.setTransactionId(response.transactionId());
+            payment.setStatus("WAITING_AUTH");
+            payment.setUpdateTime(new Date());
+            return result(payment, false);
+        }
+        if (response != null && response.isSuccess()) {
+            return applyCompletedFact(payment, response);
+        }
+        if (isDefinitePayFailure(response)) {
+            int changed = paymentService.markTerminal(payment.getId(), payment.getVersion(),
+                    payment.getStatus(), "FAILED");
+            if (changed != 1) {
+                return result(reload(payment), false);
+            }
+            payment.setStatus("FAILED");
+            payment.setActiveDdId(null);
+            payment.setUpdateTime(new Date());
+            return result(payment, false);
+        }
+        int changed = paymentService.markRequestUnknown(payment.getId(), payment.getVersion(),
+                shortly(), unknownDeadline());
+        if (changed != 1) {
+            return result(reload(payment), false);
+        }
+        payment.setStatus("REQUEST_UNKNOWN");
+        payment.setUpdateTime(new Date());
+        return result(payment, false);
+    }
+
+    private String handleCheckResponse(PosOrderLinePayment payment, LinePayResponse response,
+                                       boolean expired) {
+        if (response == null || !response.isSuccess()) {
+            return rescheduleOrReview(payment, expired);
+        }
+        JSONObject info = response.info();
+        String gatewayStatus = info == null ? null : info.getString("status");
+        if (gatewayStatus == null && info != null) {
+            gatewayStatus = info.getString("paymentStatus");
+        }
+        if ("AUTH_READY".equals(gatewayStatus) || "WAITING_AUTH".equals(gatewayStatus)) {
+            if (expired) {
+                int changed = paymentService.markManualReview(
+                        payment.getId(), payment.getVersion(), payment.getStatus());
+                return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
+            }
+            int changed = paymentService.markOfflineWaitingAuth(payment.getId(), payment.getVersion(),
+                    response.transactionId(), shortly(), waitingAuthDeadline(payment));
+            return changed == 1 ? "WAITING_AUTH" : reload(payment).getStatus();
+        }
+        if ("COMPLETE".equals(gatewayStatus)) {
+            return applyCompletedFact(payment, response).getStatus();
+        }
+        if ("CANCEL".equals(gatewayStatus)) {
+            int changed = paymentService.markTerminal(payment.getId(), payment.getVersion(),
+                    payment.getStatus(), "CANCELLED_OR_EXPIRED");
+            return changed == 1 ? "CANCELLED_OR_EXPIRED" : reload(payment).getStatus();
+        }
+        if ("FAIL".equals(gatewayStatus)) {
+            int changed = paymentService.markTerminal(payment.getId(), payment.getVersion(),
+                    payment.getStatus(), "FAILED");
+            return changed == 1 ? "FAILED" : reload(payment).getStatus();
+        }
+        return rescheduleOrReview(payment, expired);
+    }
+
+    private LinePayOfflineResult applyCompletedFact(PosOrderLinePayment payment,
+                                                    LinePayResponse response) {
+        JSONObject info = response.info();
+        String transactionId = info == null ? null : info.getString("transactionId");
+        String orderId = info == null ? null : info.getString("orderId");
+        Integer actualAmount = paymentAmount(info);
+        String provider = paymentProvider(info);
+        if (transactionId == null || transactionId.isBlank()) {
+            int changed = paymentService.markManualReview(
+                    payment.getId(), payment.getVersion(), payment.getStatus());
+            if (changed != 1) {
+                return result(reload(payment), false);
+            }
+            payment.setStatus("MANUAL_REVIEW");
+            return result(payment, false);
+        }
+        if (!payment.getLineOrderId().equals(orderId) || actualAmount == null
+                || provider == null || provider.isBlank()) {
+            if (actualAmount == null) {
+                int changed = paymentService.markManualReview(
+                        payment.getId(), payment.getVersion(), payment.getStatus());
+                if (changed != 1) {
+                    return result(reload(payment), false);
+                }
+                payment.setStatus("MANUAL_REVIEW");
+                return result(payment, false);
+            }
+            int changed = paymentService.markOfflineEvidence(payment.getId(), payment.getVersion(),
+                    payment.getStatus(), "MANUAL_REVIEW", transactionId, provider,
+                    actualAmount, new Date());
+            if (changed != 1) {
+                return result(reload(payment), false);
+            }
+            payment.setTransactionId(transactionId);
+            payment.setPaymentProvider(provider);
+            payment.setCapturedAmount(actualAmount);
+            payment.setStatus("MANUAL_REVIEW");
+            return result(payment, false);
+        }
+        if (!payment.getAmount().equals(actualAmount)) {
+            boolean changed = factService.applyAmountMismatchFact(payment.getId(),
+                    payment.getVersion(), payment.getStatus(), transactionId, provider,
+                    actualAmount, new Date());
+            if (!changed) {
+                return result(reload(payment), false);
+            }
+            payment.setTransactionId(transactionId);
+            payment.setPaymentProvider(provider);
+            payment.setCapturedAmount(actualAmount);
+            payment.setStatus("AMOUNT_MISMATCH");
+            return result(payment, false);
+        }
+        factService.applyPaidFact(payment.getId(), transactionId, provider, new Date());
+        payment.setTransactionId(transactionId);
+        payment.setPaymentProvider(provider);
+        payment.setCapturedAmount(payment.getAmount());
+        payment.setStatus("PAID");
+        payment.setUpdateTime(new Date());
+        return result(payment, false);
+    }
+
+    private String rescheduleOrReview(PosOrderLinePayment payment, boolean expired) {
+        if (expired) {
+            int changed = paymentService.markManualReview(
+                    payment.getId(), payment.getVersion(), payment.getStatus());
+            return changed == 1 ? "MANUAL_REVIEW" : reload(payment).getStatus();
+        }
+        int changed = paymentService.rescheduleOffline(
+                payment.getId(), payment.getVersion(), payment.getStatus(),
+                shortly(), payment.getReconcileDeadline() == null
+                        ? unknownDeadline() : payment.getReconcileDeadline());
+        return changed == 1 ? payment.getStatus() : reload(payment).getStatus();
+    }
+
+    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", MODE_OFFLINE, unknownDeadline()), true);
+            } catch (DuplicateKeyException duplicate) {
+                PosOrderLinePayment active = paymentService.getActiveByDdId(
+                        String.valueOf(order.getDdId()));
+                if (active != null) {
+                    if (!MODE_OFFLINE.equals(active.getPaymentMode())) {
+                        throw new ServiceException(
+                                MessageUtils.message("line.pay.offline.attempt.blocked"));
+                    }
+                    return new IntentSelection(active, false);
+                }
+                if (attempt == 2) {
+                    throw duplicate;
+                }
+            }
+        }
+        throw new ServiceException(MessageUtils.message("line.pay.offline.operation.failed"));
+    }
+
+    private PosOrder requireMerchantOrder(Long merchantId, String ddId, boolean requirePayable) {
+        if (merchantId == null) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.merchant.required"));
+        }
+        InfoUser merchant = userService.getOne(new QueryWrapper<InfoUser>()
+                .eq("user_id", merchantId));
+        if (merchant == null || "0".equals(merchant.getUserType())
+                || "2".equals(merchant.getUserType())) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.merchant.required"));
+        }
+        PosOrder order = orderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", ddId));
+        if (order == null || !"MERCHANT".equals(order.getOrderSource())) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.forbidden"));
+        }
+        boolean directMerchant = "1".equals(merchant.getUserType())
+                || "3".equals(merchant.getUserType());
+        PosStore store = storeService.getById(order.getMdId());
+        if (store == null) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.forbidden"));
+        }
+        boolean owned = directMerchant
+                ? merchant.getUserId().equals(order.getShId())
+                && merchant.getUserId().equals(store.getUserId())
+                : merchant.getStoreId() != null && merchant.getStoreId().equals(order.getMdId());
+        if (!owned) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.forbidden"));
+        }
+        String parentDdId = order.getParentDdId() == null
+                ? String.valueOf(order.getDdId()) : order.getParentDdId();
+        if (orderService.count(new QueryWrapper<PosOrder>()
+                .eq("parent_dd_id", parentDdId)) > 1) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.single.store.required"));
+        }
+        if (requirePayable && (!LinePayService.PAY_TYPE_LINE.equals(order.getPayType())
+                || order.getPayStatus() != null && order.getPayStatus() != 0L
+                || order.getState() == null || order.getState() == 3L || order.getState() == 4L
+                || order.getAmount() == null || order.getAmount() <= 0 || order.getMdId() == null
+                || order.getAfterSaleStatus() != null && order.getAfterSaleStatus() != 0L)) {
+            throw new ServiceException(MessageUtils.message("line.pay.offline.order.invalid"));
+        }
+        return order;
+    }
+
+    private static Integer paymentAmount(JSONObject info) {
+        if (info == null) {
+            return null;
+        }
+        JSONArray payInfo = info.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 paymentProvider(JSONObject info) {
+        if (info == null) {
+            return null;
+        }
+        String provider = info.getString("paymentProvider");
+        if (provider != null && !provider.isBlank()) {
+            return provider;
+        }
+        JSONArray payInfo = info.getJSONArray("payInfo");
+        if (payInfo == null) {
+            return null;
+        }
+        for (int i = 0; i < payInfo.size(); i++) {
+            JSONObject item = payInfo.getJSONObject(i);
+            String itemProvider = item == null ? null : item.getString("paymentProvider");
+            if (itemProvider != null && !itemProvider.isBlank()) {
+                return itemProvider;
+            }
+        }
+        return null;
+    }
+
+    private static boolean isDefinitePayFailure(LinePayResponse response) {
+        if (response == null || response.httpStatus() < 200 || response.httpStatus() >= 300
+                || response.returnCode() == null || response.isSuccess()
+                || AUTH_REQUIRED_CODES.contains(response.returnCode())
+                || UNCERTAIN_CODES.contains(response.returnCode())
+                || response.returnCode().startsWith("190")) {
+            return false;
+        }
+        return DEFINITE_PAY_FAILURE_CODES.contains(response.returnCode());
+    }
+
+    private static LinePayCredential toCredential(PosStoreLinePay credential) {
+        return new LinePayCredential(credential.getId(), credential.getChannelId(),
+                credential.getChannelSecret());
+    }
+
+    private static String normalizeDdId(String ddId) {
+        if (ddId == null || ddId.trim().isEmpty()) {
+            throw new ServiceException(MessageUtils.message("line.pay.order.required"));
+        }
+        return ddId.trim();
+    }
+
+    private static String newLineOrderId() {
+        return "LPOFF" + UUID.randomUUID().toString().replace("-", "")
+                .toUpperCase(Locale.ROOT);
+    }
+
+    private static Date shortly() {
+        return new Date(System.currentTimeMillis() + 30_000L);
+    }
+
+    private Date authDeadline() {
+        return new Date(System.currentTimeMillis() + authDeadlineMinutes * 60_000L);
+    }
+
+    private Date unknownDeadline() {
+        return new Date(System.currentTimeMillis() + unknownDeadlineHours * 3_600_000L);
+    }
+
+    private Date waitingAuthDeadline(PosOrderLinePayment payment) {
+        return "WAITING_AUTH".equals(payment.getStatus())
+                && payment.getReconcileDeadline() != null
+                ? payment.getReconcileDeadline() : authDeadline();
+    }
+
+    private PosOrderLinePayment reload(PosOrderLinePayment fallback) {
+        PosOrderLinePayment current = paymentService.getById(fallback.getId());
+        return current == null ? fallback : current;
+    }
+
+    private static LinePayOfflineResult result(PosOrderLinePayment payment, Boolean reused) {
+        LinePayOfflineResult result = new LinePayOfflineResult();
+        result.setDdId(payment.getDdId());
+        result.setPaymentId(payment.getId());
+        result.setLineOrderId(payment.getLineOrderId());
+        result.setTransactionId(payment.getTransactionId());
+        result.setStatus(publicStatus(payment.getStatus()));
+        result.setReusedAttempt(reused);
+        result.setUpdatedAt(payment.getUpdateTime());
+        return result;
+    }
+
+    private static String publicStatus(String status) {
+        if ("WAITING_AUTH".equals(status)) {
+            return "AUTH_REQUIRED";
+        }
+        if ("REQUESTING".equals(status) || "REQUEST_UNKNOWN".equals(status)) {
+            return "PROCESSING";
+        }
+        if ("CANCELLED_OR_EXPIRED".equals(status)) {
+            return "CANCELLED";
+        }
+        if ("AMOUNT_MISMATCH".equals(status) || "MANUAL_REVIEW".equals(status)) {
+            return "MANUAL_REVIEW";
+        }
+        return status;
+    }
+
+    private record IntentSelection(PosOrderLinePayment payment, boolean created) {
+    }
+}

+ 20 - 9
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayRefundService.java

@@ -65,7 +65,8 @@ public class LinePayRefundService {
 
     public String requestFullRefund(Long paymentId, String source) {
         PosOrderLinePayment payment = paymentService.getById(paymentId);
-        if (payment == null || !"PAID".equals(payment.getStatus())) {
+        if (payment == null || !("PAID".equals(payment.getStatus())
+                || "AMOUNT_MISMATCH".equals(payment.getStatus()))) {
             throw new ServiceException("Only a captured LINE Pay payment can be refunded");
         }
         PosOrderLineRefund refund = refundService.createIfAbsent(payment, source);
@@ -134,8 +135,11 @@ public class LinePayRefundService {
                 return "UNKNOWN";
             }
             LinePayResponse response = gatewayCall("REFUND", source, payment, refund,
-                    actionCredential.id(),
-                    () -> linePayClient.refundFull(actionCredential, payment.getTransactionId()));
+                    actionCredential.id(), () -> "OFFLINE".equals(payment.getPaymentMode())
+                            ? linePayClient.refundOfflineFull(actionCredential,
+                            payment.getLineOrderId())
+                            : linePayClient.refundFull(actionCredential,
+                            payment.getTransactionId()));
             String refundTransactionId = response.info() == null
                     ? null : response.info().getString("refundTransactionId");
             if (response.isSuccess() && refundTransactionId != null
@@ -173,7 +177,8 @@ public class LinePayRefundService {
     /** 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())) {
+        if (payment == null || !("PAID".equals(payment.getStatus())
+                || "AMOUNT_MISMATCH".equals(payment.getStatus()))) {
             throw new ServiceException("Only a captured LINE Pay payment can be refunded");
         }
         return refundService.createIfAbsent(payment, source).getStatus();
@@ -237,7 +242,7 @@ public class LinePayRefundService {
         LinePayResponse retrieve = gatewayCall("RETRIEVE", source, payment, refund,
                 credential.id(),
                 () -> linePayClient.retrieveByTransactionId(credential, payment.getTransactionId()));
-        RefundEvidence evidence = refundEvidence(retrieve, payment);
+        RefundEvidence evidence = refundEvidence(retrieve, payment, refund);
         if (!isCredentialAuthFailure(retrieve)) {
             return new RefundEvidenceResult(evidence, credential);
         }
@@ -254,7 +259,7 @@ public class LinePayRefundService {
                 currentCredential.id(),
                 () -> linePayClient.retrieveByTransactionId(
                         currentCredential, payment.getTransactionId()));
-        RefundEvidence currentEvidence = refundEvidence(proof, payment);
+        RefundEvidence currentEvidence = refundEvidence(proof, payment, refund);
         return currentEvidence.conclusive()
                 ? new RefundEvidenceResult(currentEvidence, currentCredential)
                 : new RefundEvidenceResult(evidence, credential);
@@ -264,10 +269,16 @@ public class LinePayRefundService {
         return response != null && CREDENTIAL_AUTH_FAILURE_CODES.contains(response.returnCode());
     }
 
-    private static RefundEvidence refundEvidence(LinePayResponse response, PosOrderLinePayment expected) {
+    private static RefundEvidence refundEvidence(LinePayResponse response,
+                                                 PosOrderLinePayment expected,
+                                                 PosOrderLineRefund refund) {
         if (response == null || !response.isSuccess() || response.rawBody() == null) {
             return RefundEvidence.INCONCLUSIVE;
         }
+        Integer expectedCapturedAmount = refund == null ? null : refund.getAmount();
+        if (expectedCapturedAmount == null || expectedCapturedAmount <= 0) {
+            return RefundEvidence.INCONCLUSIVE;
+        }
         JSONArray info = JSONObject.parseObject(response.rawBody()).getJSONArray("info");
         if (info == null || info.size() != 1) {
             return RefundEvidence.INCONCLUSIVE;
@@ -277,7 +288,7 @@ public class LinePayRefundService {
                 || !expected.getLineOrderId().equals(payment.getString("orderId"))
                 || !"PAYMENT".equals(payment.getString("transactionType"))
                 || !expected.getCurrency().equals(payment.getString("currency"))
-                || !expected.getAmount().equals(paymentAmount(payment))) {
+                || !expectedCapturedAmount.equals(paymentAmount(payment))) {
             return RefundEvidence.INCONCLUSIVE;
         }
         JSONArray refunds = payment.getJSONArray("refundList");
@@ -300,7 +311,7 @@ public class LinePayRefundService {
             lastRefundTransactionId = refundTransactionId;
         }
         return new RefundEvidence(true,
-                refundedAmount == expected.getAmount() ? lastRefundTransactionId : null, false);
+                refundedAmount == expectedCapturedAmount ? lastRefundTransactionId : null, false);
     }
 
     private static Integer paymentAmount(JSONObject transaction) {

+ 9 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java

@@ -54,6 +54,7 @@ public class LinePayService {
     private final LinePayFactService factService;
     private final LinePayGatewayAuditService auditService;
     private final PaymentCreateGuardService createGuard;
+    private final LinePayOfflineService offlineService;
 
     public LinePayService(IPosOrderService orderService,
                           IPosStoreLinePayService credentialService,
@@ -62,7 +63,7 @@ public class LinePayService {
                           LinePayClient linePayClient,
                           LinePayFactService factService) {
         this(orderService, credentialService, paymentService, refundService,
-                linePayClient, factService, null, null);
+                linePayClient, factService, null, null, null);
     }
 
     @Autowired
@@ -73,7 +74,8 @@ public class LinePayService {
                           LinePayClient linePayClient,
                           LinePayFactService factService,
                           LinePayGatewayAuditService auditService,
-                          PaymentCreateGuardService createGuard) {
+                          PaymentCreateGuardService createGuard,
+                          LinePayOfflineService offlineService) {
         this.orderService = orderService;
         this.credentialService = credentialService;
         this.paymentService = paymentService;
@@ -82,6 +84,7 @@ public class LinePayService {
         this.factService = factService;
         this.auditService = auditService;
         this.createGuard = createGuard;
+        this.offlineService = offlineService;
     }
 
     public LinePayCreateResult create(Long userId, String ddId) {
@@ -195,6 +198,9 @@ public class LinePayService {
         if (payment == null) {
             throw new ServiceException("LINE Pay payment not found");
         }
+        if ("OFFLINE".equals(payment.getPaymentMode()) && offlineService != null) {
+            return offlineService.reconcilePayment(paymentId, source);
+        }
         if (payment.getReconcileDeadline() != null
                 && !new Date().before(payment.getReconcileDeadline())) {
             return finalDeadlineReconcile(payment, source);
@@ -494,7 +500,7 @@ public class LinePayService {
             try {
                 return new IntentSelection(paymentService.createRequesting(
                         String.valueOf(order.getDdId()), newLineOrderId(), credential.getId(),
-                        order.getMdId(), order.getAmount(), "TWD", unknownDeadline()), true);
+                        order.getMdId(), order.getAmount(), "TWD", "ONLINE", unknownDeadline()), true);
             } catch (DuplicateKeyException duplicate) {
                 PosOrderLinePayment concurrent = paymentService.getActiveByDdId(String.valueOf(order.getDdId()));
                 if (concurrent != null) {

+ 11 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflinePayRequest.java

@@ -0,0 +1,11 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+import lombok.ToString;
+
+@Data
+public class LinePayOfflinePayRequest {
+    private String ddId;
+    @ToString.Exclude
+    private String oneTimeKey;
+}

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflineResult.java

@@ -0,0 +1,16 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+public class LinePayOfflineResult {
+    private String ddId;
+    private Long paymentId;
+    private String lineOrderId;
+    private String transactionId;
+    private String status;
+    private Boolean reusedAttempt;
+    private Date updatedAt;
+}

+ 20 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java

@@ -3,6 +3,7 @@ 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.app.pay.LinePayOfflineService;
 import com.ruoyi.system.domain.PosOrderLinePayment;
 import com.ruoyi.system.domain.PosOrderLineRefund;
 import com.ruoyi.system.service.IPosOrderLinePaymentService;
@@ -34,6 +35,7 @@ public class LinePayReconcileTask {
     private final IPosOrderLinePaymentService paymentService;
     private final IPosOrderLineRefundService refundService;
     private final LinePayService linePayService;
+    private final LinePayOfflineService linePayOfflineService;
     private final LinePayRefundService linePayRefundService;
     private final RedissonClient redissonClient;
     private final LinePayCancellationCompensationService cancellationCompensationService;
@@ -44,9 +46,22 @@ public class LinePayReconcileTask {
                                 LinePayRefundService linePayRefundService,
                                 RedissonClient redissonClient,
                                 LinePayCancellationCompensationService cancellationCompensationService) {
+        this(paymentService, refundService, linePayService, null, linePayRefundService,
+                redissonClient, cancellationCompensationService);
+    }
+
+    @org.springframework.beans.factory.annotation.Autowired
+    public LinePayReconcileTask(IPosOrderLinePaymentService paymentService,
+                                IPosOrderLineRefundService refundService,
+                                LinePayService linePayService,
+                                LinePayOfflineService linePayOfflineService,
+                                LinePayRefundService linePayRefundService,
+                                RedissonClient redissonClient,
+                                LinePayCancellationCompensationService cancellationCompensationService) {
         this.paymentService = paymentService;
         this.refundService = refundService;
         this.linePayService = linePayService;
+        this.linePayOfflineService = linePayOfflineService;
         this.linePayRefundService = linePayRefundService;
         this.redissonClient = redissonClient;
         this.cancellationCompensationService = cancellationCompensationService;
@@ -84,7 +99,11 @@ public class LinePayReconcileTask {
                 continue;
             }
             try {
-                linePayService.reconcilePayment(payment.getId(), "TASK", owner);
+                if ("OFFLINE".equals(payment.getPaymentMode()) && linePayOfflineService != null) {
+                    linePayOfflineService.reconcilePayment(payment.getId(), "TASK");
+                } else {
+                    linePayService.reconcilePayment(payment.getId(), "TASK", owner);
+                }
             } catch (Exception exception) {
                 log.warn("LINE Pay payment reconcile failed, paymentId={}", payment.getId());
             }

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

@@ -19,6 +19,7 @@ public class LinePayClient {
 
     static final String REQUEST_URI = "/v4/payments/request";
     static final String RETRIEVE_URI = "/v4/payments";
+    static final String OFFLINE_PAY_URI = "/v4/payments/oneTimeKeys/pay";
 
     private final LinePayProperties properties;
     private final LinePaySigner signer;
@@ -105,6 +106,39 @@ public class LinePayClient {
                 null, "{}", credential, 20_000);
     }
 
+    public LinePayResponse payOffline(LinePayCredential credential, LinePayOfflineRequest request)
+            throws Exception {
+        validateOfflineRequest(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 body = new JSONObject();
+        body.put("amount", request.amount());
+        body.put("currency", request.currency());
+        body.put("orderId", request.lineOrderId());
+        body.put("oneTimeKey", request.oneTimeKey());
+        body.put("packages", new JSONArray().fluentAdd(packageValue));
+        return execute("POST", OFFLINE_PAY_URI, null, body.toJSONString(), credential, 40_000);
+    }
+
+    public LinePayResponse checkOffline(LinePayCredential credential, String lineOrderId)
+            throws Exception {
+        String uri = "/v4/payments/orders/" + pathSegment(lineOrderId) + "/check";
+        return execute("GET", uri, null, "", credential, 20_000);
+    }
+
+    public LinePayResponse refundOfflineFull(LinePayCredential credential, String lineOrderId)
+            throws Exception {
+        String uri = "/v4/payments/orders/" + pathSegment(lineOrderId) + "/refund";
+        return execute("POST", uri, null, "{}", credential, 20_000);
+    }
+
     private LinePayResponse execute(String method, String uri, String query, String body,
                                     LinePayCredential credential, int readTimeoutMs) throws Exception {
         validateCredential(credential);
@@ -161,6 +195,19 @@ public class LinePayClient {
         require(request.currency(), "currency");
     }
 
+    private static void validateOfflineRequest(LinePayOfflineRequest request) {
+        if (request == null || request.amount() <= 0) {
+            throw new IllegalArgumentException("valid offline request is required");
+        }
+        require(request.lineOrderId(), "lineOrderId");
+        require(request.ddId(), "ddId");
+        require(request.productName(), "productName");
+        require(request.currency(), "currency");
+        if (request.oneTimeKey() == null || !request.oneTimeKey().matches("\\d{18}")) {
+            throw new IllegalArgumentException("oneTimeKey must contain 18 digits");
+        }
+    }
+
     private static String pathSegment(String value) {
         return URLEncoder.encode(require(value, "path segment"), StandardCharsets.UTF_8)
                 .replace("+", "%20");

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

@@ -0,0 +1,12 @@
+package com.ruoyi.app.utils.linepay;
+
+/** Server-built LINE Pay Offline My Code payment request. */
+public record LinePayOfflineRequest(String lineOrderId, String ddId, String productName,
+                                    int amount, String currency, String oneTimeKey) {
+    @Override
+    public String toString() {
+        return "LinePayOfflineRequest[lineOrderId=" + lineOrderId + ", ddId=" + ddId
+                + ", productName=" + productName + ", amount=" + amount
+                + ", currency=" + currency + ", oneTimeKey=<redacted>]";
+    }
+}

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

@@ -216,6 +216,15 @@ 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 订单请使用专用订单操作接口
+line.pay.offline.merchant.required=当前账号不是有效商家账号
+line.pay.offline.single.store.required=商家扫码订单只能包含一个门店
+line.pay.offline.order.invalid=当前订单不能使用 LINE Pay 扫码付款
+line.pay.offline.order.forbidden=订单不存在或不属于当前商家
+line.pay.offline.mycode.invalid=LINE Pay My Code 必须为 18 位数字
+line.pay.offline.attempt.blocked=当前订单已有待处理的支付,请先查询支付状态
+line.pay.offline.not.enabled=该门店暂未启用 LINE Pay 扫码付款
+line.pay.offline.attempt.not.found=当前订单没有 LINE Pay 扫码支付记录
+line.pay.offline.operation.failed=LINE Pay 扫码支付未完成,请稍后查询状态
 rider.operation.role.required=只有骑手可以操作配送订单
 
 omg.payment.ddid.required=OMG 支付订单号不能为空

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

@@ -220,6 +220,15 @@ 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
+line.pay.offline.merchant.required=The current account is not an active merchant account
+line.pay.offline.single.store.required=A merchant-scan order must contain exactly one store
+line.pay.offline.order.invalid=This order cannot be paid with LINE Pay merchant scan
+line.pay.offline.order.forbidden=The order does not exist or does not belong to this merchant
+line.pay.offline.mycode.invalid=The LINE Pay My Code must contain exactly 18 digits
+line.pay.offline.attempt.blocked=A payment is already being processed for this order; check its status first
+line.pay.offline.not.enabled=LINE Pay merchant scan is not enabled for this store
+line.pay.offline.attempt.not.found=No LINE Pay merchant-scan payment exists for this order
+line.pay.offline.operation.failed=The LINE Pay merchant-scan payment was not completed; check its status later
 rider.operation.role.required=Only riders can operate delivery orders
 omg.payment.ddid.required=OMG payment order number is required
 omg.payment.merchantTradeNo.required=OMG MerchantTradeNo is required

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

@@ -220,6 +220,15 @@ line.pay.refund.payment.not.found=Không có giao dịch LINE Pay nào có thể
 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
+line.pay.offline.merchant.required=Tài khoản hiện tại không phải là tài khoản người bán hợp lệ
+line.pay.offline.single.store.required=Đơn quét mã của người bán chỉ được chứa đúng một cửa hàng
+line.pay.offline.order.invalid=Đơn hàng này không thể thanh toán bằng cách quét LINE Pay tại cửa hàng
+line.pay.offline.order.forbidden=Đơn hàng không tồn tại hoặc không thuộc người bán hiện tại
+line.pay.offline.mycode.invalid=LINE Pay My Code phải gồm đúng 18 chữ số
+line.pay.offline.attempt.blocked=Đơn hàng đang có giao dịch chờ xử lý; hãy kiểm tra trạng thái trước
+line.pay.offline.not.enabled=Cửa hàng này chưa bật thanh toán quét mã LINE Pay
+line.pay.offline.attempt.not.found=Đơn hàng này chưa có giao dịch quét mã LINE Pay
+line.pay.offline.operation.failed=Thanh toán quét mã LINE Pay chưa hoàn tất; vui lòng kiểm tra lại trạng thái sau
 rider.operation.role.required=Chỉ tài xế giao hàng mới có thể thao tác đơn giao hàng
 omg.payment.ddid.required=Mã đơn hàng thanh toán OMG là bắt buộc
 omg.payment.merchantTradeNo.required=OMG MerchantTradeNo là bắt buộc

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

@@ -220,6 +220,15 @@ 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 订单请使用专用订单操作接口
+line.pay.offline.merchant.required=当前账号不是有效商家账号
+line.pay.offline.single.store.required=商家扫码订单只能包含一个门店
+line.pay.offline.order.invalid=当前订单不能使用 LINE Pay 扫码付款
+line.pay.offline.order.forbidden=订单不存在或不属于当前商家
+line.pay.offline.mycode.invalid=LINE Pay My Code 必须为 18 位数字
+line.pay.offline.attempt.blocked=当前订单已有待处理的支付,请先查询支付状态
+line.pay.offline.not.enabled=该门店暂未启用 LINE Pay 扫码付款
+line.pay.offline.attempt.not.found=当前订单没有 LINE Pay 扫码支付记录
+line.pay.offline.operation.failed=LINE Pay 扫码支付未完成,请稍后查询状态
 rider.operation.role.required=只有骑手可以操作配送订单
 
 omg.payment.ddid.required=OMG 支付订单号不能为空

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

@@ -220,6 +220,15 @@ 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 訂單請使用專用訂單操作介面
+line.pay.offline.merchant.required=目前帳號不是有效商家帳號
+line.pay.offline.single.store.required=商家掃碼訂單只能包含一個門店
+line.pay.offline.order.invalid=目前訂單不能使用 LINE Pay 掃碼付款
+line.pay.offline.order.forbidden=訂單不存在或不屬於目前商家
+line.pay.offline.mycode.invalid=LINE Pay My Code 必須為 18 位數字
+line.pay.offline.attempt.blocked=目前訂單已有待處理的付款,請先查詢付款狀態
+line.pay.offline.not.enabled=此門店尚未啟用 LINE Pay 掃碼付款
+line.pay.offline.attempt.not.found=目前訂單沒有 LINE Pay 掃碼付款記錄
+line.pay.offline.operation.failed=LINE Pay 掃碼付款未完成,請稍後查詢狀態
 rider.operation.role.required=只有騎手可以操作配送訂單
 
 omg.payment.ddid.required=OMG 支付訂單號不能為空

+ 165 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java

@@ -0,0 +1,165 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.OrderParent;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IOperatingHoursService;
+import com.ruoyi.system.service.IOrderParentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.utils.JwtUtil;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PosOrderShOprateControllerTest {
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setUp() {
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterEach
+    void tearDown() {
+        messages.close();
+    }
+
+    @Test
+    void ordinaryAndNightMarketMerchantMustUseOwnShId() {
+        PosOrderShOprateController controller = controllerWithStoreOwner(101L);
+        assertDoesNotThrow(() -> controller.validateMerchantOrderInput(user(101L, "1", null),
+                input(item(101L, 9L))));
+        assertDoesNotThrow(() -> controller.validateMerchantOrderInput(user(101L, "3", null),
+                input(item(101L, 9L))));
+        assertThrows(ServiceException.class, () -> controller.validateMerchantOrderInput(
+                user(101L, "1", null), input(item(999L, 9L))));
+    }
+
+    @Test
+    void stallMerchantMustUseAssociatedStoreAndRequestMustBeSingleStore() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        assertDoesNotThrow(() -> controller.validateMerchantOrderInput(user(101L, "4", 9L),
+                input(item(500L, 9L))));
+        assertThrows(ServiceException.class, () -> controller.validateMerchantOrderInput(
+                user(101L, "4", 9L), input(item(500L, 8L))));
+
+        OrderCreateInput multi = input(item(101L, 9L));
+        multi.setItems(List.of(item(101L, 9L), item(101L, 10L)));
+        assertThrows(ServiceException.class,
+                () -> controllerWithStoreOwner(101L).validateMerchantOrderInput(
+                        user(101L, "1", null), multi));
+    }
+
+    @Test
+    void directMerchantCannotPairOwnShIdWithAnotherOwnersStore() {
+        PosOrderShOprateController controller = controllerWithStoreOwner(999L);
+
+        assertThrows(ServiceException.class, () -> controller.validateMerchantOrderInput(
+                user(101L, "1", null), input(item(101L, 9L))));
+    }
+
+    @Test
+    void usersAndRidersCannotCreateMerchantOrders() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        assertThrows(ServiceException.class, () -> controller.validateMerchantOrderInput(
+                user(101L, "0", null), input(item(101L, 9L))));
+        assertThrows(ServiceException.class, () -> controller.validateMerchantOrderInput(
+                user(101L, "2", null), input(item(101L, 9L))));
+    }
+
+    @Test
+    void createOrderPersistsMerchantSourceBeforeReturning() {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IInfoUserService users = mock(IInfoUserService.class);
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        IOperatingHoursService hours = mock(IOperatingHoursService.class);
+        IOrderParentService parents = mock(IOrderParentService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "operatingHoursService", hours);
+        ReflectionTestUtils.setField(controller, "orderParentService", parents);
+        when(users.getOne(any())).thenReturn(user(101L, "1", null));
+        PosStore store = new PosStore();
+        store.setId(9);
+        store.setUserId(101L);
+        when(stores.getById(9L)).thenReturn(store);
+        when(stores.list(org.mockito.ArgumentMatchers
+                .<com.baomidou.mybatisplus.core.conditions.Wrapper<PosStore>>any()))
+                .thenReturn(List.of(store));
+        when(hours.list(org.mockito.ArgumentMatchers
+                .<com.baomidou.mybatisplus.core.conditions.Wrapper<com.ruoyi.system.domain.OperatingHours>>any()))
+                .thenReturn(List.of());
+        when(orders.save(any())).thenReturn(true);
+        when(parents.save(any())).thenReturn(true);
+        OrderParent parent = new OrderParent();
+        parent.setDdId("DD-100");
+        when(parents.getOne(any())).thenReturn(parent);
+
+        controller.createOrder(JwtUtil.token("101", "merchant"), input(item(101L, 9L)));
+
+        ArgumentCaptor<PosOrder> captor = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders).save(captor.capture());
+        org.junit.jupiter.api.Assertions.assertEquals("MERCHANT",
+                captor.getValue().getOrderSource());
+    }
+
+    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;
+    }
+
+    private static OrderCreateInput input(OrderCreatItem item) {
+        OrderCreateInput input = new OrderCreateInput();
+        input.setDdId("DD-100");
+        input.setPaymentMethod("3");
+        input.setItems(List.of(item));
+        return input;
+    }
+
+    private static OrderCreatItem item(Long shId, Long mdId) {
+        OrderCreatItem item = new OrderCreatItem();
+        item.setShId(shId);
+        item.setMdId(mdId);
+        item.setAmount(280);
+        return item;
+    }
+
+    private static PosOrderShOprateController controllerWithStoreOwner(Long ownerId) {
+        PosOrderShOprateController controller = new PosOrderShOprateController();
+        IPosStoreService stores = mock(IPosStoreService.class);
+        PosStore store = new PosStore();
+        store.setId(9);
+        store.setUserId(ownerId);
+        when(stores.getById(9L)).thenReturn(store);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        return controller;
+    }
+}

+ 38 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java

@@ -25,11 +25,13 @@ import static org.mockito.Mockito.times;
 class LinePayCancellationRaceTest {
 
     @Test
-    void cancellationFirstThenLateCaptureCreatesOneDurableRefundIntent() {
+    void offlineCancellationFirstThenLateCaptureCreatesOneDurableRefundIntent() {
         IPosOrderService orders = mock(IPosOrderService.class);
         IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
         IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
         PosOrderLinePayment payment = payment("CONFIRM_UNKNOWN");
+        payment.setPaymentMode("OFFLINE");
+        payment.setLineOrderId("LPOFF-1");
         when(payments.getById(70L)).thenReturn(payment);
         when(payments.markPaid(eq(70L), eq(2L), eq("CONFIRM_UNKNOWN"), eq("TX-1"),
                 any(), any())).thenReturn(1);
@@ -304,6 +306,41 @@ class LinePayCancellationRaceTest {
         verify(lifecycle).finalizeSystemLineRefund(9L);
     }
 
+    @Test
+    void amountMismatchFactCreatesRefundIntentOnlyAfterEvidenceCas() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        PosOrderLinePayment persisted = payment("AMOUNT_MISMATCH");
+        persisted.setCapturedAmount(100);
+        when(payments.markOfflineEvidence(eq(70L), eq(2L), eq("REQUESTING"),
+                eq("AMOUNT_MISMATCH"), eq("TX-1"), eq("TSP"), eq(100), any(Date.class)))
+                .thenReturn(1);
+        when(payments.getById(70L)).thenReturn(persisted);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        assertEquals(true, facts.applyAmountMismatchFact(70L, 2L, "REQUESTING",
+                "TX-1", "TSP", 100, new Date()));
+
+        verify(refunds).createIfAbsent(persisted, "AMOUNT_MISMATCH");
+    }
+
+    @Test
+    void failedAmountMismatchEvidenceCasNeverCreatesRefundIntent() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        when(payments.markOfflineEvidence(eq(70L), eq(2L), eq("REQUESTING"),
+                eq("AMOUNT_MISMATCH"), eq("TX-1"), eq("TSP"), eq(100), any(Date.class)))
+                .thenReturn(0);
+        LinePayFactService facts = new LinePayFactService(orders, payments, refunds);
+
+        assertEquals(false, facts.applyAmountMismatchFact(70L, 2L, "REQUESTING",
+                "TX-1", "TSP", 100, new Date()));
+
+        verify(refunds, never()).createIfAbsent(any(), any());
+    }
+
     private static PosOrderLinePayment payment(String status) {
         PosOrderLinePayment payment = new PosOrderLinePayment();
         payment.setId(70L);

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

@@ -7,6 +7,7 @@ import com.alibaba.fastjson2.JSONObject;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -44,4 +45,29 @@ class LinePayGatewayAuditServiceTest {
         String payload = captor.getAllValues().get(1).getPayload();
         assertEquals("{\"returnCode\":\"0000\",\"info\":{\"paymentUrl\":{}}}", payload);
     }
+
+    @Test
+    void offlineAuditNeverPersistsOneTimeKeyOnSuccessOrFailure() throws Exception {
+        IPaymentGatewayLogService logs = mock(IPaymentGatewayLogService.class);
+        LinePayGatewayAuditService audit = new LinePayGatewayAuditService(logs);
+        String oneTimeKey = "123456789012345678";
+
+        audit.executeOffline("APP", 1L, 5L, 9L, "DD-1", "LPOFF-1",
+                () -> new LinePayResponse(200, "1299", oneTimeKey,
+                        null, null, "{\"returnCode\":\"1299\",\"oneTimeKey\":\""
+                        + oneTimeKey + "\"}"));
+        try {
+            audit.executeOffline("APP", 2L, 5L, 9L, "DD-2", "LPOFF-2",
+                    () -> { throw new IllegalStateException("timeout"); });
+        } catch (IllegalStateException expected) {
+            // expected
+        }
+
+        ArgumentCaptor<PaymentGatewayLog> captor = ArgumentCaptor.forClass(PaymentGatewayLog.class);
+        verify(logs, org.mockito.Mockito.times(4)).append(captor.capture());
+        captor.getAllValues().forEach(log -> {
+            assertFalse(String.valueOf(log.getPayload()).contains(oneTimeKey));
+            assertFalse(String.valueOf(log.getReturnMessage()).contains(oneTimeKey));
+        });
+    }
 }

+ 417 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java

@@ -0,0 +1,417 @@
+package com.ruoyi.app.pay;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.ruoyi.app.pay.dto.LinePayOfflineResult;
+import com.ruoyi.app.utils.linepay.LinePayClient;
+import com.ruoyi.app.utils.linepay.LinePayOfflineRequest;
+import com.ruoyi.app.utils.linepay.LinePayResponse;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.domain.PosStoreLinePay;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.IPosStoreLinePayService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.springframework.dao.DuplicateKeyException;
+
+import java.util.Date;
+
+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.anyString;
+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 LinePayOfflineServiceTest {
+    private IPosOrderService orders;
+    private IInfoUserService users;
+    private IPosStoreService stores;
+    private IPosStoreLinePayService credentials;
+    private IPosOrderLinePaymentService payments;
+    private LinePayClient client;
+    private LinePayFactService facts;
+    private LinePayOfflineService service;
+
+    @BeforeEach
+    void setUp() {
+        orders = mock(IPosOrderService.class);
+        users = mock(IInfoUserService.class);
+        stores = mock(IPosStoreService.class);
+        credentials = mock(IPosStoreLinePayService.class);
+        payments = mock(IPosOrderLinePaymentService.class);
+        client = mock(LinePayClient.class);
+        facts = mock(LinePayFactService.class);
+        service = new LinePayOfflineService(orders, users, stores, credentials, payments,
+                client, facts);
+        when(users.getOne(any(Wrapper.class))).thenReturn(merchant());
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order());
+        when(orders.count(any(Wrapper.class))).thenReturn(1L);
+        when(stores.getById(9L)).thenReturn(store(101L));
+        when(credentials.getEnabledCurrent(9L)).thenReturn(credential());
+        when(credentials.getById(5L)).thenReturn(credential());
+        when(payments.createRequesting(eq("DD-100"), any(), eq(5L), eq(9L), eq(280),
+                eq("TWD"), eq("OFFLINE"), any())).thenReturn(intent());
+    }
+
+    @Test
+    void successfulPayRequiresStrictFactsAndPersistsProvider() throws Exception {
+        JSONObject info = new JSONObject();
+        info.put("orderId", "LPOFF-100");
+        info.put("transactionId", "202608180000000001");
+        info.put("paymentProvider", "TSP");
+        info.put("payInfo", com.alibaba.fastjson2.JSONArray.parseArray("[{\"amount\":280}]"));
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", "202608180000000001",
+                        info, new JSONObject().fluentPut("returnCode", "0000")
+                        .fluentPut("info", info).toJSONString()));
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "123456789012345678");
+
+        assertEquals("PAID", result.getStatus());
+        verify(facts).applyPaidFact(eq(70L), eq("202608180000000001"), eq("TSP"), any(Date.class));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"1145", "1169"})
+    void authenticationRequiredIsDurableAndDoesNotApplyPaidFact(String returnCode) throws Exception {
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(
+                new LinePayResponse(200, returnCode, "Authentication required", null, null,
+                        "{\"returnCode\":\"" + returnCode + "\"}"));
+        when(payments.markOfflineWaitingAuth(eq(70L), eq(0L), any(), any(), any())).thenReturn(1);
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "123456789012345678");
+
+        assertEquals("AUTH_REQUIRED", result.getStatus());
+        ArgumentCaptor<Date> deadline = ArgumentCaptor.forClass(Date.class);
+        verify(payments).markOfflineWaitingAuth(eq(70L), eq(0L), any(), any(), deadline.capture());
+        long remaining = deadline.getValue().getTime() - System.currentTimeMillis();
+        assertTrue(remaining >= 29L * 60_000L && remaining <= 31L * 60_000L);
+        verify(facts, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void activeOfflineAttemptIsReusedWithoutSendingNewMyCode() throws Exception {
+        PosOrderLinePayment active = intent();
+        active.setStatus("REQUEST_UNKNOWN");
+        when(payments.getActiveByDdId("DD-100")).thenReturn(active);
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "999999999999999999");
+
+        assertEquals("PROCESSING", result.getStatus());
+        assertEquals(true, result.getReusedAttempt());
+        verify(client, never()).payOffline(any(), any());
+    }
+
+    @Test
+    void directMerchantCannotUseAnotherOwnersStoreCredential() throws Exception {
+        when(stores.getById(9L)).thenReturn(store(999L));
+
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString()))
+                    .thenAnswer(invocation -> invocation.getArgument(0));
+            org.junit.jupiter.api.Assertions.assertThrows(com.ruoyi.common.exception.ServiceException.class,
+                    () -> service.pay(101L, "DD-100", "123456789012345678"));
+        }
+
+        verify(client, never()).payOffline(any(), any());
+    }
+
+    @Test
+    void duplicateIntentRaceReusesConcurrentAttemptWithoutSendingMyCode() throws Exception {
+        PosOrderLinePayment concurrent = intent();
+        when(payments.getActiveByDdId("DD-100")).thenReturn(null, concurrent);
+        when(payments.createRequesting(eq("DD-100"), any(), eq(5L), eq(9L), eq(280),
+                eq("TWD"), eq("OFFLINE"), any()))
+                .thenThrow(new DuplicateKeyException("active"));
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "123456789012345678");
+
+        assertEquals(true, result.getReusedAttempt());
+        assertEquals("PROCESSING", result.getStatus());
+        verify(client, never()).payOffline(any(), any());
+    }
+
+    @Test
+    void undocumentedPayCodeDefaultsToUnknownAndKeepsActiveAttempt() throws Exception {
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(
+                new LinePayResponse(200, "1299", "New provider code", null, null,
+                        "{\"returnCode\":\"1299\"}"));
+
+        assertEquals("PROCESSING",
+                service.pay(101L, "DD-100", "123456789012345678").getStatus());
+
+        verify(payments).markRequestUnknown(eq(70L), eq(0L), any(), any());
+        verify(payments, never()).markTerminal(any(), any(), any(), any());
+    }
+
+    @Test
+    void amountMismatchCreatesOneRefundIntentAndNeverFulfillsOrder() throws Exception {
+        JSONObject info = completedInfo(100, "TSP");
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(
+                success(info));
+        when(facts.applyAmountMismatchFact(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("202608180000000001"), eq("TSP"), eq(100), any())).thenReturn(true);
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "123456789012345678");
+
+        assertEquals("MANUAL_REVIEW", result.getStatus());
+        verify(facts).applyAmountMismatchFact(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("202608180000000001"), eq("TSP"), eq(100), any());
+        verify(facts, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void failedAmountMismatchCasNeverCreatesRefundFromStaleResponse() throws Exception {
+        JSONObject info = completedInfo(100, "TSP");
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(success(info));
+        when(facts.applyAmountMismatchFact(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("202608180000000001"), eq("TSP"), eq(100), any())).thenReturn(false);
+        PosOrderLinePayment current = intent();
+        current.setStatus("PAID");
+        when(payments.getById(70L)).thenReturn(current);
+
+        assertEquals("PAID",
+                service.pay(101L, "DD-100", "123456789012345678").getStatus());
+
+        verify(facts).applyAmountMismatchFact(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("202608180000000001"), eq("TSP"), eq(100), any());
+    }
+
+    @Test
+    void missingPaymentProviderMovesToManualReviewWithoutRefundOrFulfillment() throws Exception {
+        JSONObject info = completedInfo(280, null);
+        when(client.payOffline(any(), any(LinePayOfflineRequest.class))).thenReturn(success(info));
+        when(payments.markOfflineEvidence(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("MANUAL_REVIEW"), eq("202608180000000001"), eq(null), eq(280), any()))
+                .thenReturn(1);
+
+        LinePayOfflineResult result = service.pay(101L, "DD-100", "123456789012345678");
+
+        assertEquals("MANUAL_REVIEW", result.getStatus());
+        verify(payments).markOfflineEvidence(eq(70L), eq(0L), eq("REQUESTING"),
+                eq("MANUAL_REVIEW"), eq("202608180000000001"), eq(null), eq(280), any());
+        verify(facts, never()).applyAmountMismatchFact(any(), any(), any(), any(), any(), any(), any());
+        verify(facts, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void checkAuthReadyKeepsAttemptBlockingAndReschedules() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("REQUEST_UNKNOWN");
+        when(payments.getById(70L)).thenReturn(payment);
+        JSONObject info = new JSONObject();
+        info.put("status", "AUTH_READY");
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(success(info));
+        when(payments.markOfflineWaitingAuth(eq(70L), eq(0L), any(), any(), any()))
+                .thenReturn(1);
+
+        assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
+
+        verify(payments).markOfflineWaitingAuth(eq(70L), eq(0L), any(), any(), any());
+        verify(facts, never()).applyPaidFact(any(), any(), any(), any());
+    }
+
+    @Test
+    void repeatedAuthReadyKeepsOriginalAuthenticationDeadline() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("WAITING_AUTH");
+        Date originalDeadline = new Date(System.currentTimeMillis() + 10L * 60_000L);
+        payment.setReconcileDeadline(originalDeadline);
+        when(payments.getById(70L)).thenReturn(payment);
+        JSONObject info = new JSONObject();
+        info.put("status", "AUTH_READY");
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(success(info));
+
+        assertEquals("WAITING_AUTH", service.reconcilePayment(70L, "TASK"));
+
+        verify(payments).markOfflineWaitingAuth(eq(70L), eq(0L), any(), any(),
+                eq(originalDeadline));
+    }
+
+    @Test
+    void checkCompleteAppliesStrictPaidFact() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        JSONObject info = completedInfo(280, "EPI");
+        info.put("status", "COMPLETE");
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(success(info));
+
+        assertEquals("PAID", service.reconcilePayment(70L, "TASK"));
+
+        verify(facts).applyPaidFact(eq(70L), eq("202608180000000001"), eq("EPI"), any());
+    }
+
+    @Test
+    void checkCancelAndFailReleaseOnlyTheirOwnAttempt() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        JSONObject cancel = new JSONObject();
+        cancel.put("status", "CANCEL");
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(success(cancel));
+        when(payments.markTerminal(70L, 0L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED"))
+                .thenReturn(1);
+
+        assertEquals("CANCELLED_OR_EXPIRED", service.reconcilePayment(70L, "TASK"));
+        verify(payments).markTerminal(70L, 0L, "WAITING_AUTH", "CANCELLED_OR_EXPIRED");
+    }
+
+    @Test
+    void checkFailReleasesAttemptAsExplicitFailure() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("WAITING_AUTH");
+        when(payments.getById(70L)).thenReturn(payment);
+        JSONObject fail = new JSONObject();
+        fail.put("status", "FAIL");
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(success(fail));
+        when(payments.markTerminal(70L, 0L, "WAITING_AUTH", "FAILED")).thenReturn(1);
+
+        assertEquals("FAILED", service.reconcilePayment(70L, "TASK"));
+        verify(payments).markTerminal(70L, 0L, "WAITING_AUTH", "FAILED");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"1152", "1172", "1198", "1199", "1901", "9000"})
+    void uncertainCheckCodeOnlyReschedulesByLineOrderId(String returnCode) throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("REQUEST_UNKNOWN");
+        payment.setReconcileDeadline(new Date(System.currentTimeMillis() + 60_000L));
+        when(payments.getById(70L)).thenReturn(payment);
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(
+                new LinePayResponse(200, returnCode, "Pending", null, null,
+                        "{\"returnCode\":\"" + returnCode + "\"}"));
+
+        assertEquals("REQUEST_UNKNOWN", service.reconcilePayment(70L, "TASK"));
+
+        verify(payments).rescheduleOffline(eq(70L), eq(0L), eq("REQUEST_UNKNOWN"),
+                any(), eq(payment.getReconcileDeadline()));
+        verify(client, never()).payOffline(any(), any());
+    }
+
+    @Test
+    void undocumentedCheckCodeNeverReleasesActiveAttempt() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("REQUEST_UNKNOWN");
+        payment.setReconcileDeadline(new Date(System.currentTimeMillis() + 60_000L));
+        when(payments.getById(70L)).thenReturn(payment);
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(
+                new LinePayResponse(200, "1299", "New provider code", null, null,
+                        "{\"returnCode\":\"1299\"}"));
+
+        assertEquals("REQUEST_UNKNOWN", service.reconcilePayment(70L, "TASK"));
+
+        verify(payments).rescheduleOffline(eq(70L), eq(0L), eq("REQUEST_UNKNOWN"),
+                any(), eq(payment.getReconcileDeadline()));
+        verify(payments, never()).markTerminal(any(), any(), any(), any());
+    }
+
+    @Test
+    void expiredUnknownGetsOneFinalCheckThenManualReview() throws Exception {
+        PosOrderLinePayment payment = intent();
+        payment.setStatus("REQUEST_UNKNOWN");
+        payment.setReconcileDeadline(new Date(System.currentTimeMillis() - 1_000L));
+        when(payments.getById(70L)).thenReturn(payment);
+        when(client.checkOffline(any(), eq("LPOFF-100"))).thenReturn(
+                new LinePayResponse(200, "9000", "Pending", null, null,
+                        "{\"returnCode\":\"9000\"}"));
+        when(payments.markManualReview(70L, 0L, "REQUEST_UNKNOWN")).thenReturn(1);
+
+        assertEquals("MANUAL_REVIEW", service.reconcilePayment(70L, "TASK"));
+
+        verify(client).checkOffline(any(), eq("LPOFF-100"));
+        verify(payments).markManualReview(70L, 0L, "REQUEST_UNKNOWN");
+        verify(payments, never()).rescheduleOffline(any(), any(), any(), any(), any());
+    }
+
+    private static JSONObject completedInfo(int amount, String provider) {
+        JSONObject info = new JSONObject();
+        info.put("orderId", "LPOFF-100");
+        info.put("transactionId", "202608180000000001");
+        if (provider != null) {
+            info.put("paymentProvider", provider);
+        }
+        info.put("payInfo", com.alibaba.fastjson2.JSONArray.parseArray(
+                "[{\"amount\":" + amount + "}]"));
+        return info;
+    }
+
+    private static LinePayResponse success(JSONObject info) {
+        JSONObject root = new JSONObject();
+        root.put("returnCode", "0000");
+        root.put("info", info);
+        return new LinePayResponse(200, "0000", "Success",
+                info.getString("transactionId"), info, root.toJSONString());
+    }
+
+    private static InfoUser merchant() {
+        InfoUser user = new InfoUser();
+        user.setUserId(101L);
+        user.setUserType("1");
+        return user;
+    }
+
+    private static PosOrder order() {
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-100");
+        order.setParentDdId("DD-100");
+        order.setShId(101L);
+        order.setMdId(9L);
+        order.setAmount(280);
+        order.setState(1L);
+        order.setPayStatus(0L);
+        order.setAfterSaleStatus(0L);
+        order.setPayType("3");
+        order.setOrderSource("MERCHANT");
+        return order;
+    }
+
+    private static PosStoreLinePay credential() {
+        PosStoreLinePay value = new PosStoreLinePay();
+        value.setId(5L);
+        value.setStoreId(9L);
+        value.setChannelId("channel");
+        value.setChannelSecret("secret");
+        return value;
+    }
+
+    private static PosStore store(Long ownerId) {
+        PosStore value = new PosStore();
+        value.setId(9);
+        value.setUserId(ownerId);
+        return value;
+    }
+
+    private static PosOrderLinePayment intent() {
+        PosOrderLinePayment value = new PosOrderLinePayment();
+        value.setId(70L);
+        value.setDdId("DD-100");
+        value.setLineOrderId("LPOFF-100");
+        value.setCredentialId(5L);
+        value.setStoreId(9L);
+        value.setAmount(280);
+        value.setCurrency("TWD");
+        value.setPaymentMode("OFFLINE");
+        value.setStatus("REQUESTING");
+        value.setActiveDdId("DD-100");
+        value.setVersion(0L);
+        value.setUpdateTime(new Date());
+        return value;
+    }
+}

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

@@ -305,6 +305,73 @@ class LinePayRefundTest {
         verify(refundService).markManualReview(88L, 0L, "UNKNOWN");
     }
 
+    @Test
+    void offlineRefundUsesPersistedModeAndPermanentLineOrderId() throws Exception {
+        PosOrderLinePayment payment = payment();
+        payment.setPaymentMode("OFFLINE");
+        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-OFF-1");
+        when(client.refundOfflineFull(any(), eq("LP-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, info, "{}"));
+
+        assertEquals("REFUNDED", service.requestFullRefund(70L, "ADMIN"));
+
+        verify(client).refundOfflineFull(any(), eq("LP-100"));
+        verify(client, never()).refundFull(any(), any());
+    }
+
+    @Test
+    void amountMismatchRefundUsesCapturedAmountAsStrictEvidence() throws Exception {
+        PosOrderLinePayment payment = payment();
+        payment.setStatus("AMOUNT_MISMATCH");
+        payment.setPaymentMode("OFFLINE");
+        payment.setCapturedAmount(100);
+        PosOrderLineRefund refund = refund("CREATED");
+        refund.setAmount(100);
+        when(paymentService.getById(70L)).thenReturn(payment);
+        when(refundService.createIfAbsent(payment, "AMOUNT_MISMATCH")).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(
+                amountMismatchRetrieve("[]"));
+        JSONObject info = new JSONObject();
+        info.put("refundTransactionId", "RF-MISMATCH");
+        when(client.refundOfflineFull(any(), eq("LP-100"))).thenReturn(
+                new LinePayResponse(200, "0000", "Success", null, info, "{}"));
+
+        assertEquals("REFUNDED", service.requestFullRefund(70L, "AMOUNT_MISMATCH"));
+
+        verify(client).refundOfflineFull(any(), eq("LP-100"));
+    }
+
+    @Test
+    void amountMismatchUnknownRecoversFullRefundByCapturedAmount() throws Exception {
+        PosOrderLinePayment payment = payment();
+        payment.setStatus("AMOUNT_MISMATCH");
+        payment.setPaymentMode("OFFLINE");
+        payment.setCapturedAmount(100);
+        PosOrderLineRefund refund = refund("UNKNOWN");
+        refund.setAmount(100);
+        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(
+                amountMismatchRetrieve("[{\"refundTransactionId\":\"RF-MISMATCH\","
+                        + "\"refundAmount\":-100}]"));
+
+        assertEquals("REFUNDED", service.requestFullRefund(70L, "TASK"));
+
+        verify(client, never()).refundOfflineFull(any(), any());
+        verify(factService).applyRefundedFact(eq(88L), eq(70L), eq(0L),
+                eq("RF-MISMATCH"), any());
+    }
+
     private static LinePayResponse noRefundResponse() {
         String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\"," 
                 + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\"," 
@@ -332,9 +399,17 @@ class LinePayRefundTest {
         refund.setPaymentId(70L);
         refund.setStatus(status);
         refund.setVersion(0L);
+        refund.setAmount(280);
         return refund;
     }
 
+    private static LinePayResponse amountMismatchRetrieve(String refundList) {
+        String raw = "{\"returnCode\":\"0000\",\"info\":[{\"transactionId\":\"TX-100\","
+                + "\"orderId\":\"LP-100\",\"transactionType\":\"PAYMENT\",\"currency\":\"TWD\","
+                + "\"payInfo\":[{\"amount\":100}],\"refundList\":" + refundList + "}]}";
+        return new LinePayResponse(200, "0000", "Success", null, null, raw);
+    }
+
     private static PosStoreLinePay credential() {
         PosStoreLinePay credential = new PosStoreLinePay();
         credential.setId(5L);

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

@@ -61,7 +61,7 @@ class LinePayServiceCreateTest {
         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());
+        verify(paymentService, never()).createRequesting(any(), any(), any(), any(), any(), any(), any(), any());
     }
 
     @Test
@@ -73,7 +73,7 @@ class LinePayServiceCreateTest {
         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);
+                eq(280), eq("TWD"), eq("ONLINE"), any())).thenReturn(requesting);
         JSONObject paymentUrl = new JSONObject();
         paymentUrl.put("web", "https://pay.example/web");
         JSONObject info = new JSONObject();
@@ -91,7 +91,7 @@ class LinePayServiceCreateTest {
         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());
+                eq(280), eq("TWD"), eq("ONLINE"), any());
         verify(paymentService).markRequestSucceeded(eq(70L), eq(0L),
                 eq("2026081200000000001"), eq("https://pay.example/web"),
                 any(), any(), any());
@@ -103,7 +103,7 @@ class LinePayServiceCreateTest {
         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"));
+                eq(280), eq("TWD"), eq("ONLINE"), any())).thenReturn(payment("REQUESTING"));
         when(linePayClient.request(any(), any())).thenThrow(new IllegalStateException("timeout"));
         when(paymentService.markRequestUnknown(eq(70L), eq(0L), any(), any())).thenReturn(1);
 
@@ -122,7 +122,7 @@ class LinePayServiceCreateTest {
         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"));
+                eq(280), eq("TWD"), eq("ONLINE"), any())).thenThrow(new DuplicateKeyException("active"));
 
         LinePayCreateResult result = service.create(101L, "DD-100");
 
@@ -137,7 +137,7 @@ class LinePayServiceCreateTest {
         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"));
+                eq(280), eq("TWD"), eq("ONLINE"), 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);
@@ -155,7 +155,7 @@ class LinePayServiceCreateTest {
         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"));
+                eq(280), eq("TWD"), eq("ONLINE"), 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);
@@ -173,7 +173,7 @@ class LinePayServiceCreateTest {
         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"));
+                eq(280), eq("TWD"), eq("ONLINE"), 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);

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

@@ -2,6 +2,7 @@ package com.ruoyi.app.task;
 
 import com.ruoyi.app.pay.LinePayRefundService;
 import com.ruoyi.app.pay.LinePayService;
+import com.ruoyi.app.pay.LinePayOfflineService;
 import com.ruoyi.app.pay.LinePayCancellationCompensationService;
 import com.ruoyi.system.domain.PosOrderLinePayment;
 import com.ruoyi.system.domain.PosOrderLineRefund;
@@ -17,6 +18,7 @@ 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.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -93,6 +95,32 @@ class LinePayReconcileTaskTest {
         verify(lineRefund).requestFullRefund(9L, "TASK");
     }
 
+    @Test
+    void offlineAttemptIsDispatchedToOfflineCheckOnly() {
+        IPosOrderLinePaymentService payments = mock(IPosOrderLinePaymentService.class);
+        IPosOrderLineRefundService refunds = mock(IPosOrderLineRefundService.class);
+        LinePayService online = mock(LinePayService.class);
+        LinePayOfflineService offline = mock(LinePayOfflineService.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 payment = payment(3L);
+        payment.setPaymentMode("OFFLINE");
+        when(payments.scanDue(any(), anyInt())).thenReturn(List.of(payment));
+        when(payments.claimReconcileLease(any(), any(), anyString(), any(), any())).thenReturn(1);
+        when(payments.getCancelledPaidWithoutRefund(anyInt())).thenReturn(List.of());
+        when(refunds.scanDue(any(), anyInt())).thenReturn(List.of());
+
+        new LinePayReconcileTask(payments, refunds, online, offline, lineRefund, redisson,
+                mock(LinePayCancellationCompensationService.class)).reconcile();
+
+        verify(offline).reconcilePayment(eq(3L), eq("TASK"));
+        verify(online, org.mockito.Mockito.never()).reconcilePayment(any(), anyString(), anyString());
+    }
+
     private static PosOrderLinePayment payment(Long id) {
         PosOrderLinePayment payment = new PosOrderLinePayment();
         payment.setId(id);

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

@@ -95,6 +95,71 @@ class LinePayClientTest {
                 + "2026081200000000001/check", transport.url);
     }
 
+    @Test
+    void offlinePayUsesOneTimeKeyContractWithoutOnlineOrDeviceFields() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"1145\",\"returnMessage\":\"Authentication required\"}";
+
+        client.payOffline(credential(), new LinePayOfflineRequest(
+                "LPOFF-001", "DD-001", "Order DD-001", 280, "TWD",
+                "123456789012345678"));
+
+        assertEquals("POST", transport.method);
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments/oneTimeKeys/pay", transport.url);
+        assertEquals(40_000, transport.readTimeoutMs);
+        JSONObject body = JSON.parseObject(transport.body);
+        assertEquals("123456789012345678", body.getString("oneTimeKey"));
+        assertEquals(280, body.getIntValue("amount"));
+        assertFalse(body.containsKey("redirectUrls"));
+        assertFalse(body.containsKey("options"));
+        assertFalse(body.containsKey("capture"));
+        assertFalse(body.containsKey("void"));
+        assertFalse(transport.headers.containsKey("X-LINE-MerchantDeviceProfileId"));
+        assertFalse(transport.headers.containsKey("X-LINE-MerchantDeviceType"));
+    }
+
+    @Test
+    void offlineCheckUsesEncodedPermanentOrderId() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"info\":{\"status\":\"AUTH_READY\"}}";
+
+        client.checkOffline(credential(), "LPOFF 订单-1");
+
+        assertEquals("GET", transport.method);
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments/orders/"
+                + "LPOFF%20%E8%AE%A2%E5%8D%95-1/check", transport.url);
+        assertEquals(20_000, transport.readTimeoutMs);
+    }
+
+    @Test
+    void offlineFullRefundUsesOrderIdAndEmptyJson() throws Exception {
+        transport.responseBody = "{\"returnCode\":\"0000\",\"info\":{\"refundTransactionId\":\"RF-1\"}}";
+
+        client.refundOfflineFull(credential(), "LPOFF-001");
+
+        assertEquals("POST", transport.method);
+        assertEquals("https://sandbox-api-pay.line.me/v4/payments/orders/LPOFF-001/refund", transport.url);
+        assertEquals("{}", transport.body);
+        assertEquals(20_000, transport.readTimeoutMs);
+        assertFalse(transport.body.contains("refundAmount"));
+    }
+
+    @Test
+    void offlinePayRejectsMalformedOneTimeKeyBeforeTransport() {
+        org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+                () -> client.payOffline(credential(), new LinePayOfflineRequest(
+                        "LPOFF-001", "DD-001", "Order DD-001", 280, "TWD", "123")));
+        assertEquals(null, transport.method);
+    }
+
+    @Test
+    void offlineRequestStringRepresentationRedactsMyCode() {
+        LinePayOfflineRequest request = new LinePayOfflineRequest(
+                "LPOFF-001", "DD-001", "Order DD-001", 280, "TWD",
+                "123456789012345678");
+
+        assertFalse(request.toString().contains("123456789012345678"));
+        assertTrue(request.toString().contains("<redacted>"));
+    }
+
     private static LinePayCredential credential() {
         return new LinePayCredential(5L, "1234567890", "test-secret");
     }

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

@@ -276,6 +276,11 @@ public class PosOrder {
      */
     private String payType;
 
+    /**
+     * 订单来源:USER 用户端,MERCHANT 商家端。
+     */
+    private String orderSource;
+
 
     /**
      * 预计送达时间

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

@@ -22,7 +22,9 @@ public class PosOrderLinePayment {
     private Long credentialId;
     private Long storeId;
     private Integer amount;
+    private Integer capturedAmount;
     private String currency;
+    private String paymentMode;
     private String paymentUrlWeb;
     private String paymentUrlApp;
     private String paymentProvider;
@@ -42,4 +44,3 @@ public class PosOrderLinePayment {
     @TableField(fill = FieldFill.INSERT_UPDATE)
     private Date updateTime;
 }
-

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

@@ -29,6 +29,24 @@ public interface PosOrderLinePaymentMapper extends BaseMapper<PosOrderLinePaymen
                            @Param("nextReconcileAt") Date nextReconcileAt,
                            @Param("reconcileDeadline") Date reconcileDeadline,
                            @Param("updateTime") Date updateTime);
+    int markOfflineWaitingAuth(@Param("id") Long id, @Param("version") Long version,
+                               @Param("transactionId") String transactionId,
+                               @Param("nextReconcileAt") Date nextReconcileAt,
+                               @Param("reconcileDeadline") Date reconcileDeadline,
+                               @Param("updateTime") Date updateTime);
+    int markOfflineEvidence(@Param("id") Long id, @Param("version") Long version,
+                            @Param("expectedStatus") String expectedStatus,
+                            @Param("targetStatus") String targetStatus,
+                            @Param("transactionId") String transactionId,
+                            @Param("paymentProvider") String paymentProvider,
+                            @Param("capturedAmount") Integer capturedAmount,
+                            @Param("payTime") Date payTime,
+                            @Param("updateTime") Date updateTime);
+    int rescheduleOffline(@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 claimConfirm(@Param("id") Long id, @Param("version") Long version,
                      @Param("expectedStatus") String expectedStatus,
                      @Param("leaseOwner") String leaseOwner,

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

@@ -9,6 +9,9 @@ public interface IPosOrderLinePaymentService {
     PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
                                          Long storeId, Integer amount, String currency,
                                          Date reconcileDeadline);
+    PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
+                                         Long storeId, Integer amount, String currency,
+                                         String paymentMode, Date reconcileDeadline);
     PosOrderLinePayment getActiveByDdId(String ddId);
     PosOrderLinePayment getByLineOrderId(String lineOrderId);
     PosOrderLinePayment getByTransactionId(String transactionId);
@@ -20,6 +23,13 @@ public interface IPosOrderLinePaymentService {
                              String paymentUrlWeb, String paymentUrlApp,
                              Date nextReconcileAt, Date reconcileDeadline);
     int markRequestUnknown(Long id, Long version, Date nextReconcileAt, Date reconcileDeadline);
+    int markOfflineWaitingAuth(Long id, Long version, String transactionId,
+                               Date nextReconcileAt, Date reconcileDeadline);
+    int markOfflineEvidence(Long id, Long version, String expectedStatus, String targetStatus,
+                            String transactionId, String paymentProvider, Integer capturedAmount,
+                            Date payTime);
+    int rescheduleOffline(Long id, Long version, String expectedStatus,
+                          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);

+ 45 - 1
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java

@@ -27,8 +27,17 @@ public class PosOrderLinePaymentServiceImpl implements IPosOrderLinePaymentServi
     public PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
                                                 Long storeId, Integer amount, String currency,
                                                 Date reconcileDeadline) {
+        return createRequesting(ddId, lineOrderId, credentialId, storeId, amount, currency,
+                "ONLINE", reconcileDeadline);
+    }
+
+    @Override
+    public PosOrderLinePayment createRequesting(String ddId, String lineOrderId, Long credentialId,
+                                                Long storeId, Integer amount, String currency,
+                                                String paymentMode, Date reconcileDeadline) {
         if (StrUtil.isBlank(ddId) || StrUtil.isBlank(lineOrderId) || credentialId == null
-                || storeId == null || amount == null || amount <= 0 || StrUtil.isBlank(currency)) {
+                || storeId == null || amount == null || amount <= 0 || StrUtil.isBlank(currency)
+                || !("ONLINE".equals(paymentMode) || "OFFLINE".equals(paymentMode))) {
             throw new ServiceException("Invalid LINE Pay request intent");
         }
         Date now = new Date();
@@ -39,6 +48,7 @@ public class PosOrderLinePaymentServiceImpl implements IPosOrderLinePaymentServi
         row.setStoreId(storeId);
         row.setAmount(amount);
         row.setCurrency(currency);
+        row.setPaymentMode(paymentMode);
         row.setStatus("REQUESTING");
         row.setActiveDdId(ddId);
         row.setVersion(0L);
@@ -112,6 +122,40 @@ public class PosOrderLinePaymentServiceImpl implements IPosOrderLinePaymentServi
                 reconcileDeadline, new Date());
     }
 
+    @Override
+    public int markOfflineWaitingAuth(Long id, Long version, String transactionId,
+                                      Date nextReconcileAt, Date reconcileDeadline) {
+        if (id == null || version == null) {
+            return 0;
+        }
+        return paymentMapper.markOfflineWaitingAuth(id, version, transactionId,
+                nextReconcileAt, reconcileDeadline, new Date());
+    }
+
+    @Override
+    public int markOfflineEvidence(Long id, Long version, String expectedStatus, String targetStatus,
+                                   String transactionId, String paymentProvider,
+                                   Integer capturedAmount, Date payTime) {
+        if (id == null || version == null || StrUtil.isBlank(expectedStatus)
+                || !("AMOUNT_MISMATCH".equals(targetStatus) || "MANUAL_REVIEW".equals(targetStatus))
+                || StrUtil.isBlank(transactionId) || capturedAmount == null || capturedAmount <= 0) {
+            return 0;
+        }
+        return paymentMapper.markOfflineEvidence(id, version, expectedStatus, targetStatus,
+                transactionId, paymentProvider, capturedAmount,
+                payTime == null ? new Date() : payTime, new Date());
+    }
+
+    @Override
+    public int rescheduleOffline(Long id, Long version, String expectedStatus,
+                                 Date nextReconcileAt, Date reconcileDeadline) {
+        if (id == null || version == null || StrUtil.isBlank(expectedStatus)) {
+            return 0;
+        }
+        return paymentMapper.rescheduleOffline(id, version, expectedStatus,
+                nextReconcileAt, reconcileDeadline, new Date());
+    }
+
     @Override
     public int claimConfirm(Long id, Long version, String expectedStatus,
                             String leaseOwner, Date leaseUntil) {

+ 8 - 2
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImpl.java

@@ -29,7 +29,9 @@ public class PosOrderLineRefundServiceImpl implements IPosOrderLineRefundService
     @Override
     @Transactional(rollbackFor = Exception.class)
     public PosOrderLineRefund createIfAbsent(PosOrderLinePayment payment, String source) {
-        if (payment == null || payment.getId() == null || !"PAID".equals(payment.getStatus())
+        if (payment == null || payment.getId() == null
+                || !("PAID".equals(payment.getStatus())
+                || "AMOUNT_MISMATCH".equals(payment.getStatus()))
                 || payment.getTransactionId() == null) {
             throw new ServiceException("Only a captured LINE Pay payment can be refunded");
         }
@@ -43,7 +45,11 @@ public class PosOrderLineRefundServiceImpl implements IPosOrderLineRefundService
         row.setDdId(payment.getDdId());
         row.setCredentialId(payment.getCredentialId());
         row.setTransactionId(payment.getTransactionId());
-        row.setAmount(payment.getAmount());
+        row.setAmount("AMOUNT_MISMATCH".equals(payment.getStatus())
+                ? payment.getCapturedAmount() : payment.getAmount());
+        if (row.getAmount() == null || row.getAmount() <= 0) {
+            throw new ServiceException("Captured LINE Pay amount is unavailable");
+        }
         row.setStatus("CREATED");
         row.setSource(source);
         row.setVersion(0L);

+ 44 - 3
ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml

@@ -3,7 +3,9 @@
 <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,
+        credential_id AS credentialId, store_id AS storeId, amount,
+        captured_amount AS capturedAmount, currency,
+        payment_mode AS paymentMode,
         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,
@@ -35,7 +37,8 @@
     <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.store_id AS storeId, p.amount, p.captured_amount AS capturedAmount, p.currency,
+               p.payment_mode AS paymentMode,
                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,
@@ -92,6 +95,43 @@
           AND active_dd_id IS NOT NULL
     </update>
 
+    <update id="markOfflineWaitingAuth">
+        UPDATE pos_order_line_payment
+        SET transaction_id = COALESCE(#{transactionId}, transaction_id), status = 'WAITING_AUTH',
+            next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = #{reconcileDeadline},
+            status_changed_at = #{updateTime}, update_time = #{updateTime},
+            lease_owner = NULL, lease_until = NULL, version = version + 1
+        WHERE id = #{id} AND version = #{version}
+          AND status IN ('REQUESTING', 'REQUEST_UNKNOWN', 'WAITING_AUTH')
+          AND payment_mode = 'OFFLINE' AND active_dd_id IS NOT NULL
+    </update>
+
+    <update id="markOfflineEvidence">
+        UPDATE pos_order_line_payment
+        SET status = #{targetStatus}, transaction_id = #{transactionId},
+            payment_provider = #{paymentProvider}, captured_amount = #{capturedAmount},
+            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 payment_mode = 'OFFLINE' AND active_dd_id IS NOT NULL
+          AND #{targetStatus} IN ('AMOUNT_MISMATCH', 'MANUAL_REVIEW')
+          AND (transaction_id IS NULL OR transaction_id = #{transactionId})
+    </update>
+
+    <update id="rescheduleOffline">
+        UPDATE pos_order_line_payment
+        SET next_reconcile_at = #{nextReconcileAt},
+            reconcile_deadline = COALESCE(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 = #{expectedStatus}
+          AND payment_mode = 'OFFLINE' AND active_dd_id IS NOT NULL
+          AND status IN ('WAITING_AUTH', 'REQUEST_UNKNOWN')
+    </update>
+
     <update id="claimConfirm">
         UPDATE pos_order_line_payment
         SET status = 'CONFIRMING', lease_owner = #{leaseOwner}, lease_until = #{leaseUntil},
@@ -121,7 +161,8 @@
     <update id="markPaid">
         UPDATE pos_order_line_payment
         SET status = 'PAID', transaction_id = #{transactionId},
-            payment_provider = #{paymentProvider}, pay_time = #{payTime},
+            payment_provider = #{paymentProvider}, captured_amount = COALESCE(captured_amount, amount),
+            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

+ 5 - 1
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -44,6 +44,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="pointsReduction" column="points_reduction" />
         <result property="sdTime" column="sd_time" />
         <result property="payType" column="pay_type" />
+        <result property="orderSource" column="order_source" />
         <result property="parentDdId" column="parent_dd_id" />
         <result property="orderCategory" column="order_category" />
         <result property="tableId" column="table_id" />
@@ -63,7 +64,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectPosOrderVo">
-        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,delivery_status,pay_status,after_sale_status from pos_order
+        select id, dd_id, sh_id, md_id, cretim, shdz_id, user_id,sh_address, amount, remarks, state, type, delry_time,food,yh_id,yh_name,md_yh_id,md_yh_name,md_discount_amount,jvli,freight,dining_status,qs_id,pay_url,collect_payment,activity,md_activity,kefu_state,kefu_content,kefu_repeat,repeat_dd_id,sales_name,md_sales_name,sales_reduction,md_sales_reduction,points,points_reduction,sd_time,pay_type,order_source,parent_dd_id,order_category,table_id,table_no,logo,pos_name,food_amount,pick_up_num,pick_up_time,reserve_phone,is_accepted,is_display,display_time,delivery_status,pay_status,after_sale_status from pos_order
     </sql>
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -178,6 +179,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="pointsReduction != null">points_reduction,</if>
             <if test="sdTime != null">sd_time,</if>
             <if test="payType != null">pay_type,</if>
+            <if test="orderSource != null and orderSource != ''">order_source,</if>
             <if test="parentDdId != null and parentDdId != ''">parent_dd_id,</if>
             <if test="orderCategory != null and orderCategory != ''">order_category,</if>
             <if test="tableId != null">table_id,</if>
@@ -221,6 +223,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="pointsReduction != null">#{pointsReduction},</if>
             <if test="sdTime != null">#{sdTime},</if>
             <if test="payType != null">#{payType},</if>
+            <if test="orderSource != null and orderSource != ''">#{orderSource},</if>
             <if test="parentDdId != null and parentDdId != ''">#{parentDdId},</if>
             <if test="orderCategory != null and orderCategory != ''">#{orderCategory},</if>
             <if test="tableId != null">#{tableId},</if>
@@ -270,6 +273,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="pointsReduction != null">points_reduction = #{pointsReduction},</if>
             <if test="sdTime != null">sd_time = #{sdTime},</if>
             <if test="payType != null">pay_type = #{payType},</if>
+            <if test="orderSource != null and orderSource != ''">order_source = #{orderSource},</if>
             <if test="parentDdId != null and parentDdId != ''">parent_dd_id = #{parentDdId},</if>
             <if test="orderCategory != null and orderCategory != ''">order_category = #{orderCategory},</if>
             <if test="tableId != null">table_id = #{tableId},</if>

+ 67 - 0
ruoyi-system/src/test/java/com/ruoyi/system/mapper/LinePayOfflinePersistenceContractTest.java

@@ -0,0 +1,67 @@
+package com.ruoyi.system.mapper;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LinePayOfflinePersistenceContractTest {
+
+    @Test
+    void orderSourceAndPaymentModeRoundTripThroughExplicitMappers() throws IOException {
+        String orderXml = resource("mapper/system/PosOrderMapper.xml");
+        String paymentXml = resource("mapper/chanting/PosOrderLinePaymentMapper.xml");
+        String sql = sql();
+
+        assertTrue(orderXml.contains("property=\"orderSource\" column=\"order_source\""));
+        assertTrue(orderXml.contains("pay_type,order_source,parent_dd_id"));
+        assertTrue(orderXml.contains("<if test=\"orderSource != null and orderSource != ''\">order_source,</if>"));
+        assertTrue(orderXml.contains("#{orderSource}"));
+        assertTrue(paymentXml.contains("payment_mode AS paymentMode"));
+        assertTrue(paymentXml.contains("captured_amount AS capturedAmount"));
+        assertTrue(paymentXml.contains("captured_amount = #{capturedAmount}"));
+        assertTrue(paymentXml.contains("payment_mode = 'OFFLINE'"));
+        assertTrue(sql.contains("order_source VARCHAR(16)"));
+        assertTrue(sql.contains("DEFAULT 'USER'"));
+        assertTrue(sql.contains("payment_mode VARCHAR(16)"));
+        assertTrue(sql.contains("captured_amount INT"));
+        assertTrue(sql.contains("DEFAULT 'ONLINE'"));
+        assertTrue(sql.contains("idx_line_payment_mode_due"));
+    }
+
+    @Test
+    void waitingAuthUpdateUsesDeadlineChosenByService() throws IOException {
+        String paymentXml = resource("mapper/chanting/PosOrderLinePaymentMapper.xml");
+        int start = paymentXml.indexOf("<update id=\"markOfflineWaitingAuth\">");
+        int end = paymentXml.indexOf("</update>", start);
+        assertTrue(start >= 0 && end > start);
+
+        String statement = paymentXml.substring(start, end);
+        assertTrue(statement.contains("reconcile_deadline = #{reconcileDeadline}"));
+    }
+
+    private String resource(String name) throws IOException {
+        try (InputStream input = getClass().getClassLoader().getResourceAsStream(name)) {
+            return new String(Objects.requireNonNull(input, name + " not found").readAllBytes(),
+                    StandardCharsets.UTF_8);
+        }
+    }
+
+    private String sql() throws IOException {
+        for (Path candidate : List.of(Paths.get("updatesql", "sql.md"),
+                Paths.get("..", "updatesql", "sql.md"))) {
+            if (Files.exists(candidate)) {
+                return Files.readString(candidate, StandardCharsets.UTF_8);
+            }
+        }
+        throw new IOException("updatesql/sql.md not found");
+    }
+}

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

@@ -45,6 +45,31 @@ class PosOrderLinePaymentServiceImplTest {
         assertEquals(0, row.getReconcileCount());
     }
 
+    @Test
+    void newAttemptPersistsExplicitOfflineMode() {
+        when(paymentMapper.insert(any(PosOrderLinePayment.class))).thenReturn(1);
+
+        PosOrderLinePayment row = paymentService.createRequesting(
+                "DD-OFF-100", "LPOFF-100", 5L, 9L, 280, "TWD",
+                "OFFLINE", new Date(1_000_000L));
+
+        assertEquals("OFFLINE", row.getPaymentMode());
+        ArgumentCaptor<PosOrderLinePayment> captor =
+                ArgumentCaptor.forClass(PosOrderLinePayment.class);
+        verify(paymentMapper).insert(captor.capture());
+        assertEquals("OFFLINE", captor.getValue().getPaymentMode());
+    }
+
+    @Test
+    void legacyCreateMethodStillWritesOnlineModeExplicitly() {
+        when(paymentMapper.insert(any(PosOrderLinePayment.class))).thenReturn(1);
+
+        PosOrderLinePayment row = paymentService.createRequesting(
+                "DD-ONLINE-100", "LP-100", 5L, 9L, 280, "TWD", new Date());
+
+        assertEquals("ONLINE", row.getPaymentMode());
+    }
+
     @Test
     void explicitTerminalStateReleasesActiveKeyUsingCompareAndSet() {
         when(paymentMapper.markTerminal(eq(70L), eq(4L), eq("WAITING_AUTH"),
@@ -68,6 +93,19 @@ class PosOrderLinePaymentServiceImplTest {
         assertEquals("DD-100", row.getActiveDdId());
     }
 
+    @Test
+    void amountMismatchAndManualReviewNeverReleaseActiveKey() {
+        for (String status : java.util.List.of("AMOUNT_MISMATCH", "MANUAL_REVIEW", "PAID")) {
+            PosOrderLinePayment row = new PosOrderLinePayment();
+            row.setActiveDdId("DD-100");
+            row.setStatus(status);
+
+            paymentService.applyActiveKeyInvariant(row);
+
+            assertEquals("DD-100", row.getActiveDdId());
+        }
+    }
+
     @Test
     void onlyExplicitFailedOrCancelledAttemptCanHaveNoActiveKey() {
         PosOrderLinePayment row = new PosOrderLinePayment();

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

@@ -60,6 +60,19 @@ class PosOrderLineRefundServiceImplTest {
         assertNull(result.getReconcileDeadline());
     }
 
+    @Test
+    void amountMismatchRefundUsesPersistedCapturedAmount() {
+        when(refundMapper.selectByPaymentId(70L)).thenReturn(null);
+        when(refundMapper.insert(any(PosOrderLineRefund.class))).thenReturn(1);
+        PosOrderLinePayment payment = payment();
+        payment.setStatus("AMOUNT_MISMATCH");
+        payment.setCapturedAmount(100);
+
+        PosOrderLineRefund result = refundService.createIfAbsent(payment, "AMOUNT_MISMATCH");
+
+        assertEquals(100, result.getAmount());
+    }
+
     private static PosOrderLinePayment payment() {
         PosOrderLinePayment payment = new PosOrderLinePayment();
         payment.setId(70L);

+ 2 - 2
specs/019-line-pay/checklists/requirements.md

@@ -43,10 +43,10 @@
 - [x] 用户已批准整份书面规格(2026-08-12)
 - [x] 已生成 `plan.md`、`research.md`、`data-model.md`、`contracts/api.md` 和 `quickstart.md`
 - [x] 已生成 `tasks.md`
-- [ ] 实现和验证已完成
+- [x] 实现和验证已完成
 
 ## Notes
 
 - `1150` 凭证探测不是 LINE 官方专用校验接口;自动启用是用户已批准的业务规则,真实支付能力仍需 Sandbox 端到端验证。
 - 回跳页面约 20 秒响应约束与 Confirm 至少 40 秒读取超时存在冲突,因此规格固定为快速中间页 + 异步 Confirm/App 查询。
-- 当前已完成 specification、plan 和 tasks,下一阶段按 TDD 实现业务代码、前端和 SQL 脚本。
+- Offline 商家扫码后端、测试源码、SQL 迁移记录和规格已完成;JDK 21 定向测试、模块打包、差异、Controller 契约、i18n 与敏感字段检查通过。数据库 DDL、真实 Sandbox/生产商户 Offline 权限验证和前端构建未执行,详见 `tasks.md` T059-T060。

+ 112 - 5
specs/019-line-pay/contracts/api.md

@@ -66,7 +66,73 @@ Success `data`:
 
 该接口只读本地状态,不在 App 请求线程中调用 LINE。
 
-## 2. LINE redirect endpoints
+## 2. Merchant Offline API
+
+### Existing `POST /system/orderShOprate/createOrder`
+
+Request 继续使用现有 `OrderCreateInput`,其中 `paymentMethod="3"` 写入子订单 `pay_type="3"`。本增量不接受新的来源字段:
+
+- token 必须对应有效商家用户。
+- `items` 必须非空且恰好包含一个门店包,所有商品和金额属于同一门店。
+- `userType=1/3` 同时校验 `PosOrder.shId=userId` 和目标 `PosStore.userId=userId`;其他摊位商家按 `PosOrder.mdId=InfoUser.storeId` 校验。
+- 服务端为创建出的每个 `PosOrder` 显式写 `order_source="MERCHANT"`;客户端不能传入或覆盖该值。
+
+### `POST /system/orderShOprate/linePay/offline/pay`
+
+Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestBody LinePayOfflinePayRequest`。
+
+Request:
+
+```json
+{
+  "ddId": "202608180001",
+  "oneTimeKey": "123456789012345678"
+}
+```
+
+Success `data`:
+
+```json
+{
+  "ddId": "202608180001",
+  "paymentId": 84,
+  "lineOrderId": "LPOFF20260818...",
+  "transactionId": "2026081800000000001",
+  "status": "AUTH_REQUIRED",
+  "reusedAttempt": false,
+  "updatedAt": "2026-08-18T12:34:56+08:00"
+}
+```
+
+Rules:
+
+- `oneTimeKey` 必须是 18 位数字,只在本次内存调用链使用;响应、异常、数据库和任何日志不得返回原值。
+- 订单必须为当前商家拥有的单门店 `MERCHANT` 新订单,且 `payType="3"`、未支付、未取消、未完成、金额大于零、门店凭证已启用。
+- 已有任一 Online/Offline 阻断性尝试时不得创建新尝试;已有 Offline 活跃尝试时忽略新 `oneTimeKey`,返回已有本地状态并令 `reusedAttempt=true`。
+- 若阻断性活跃尝试属于 Online,付款接口返回业务错误 `line.pay.offline.attempt.blocked`,不得返回 Online 跳转地址、交易上下文或发送 My Code。
+- 金额、TWD、商品摘要、storeId、credentialId 和 LINE orderId 全部取服务端事实。
+- 首次进入认证等待使用 30 分钟固定截止;未知 Pay/Check 返回码不会按失败释放订单。CAS 冲突重读数据库状态,唯一键冲突复用已有尝试且不调用第二次 Pay。
+
+### `GET /system/orderShOprate/linePay/offline/status?ddId={ddId}`
+
+Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestParam String ddId`。
+
+该接口验证 `MERCHANT` 来源和当前商家归属后只读本地状态,不在请求线程调用 LINE。Success `data` 与付款接口相同,但 `reusedAttempt` 可省略。规范状态:
+
+若订单尚无 Offline 尝试(包括只有 Online 历史/活跃尝试),返回业务错误 `line.pay.offline.attempt.not.found`,不得回传 Online 支付上下文。
+
+```text
+AUTH_REQUIRED  1145/1169/AUTH_READY/WAITING_AUTH,客户仍需在 LINE Pay 操作
+PROCESSING     Pay 结果未知或重复待核实,后端只读查单中
+PAID           已严格核实订单号、交易号、金额和 paymentProvider
+CANCELLED      客户取消或支付过期,可使用新的 My Code 重试
+FAILED         明确无副作用失败,可使用新的 My Code 重试
+MANUAL_REVIEW  恢复截止仍未知或支付事实字段不完整
+```
+
+`AUTH_REQUIRED/PROCESSING/PAID/MANUAL_REVIEW` 均阻止再次扫码。金额不一致内部状态 `AMOUNT_MISMATCH` 对外返回 `MANUAL_REVIEW`,持久化 LINE 实际扣款额,并以该金额自动创建唯一全额退款;原订单不再开放支付。
+
+## 3. LINE redirect endpoints
 
 ### `GET /pay/line/confirm?orderId={lineOrderId}&transactionId={transactionId}`
 
@@ -87,7 +153,7 @@ Optional explicit query parameters: `orderId`, `transactionId`.
 - 只记录浏览器事件并触发只读核对;不能把到达 cancelUrl 当成取消终态。
 - 返回与 confirm 相同安全页面/响应头。
 
-## 3. Platform credential API
+## 4. Platform credential API
 
 Base path: `/system/storeLinePay`
 
@@ -132,7 +198,7 @@ Permission: `chanting:storeLinePay:toggleEnable`.
 
 Only changes whether the current verified version may serve new payments; old transactions remain queryable/refundable through their `credential_id`.
 
-## 4. Platform order API
+## 5. Platform order API
 
 ### `GET /system/order/{id}/status-context`
 
@@ -157,7 +223,7 @@ Permission: `system:order:linePaymentReconcile`. Uses the selected paymentId fro
 
 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
+## 6. LINE v4 outbound contracts
 
 ### Request
 
@@ -207,6 +273,45 @@ Confirm is sent only after local CAS claim and amount/currency/order/store/crede
 
 `POST /v4/payments/{transactionId}/refund`, 20 second timeout, body `{}`. `refundAmount` is intentionally omitted for full refund; there is no currency request field.
 
+### Offline Pay
+
+`POST /v4/payments/oneTimeKeys/pay`, read timeout 40 seconds or greater.
+
+```json
+{
+  "amount": 100,
+  "currency": "TWD",
+  "orderId": "LPOFF20260818...",
+  "oneTimeKey": "<仅发送给 LINE,不记录>",
+  "packages": [{
+    "id": "202608180001",
+    "amount": 100,
+    "products": [{"name": "Order 202608180001", "quantity": 1, "price": 100}]
+  }]
+}
+```
+
+- 不发送 redirect URL、设备请求头、Capture 或 Void 字段。
+- `0000` 必须核对响应 `orderId`、字符串 `transactionId`、`payInfo[].amount` 合计和非空 `paymentProvider` 后才能落账。
+- `1145/1169` 进入 `WAITING_AUTH`;超时、不可解析响应和不明确结果进入 `REQUEST_UNKNOWN`,后续不得再次调用 Pay。
+
+### Offline Check
+
+`GET /v4/payments/orders/{encodedLineOrderId}/check`, 20 second timeout. Path 中使用永久 `lineOrderId`,签名 URI 使用编码后的最终路径。
+
+```text
+AUTH_READY -> WAITING_AUTH / AUTH_REQUIRED
+COMPLETE   -> strict fact validation -> PAID or AMOUNT_MISMATCH/MANUAL_REVIEW
+CANCEL     -> CANCELLED_OR_EXPIRED / CANCELLED
+FAIL       -> FAILED
+```
+
+### Offline Refund
+
+`POST /v4/payments/orders/{encodedLineOrderId}/refund`, 20 second timeout, body `{}`。`refundAmount` 省略;退款响应未知时沿用 Retrieve/refundList 只读恢复,禁止直接重发。
+
+Online 与 Offline Refund 只能根据持久化的 `payment_mode` 分派,不能根据 URL、transactionId 是否存在或调用者参数判断。
+
 ### Required headers
 
 ```text
@@ -216,7 +321,9 @@ X-LINE-Authorization: Base64(HMAC-SHA256(channelSecret, channelSecret + URI + bo
 Content-Type: application/json
 ```
 
-## 6. App deep-link contract
+Offline 请求不得添加 `X-LINE-MerchantDeviceProfileId` 或 `X-LINE-MerchantDeviceType`。网关审计保存 Offline REQUEST 时必须删除 `oneTimeKey` 或固定替换为 `<redacted>`;捕获异常时不得拼接请求 DTO/body。
+
+## 7. App deep-link contract
 
 The mobile application must register and handle:
 

+ 45 - 5
specs/019-line-pay/data-model.md

@@ -7,9 +7,11 @@ 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)
+pos_order (`order_source=USER/MERCHANT`)
+          1 ── N pos_order_line_payment (append-only attempts)
                        │
                        ├── credential_id -> exact credential version
+                       ├── payment_mode -> ONLINE/OFFLINE gateway dispatch
                        ├── active_dd_id UNIQUE: one blocking attempt/order
                        └── 1 ── 0..1 pos_order_line_refund
 
@@ -60,10 +62,12 @@ Constraints and indexes:
 | `credential_id` | BIGINT | no | 发起时选中的不可变凭证版本 |
 | `store_id` | BIGINT | no | 门店快照 |
 | `amount` | INT | no | TWD 整数金额 |
+| `captured_amount` | INT | yes | LINE 实际扣款金额;正常支付等于 `amount`,金额异常时作为全额退款金额与恢复证据 |
 | `currency` | CHAR(3) ASCII | no | 首版 `TWD` |
+| `payment_mode` | VARCHAR(16) ASCII | no | `ONLINE/OFFLINE`;默认 `ONLINE`,新尝试显式写入 |
 | `payment_url_web` | VARCHAR(1000) | yes | Sandbox/浏览器跳转 URL |
 | `payment_url_app` | VARCHAR(1000) | yes | 生产 LINE App URL,可空 |
-| `payment_provider` | VARCHAR(16) | yes | 原始值可空;展示归一为 TSP |
+| `payment_provider` | VARCHAR(16) | yes | 网关原值;进入 `PAID` 时必须非空,已知 `TSP/EPI` 但不封闭枚举 |
 | `status` | VARCHAR(40) | no | 下方状态机 |
 | `active_dd_id` | VARCHAR(64) | yes | 阻断新尝试时等于 ddId,明确终止后 NULL |
 | `version` | BIGINT | no | CAS,从 0 递增 |
@@ -85,7 +89,9 @@ Constraints and indexes:
 - `INDEX(dd_id, create_time, id)`
 - `INDEX(status, next_reconcile_at, id)`
 - `INDEX(credential_id, create_time)`
+- `INDEX(payment_mode, status, next_reconcile_at, id)`
 - 所有状态更新条件至少包含 `id + expected status + version`,领取租约还需 `lease_until IS NULL OR lease_until < NOW()`。
+- 既有支付行通过 DDL 默认值保持 `ONLINE`;新建 Online/Offline 尝试必须显式写模式,禁止根据 URL、交易号或状态猜测。
 
 Payment states:
 
@@ -113,9 +119,23 @@ CONFIRMING
 CONFIRM_UNKNOWN
   ├─ PAID
   └─ MANUAL_REVIEW
+
+OFFLINE REQUESTING
+  ├─ WAITING_AUTH (1145/1169;AUTH_READY)
+  ├─ PAID (0000 且资金事实匹配)
+  ├─ REQUEST_UNKNOWN (超时/响应丢失/重复待核实)
+  ├─ AMOUNT_MISMATCH (真实付款但金额不一致)
+  └─ FAILED (明确无副作用失败;释放 active key)
+
+OFFLINE WAITING_AUTH / REQUEST_UNKNOWN
+  ├─ PAID (Check COMPLETE 且事实匹配)
+  ├─ CANCELLED_OR_EXPIRED (Check CANCEL;释放 active key)
+  ├─ FAILED (Check FAIL;释放 active key)
+  ├─ AMOUNT_MISMATCH
+  └─ MANUAL_REVIEW (截止仍未知或必要事实缺失)
 ```
 
-`PAID`、UNKNOWN、`MANUAL_REVIEW` 不释放 `active_dd_id`。旧尝试迟到证实 `PAID` 时照实记录,并阻断/退款,不覆盖当前尝试。
+`PAID`、UNKNOWN、`AMOUNT_MISMATCH`、`MANUAL_REVIEW` 不释放 `active_dd_id`。Offline 金额不一致退款成功后原订单仍关闭支付,商家必须创建新订单。旧尝试迟到证实 `PAID` 时照实记录,并阻断/退款,不覆盖当前尝试。
 
 ## 3. `pos_order_line_refund`
 
@@ -145,6 +165,7 @@ Constraints and indexes:
 - `UNIQUE(refund_transaction_id)`;允许多个 NULL。
 - `INDEX(status, next_reconcile_at, id)`
 - `INDEX(dd_id, create_time, id)`
+- 退款端点不在退款行重复保存模式;通过不可变 `payment_id` 读取支付行 `payment_mode`,Online 使用 `transaction_id`,Offline 使用 `line_order_id`。
 
 Refund transitions:
 
@@ -179,7 +200,7 @@ UNKNOWN    ├─ REFUNDED (Retrieve refundList proves it)
 | `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 |
+| `payload` | LONGTEXT | yes | 脱敏 JSON/body/error summary;Offline REQUEST 不得包含原始 `oneTimeKey` |
 | `create_time` | DATETIME | no | append time |
 
 Indexes:
@@ -193,7 +214,19 @@ Indexes:
 
 ## 5. Order integration
 
-不新增 `pos_order` 字段。使用现有:
+新增字段:
+
+| Column | Type | Null | Notes |
+|---|---|---:|---|
+| `order_source` | VARCHAR(16) ASCII | no | `USER/MERCHANT`;默认 `USER`,仅商家创建入口显式写 `MERCHANT` |
+
+兼容与写入规则:
+
+- 现有行和用户端新订单使用数据库默认 `USER`,不得根据 userId、创建时间、payType 或入口日志推断/回填。
+- `/system/orderShOprate/createOrder` 创建的每个子订单显式写 `MERCHANT`;父订单不作为 Offline 支付归属事实。
+- Offline 扫码必须同时满足 `order_source=MERCHANT`、当前 token 商家归属、单门店、未支付、未取消、未完成、金额大于零和 `pay_type="3"`。普通/夜市商家除校验 `sh_id=userId` 外,还必须校验订单 `md_id` 对应 `pos_store.user_id=userId`,禁止组合本人 `shId` 与其他商家的 `mdId` 使用他店凭证。
+
+继续使用现有字段:
 
 - `pay_type="3"`: 新 LINE 订单选择;历史同值不自动视为 LINE,必须存在匹配 Line payment。
 - `pay_status=0/1/2`: 未付/已付/已确认全额退款。
@@ -208,4 +241,11 @@ Indexes:
 4. 出现多条未全退 `PAID` 不自动任选,返回 `MANUAL_REVIEW`。
 5. 回跳只按唯一 `line_order_id` 定位,transactionId 存在时必须匹配。
 6. 定时/平台动作只按领取到的 `paymentId/refundId` 操作。
+7. 商家 Offline 状态查询先验证 `order_source=MERCHANT` 和当前商家归属,再按唯一活跃尝试或最近终态选择 `payment_mode=OFFLINE` 的记录;不得返回其他商家或 Online 尝试的敏感上下文。
+
+## 7. Migration and compatibility
 
+- `updatesql/sql.md` 追加 `pos_order.order_source VARCHAR(16) NOT NULL DEFAULT 'USER'`。
+- `updatesql/sql.md` 追加 `pos_order_line_payment.payment_mode VARCHAR(16) NOT NULL DEFAULT 'ONLINE'`、可空 `captured_amount` 及模式扫描索引。
+- Entity、MyBatis `paymentColumns`、所有显式 select、insert、update 和 Service 构造参数同步新增字段;不直接执行 DDL。
+- 默认值只提供历史兼容,业务代码创建订单/支付尝试时仍必须显式写入来源和模式,防止调用入口变化后静默误分类。

+ 3 - 2
specs/019-line-pay/offline-merchant-scan-design.md

@@ -196,7 +196,7 @@ MANUAL_REVIEW
 
 - `0000`:核对 `orderId`、`transactionId`、`payInfo` 金额合计和 `paymentProvider`;全部一致后落 `PAID` 并且只执行一次订单支付事实与后续副作用。币种固定取本地请求事实 `TWD`,不要求付款响应返回该字段。
 - `1145`、`1169`:进入 `WAITING_AUTH`,本地返回 `AUTH_REQUIRED/PROCESSING`,后续按 `lineOrderId` 查询。
-- `1152`、`1172`、`1198`:存在相同或重复请求的可能,不直接判失败,转只读状态查询。
+- `1152`、`1172`、`1198`:存在相同或重复请求的可能,不直接判失败,转只读状态查询;任何未识别 Pay/Check 返回码也按未知结果处理,不能按失败释放订单。
 - 网络超时、响应丢失、无法解析响应、`1199`、`190X`、`9000`:进入 `REQUEST_UNKNOWN`,禁止重新调用付款接口,只能状态查询。
 - `1133`:My Code 无效或过期,明确失败并提示重新生成 My Code。
 - 余额不足、用户/信用卡不可用、金额/限额等明确业务拒绝:进入 `FAILED`。
@@ -206,13 +206,14 @@ MANUAL_REVIEW
 - 查询 `FAIL`:进入 `FAILED` 并释放活跃尝试。
 - 查询暂时 `1150` 或未取得终态:在恢复截止时间前继续只读查询;到期进入 `MANUAL_REVIEW`,不自动释放订单占用。
 
-若 `payInfo` 合计与订单金额不一致,支付行进入 `AMOUNT_MISMATCH`,记录真实交易号并立即创建唯一全额退款意图;不得执行正常订单已支付/履约副作用,也不得允许再次扫码,退款结果仍不明确时进入人工核对。
+若 `payInfo` 合计与订单金额不一致,支付行进入 `AMOUNT_MISMATCH`,记录真实交易号和 `captured_amount`,并在同一事务内以实际扣款额创建唯一全额退款意图;不得执行正常订单已支付/履约副作用,也不得允许再次扫码,退款结果仍不明确时进入人工核对。
 
 ## 9. 并发和幂等
 
 - 同一订单的 Online 和 Offline 尝试共用现有订单级支付创建锁和 `active_dd_id` 唯一门禁。
 - 已有任一模式的活跃尝试时,不发起另一个模式的新请求。
 - 重复提交 Offline 接口时,若已经存在 Offline 活跃尝试,只返回其当前状态;新传入的 `oneTimeKey` 不发送、不记录。
+- 创建尝试发生唯一键冲突时直接复用数据库中的活跃尝试;所有状态 CAS 失败后重读数据库状态,不以过期内存对象继续网关副作用。
 - 只有 `CANCELLED_OR_EXPIRED` 或明确 `FAILED` 释放后,才能使用客户新生成的 My Code 创建新尝试。
 - `PAID`、`REQUEST_UNKNOWN`、`WAITING_AUTH`、`AMOUNT_MISMATCH` 和 `MANUAL_REVIEW` 持续阻断新支付。
 - 支付事实、退款意图、订单 `payStatus` 和通知/结算副作用均使用现有 CAS/唯一约束保证至多一次。

+ 98 - 22
specs/019-line-pay/plan.md

@@ -1,12 +1,12 @@
-# LINE Pay 直连支付 Implementation Plan
+# LINE Pay Online / Offline API v4 Implementation Plan
 
 > **For agentic workers:** 按 `tasks.md` 顺序逐项执行;每个实现任务先补失败测试,再写最小实现并运行对应验证。不得跳过状态机 CAS、外部调用前持久化 intent 或最终 `git diff` 检查。
 
-**Goal:** 为 `payType="3"` 接入 LINE Pay Online API v4,提供门店凭证版本管理、追加式支付尝试、异步确认、主动补单、自动全额退款和平台运维能力。
+**Goal:** 在保留现有 LINE Pay Online API v4 能力的同时,为商家端创建的单门店订单增加 Offline API v4 My Code 扫码付款、主动查单和全额退款后端能力。
 
-**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`。
+**Architecture:** `ruoyi-system` 只承载实体、MyBatis Mapper 和持久化状态机;`ruoyi-admin` 承载 LINE HTTP/HMAC、订单编排、回跳页面、定时任务和 Controller。Online 与 Offline 复用同一支付尝试、支付事实、退款事实、门店凭证和订单级活跃门禁,通过持久化的 `payment_mode` 选择网关端点,通过 `order_source` 限制商家扫码订单。所有有资金副作用的请求都采用“本地先提交 intent/claim → 外部 HTTP → 独立本地 CAS 落事实”,不依赖跨网络事务。
 
-**Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Apache HttpClient 4、fastjson2、Redisson、Vue 2、Element UI、JUnit 5/Mockito。
+**Tech Stack:** Java 21、Spring Boot、MyBatis/MyBatis-Plus、MySQL、Apache HttpClient 4、fastjson2、Redisson、JUnit 5/Mockito;现有 Online 管理功能仍使用 Vue 2/Element UI,本次 Offline 增量不修改前端。
 
 ---
 
@@ -14,17 +14,19 @@
 
 LINE Pay 普通付款省略未被当前公开契约保证的 `payType/confirmUrlType/appPackageName`,仅显式 `capture=true` 并使用默认浏览器回跳。Request 成功只代表已创建付款请求;回跳快速登记后返回内嵌安全中间页,Confirm 由可恢复服务异步完成。任务每分钟按行租约扫描,先 Retrieve,再按需 Check;只有 Check `0110` 且本地订单仍可付款时才能 Confirm。支付经严格匹配的 Retrieve/Confirm 证实后,支付行永久保持 `PAID`,退款事实只写退款表。
 
+Offline 增量只服务 `/system/orderShOprate/createOrder` 新建并持久化为 `MERCHANT` 的单门店订单。商家端提交 `ddId + oneTimeKey` 后,后端在现有订单级支付锁内创建 `payment_mode=OFFLINE` 的永久支付尝试,调用 `/v4/payments/oneTimeKeys/pay` 自动请款;等待认证或结果未知时只按 `lineOrderId` 调 `/v4/payments/orders/{orderId}/check`,绝不重复发送 Pay 或重用 My Code。Online/Offline 退款分别按 `transactionId/lineOrderId` 分派,公共资金事实与通知副作用继续使用现有 CAS 和唯一约束。
+
 ## 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  
+**Storage**: MySQL;保留 4 张 LINE 专用表,新增 `pos_order.order_source` 和 `pos_order_line_payment.payment_mode`,只记录到 `updatesql/sql.md`
+**Testing**: Maven Surefire + JUnit 5/Mockito;Offline 增量只执行后端定向测试、模块构建和完整 LINE 回归
 **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**: 门店级凭证;单门店子订单;每订单多次历史尝试但最多一条阻断性尝试;首版只支持全额退款
+**Performance Goals**: 商家状态查询只读本地数据库;Offline Pay Read Timeout 不少于 40 秒,Check/Refund 不少于 20 秒;定时任务单轮最多 20 笔并走 `(status,next_reconcile_at,id)`
+**Constraints**: 仅修改后端;不修改 OMG 表结构或迁移 OMG 日志;不修改废弃 ZaloPay/PayController/TestTask/OrderAppeal;`oneTimeKey` 不落库、不进入日志/异常/响应;支付/退款副作用未知时不得盲重试
+**Scale/Scope**: 门店级凭证;商家端新建的单门店餐饮订单;每订单多次历史尝试但 Online/Offline 合计最多一条阻断性尝试;只支持自动请款和全额退款
 
 ## Constitution Check
 
@@ -34,6 +36,9 @@ LINE Pay 普通付款省略未被当前公开契约保证的 `payType/confirmUrl
 - [x] Controller 使用明确 DTO、`@RequestBody/@RequestParam/@RequestHeader/@PathVariable`,不新增 Map 入参或 Bean Validation。
 - [x] 所有 DDL/权限 SQL 只追加到 `updatesql/sql.md`,不直接执行数据库变更。
 - [x] 不修改废弃支付与任务代码;仅在当前有效订单入口接入支付/退款门禁。
+- [x] 商家接口使用 token 校验身份及 `shId/mdId` 门店归属;用户端和历史订单不得推断为商家来源。
+- [x] `oneTimeKey` 只在 Controller 至网关调用链内存中短暂存在,所有审计和异常路径执行脱敏。
+- [x] Offline 增量只修改 `foodie_server`,不修改 uni-app、`foodie-store` 或 `foodie-admin-vue`。
 - [x] 平台新增文本四语同步,保留 Vue 文件 CRLF。
 - [x] 修复/功能均先测试再实现,交付前编译、定向测试、前端校验和 diff 审核。
 
@@ -48,6 +53,7 @@ specs/019-line-pay/
 ├── research.md
 ├── data-model.md
 ├── quickstart.md
+├── offline-merchant-scan-design.md
 ├── tasks.md
 ├── contracts/
 │   └── api.md
@@ -61,6 +67,7 @@ specs/019-line-pay/
 foodie_server/
 ├── ruoyi-system/src/main/java/com/ruoyi/system/
 │   ├── domain/{PosStoreLinePay,PosOrderLinePayment,PosOrderLineRefund,PaymentGatewayLog}.java
+│   ├── domain/PosOrder.java
 │   ├── domain/dto/{StoreLinePayCredentialDto,StoreLinePayToggleDto}.java
 │   ├── domain/vo/PosStoreLinePayVo.java
 │   ├── mapper/*Line*.java
@@ -68,11 +75,12 @@ foodie_server/
 ├── 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
+│   ├── pay/{LinePayController,LinePayService,LinePayOfflineService,LinePayGatewayAuditService,PaymentCreateGuardService}.java
+│   ├── pay/dto/{LinePayOfflinePayRequest,LinePayOfflineResult,*LinePay*}.java
+│   ├── order/{PosOrderShOprateController,PosOrderLinePayOfflineController}.java
 │   ├── mendian/PosStoreLinePayController.java
 │   └── task/LinePayReconcileTask.java
-├── ruoyi-admin/src/test/java/com/ruoyi/app/{utils/linepay,pay,task}/*Test.java
+├── ruoyi-admin/src/test/java/com/ruoyi/app/{utils/linepay,pay,order,task}/*Test.java
 ├── ruoyi-system/src/test/java/com/ruoyi/system/service/impl/*Line*Test.java
 └── updatesql/sql.md
 
@@ -91,6 +99,8 @@ foodie-admin-vue/
 
 - `IPosStoreLinePayService` 负责版本号分配、验证成功后的当前版本原子切换、启停和列表/详情。
 - `IPosOrderLinePaymentService` 负责创建 `REQUESTING`、稳定键查询、App 选择规则、CAS 状态推进、活跃键释放、扫描和行租约。
+- `PosOrder`、`PosOrderMapper.xml` 新增 `order_source`,默认 `USER`;仅商家创建入口显式写 `MERCHANT`,不回填历史订单。
+- `PosOrderLinePayment`、Mapper 和 Service 新增非空 `payment_mode`,既有行默认 `ONLINE`;所有新尝试显式写模式,查询、恢复和退款不得根据 URL 或交易号形态猜测模式。
 - `IPosOrderLineRefundService` 负责 `UNIQUE(payment_id)` 的 insert-if-absent、CAS claim、扫描和退款事实。
 - `IPaymentGatewayLogService` 只追加审计数据;日志不是状态机事实,日志写入失败不回滚资金事实。
 - `PosOrderMapper` 新增条件更新:只有未付款、合法订单态且当前 `pay_type` 与目标渠道一致时,才允许领取支付渠道或更新支付 URL。
@@ -98,9 +108,9 @@ foodie-admin-vue/
 ### 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 秒配置。
+- `LinePayClient` 保留 Online `request/check/confirm/retrieve/refund`,新增 Offline `payOffline/checkOffline/refundOfflineFull`;交易号始终为字符串。Online Request 10 秒、Offline Pay 40 秒、Check/Retrieve/Refund 20 秒、Confirm 40 秒。
 - `LinePayHttpTransport` 隔离实际 Apache HttpClient,便于不联网测试精确 method/URI/body/header。
-- `LinePayGatewayAuditService` 记录 correlationId、方向、action、HTTP/LINE 结果和 payload;调用方捕获日志异常后继续处理资金事实。
+- `LinePayGatewayAuditService` 记录 correlationId、方向、action、HTTP/LINE 结果和 payload;Offline REQUEST 在进入审计边界前移除或替换 `oneTimeKey` 为 `<redacted>`,异常对象和响应也不得携带原值。
 
 ### Orchestration boundary (`ruoyi-admin`)
 
@@ -111,6 +121,9 @@ foodie-admin-vue/
 - `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` 领取;失败一笔不终止整批。
+- `LinePayOfflineService` 校验商家、订单来源、门店归属、单门店、订单状态、金额、`payType=3` 和凭证,在共享支付锁内创建/复用 `OFFLINE` 尝试;Pay 响应不明确后只调 Check。
+- `LinePayService.reconcilePayment` 按 `payment_mode` 分派 Online Retrieve/Check/Confirm 或 Offline Check;公共的 `LinePayFactService.applyPaidFact` 继续负责至多一次落账、结算和通知。
+- `LinePayRefundService` 按 `payment_mode` 选择 Online `transactionId` Refund 或 Offline `lineOrderId` Refund;退款 UNKNOWN 仍只用现有 Retrieve/refundList 证据恢复。
 
 ### Platform and App boundary
 
@@ -119,6 +132,35 @@ foodie-admin-vue/
 - 平台凭证页可查看当前版本详情、验证并切换新版本、启停;普通列表不携带 Secret,拥有详情权限的页面按用户决定可回显。
 - 订单平台页显示 LINE 支付/退款状态,并提供人工查询与全额退款;资金动作使用独立权限。
 
+## Offline Incremental Design (2026-08-18)
+
+### Merchant order source and authorization
+
+- `/system/orderShOprate/createOrder` 在落父子订单前验证 token 对应有效商家、`items` 恰好一个门店包,并按项目规则校验门店归属:`userType=1/3` 同时要求 `PosOrder.shId=userId` 和目标 `PosStore.userId=userId`,其他摊位商家使用 `PosOrder.mdId=InfoUser.storeId`。支付前按同一规则重查订单与门店,不能把本人 `shId` 和其他商家的 `mdId` 组合使用。
+- 所有通过该入口创建的子订单显式写 `order_source=MERCHANT`;用户端和历史数据使用数据库默认 `USER`。扫码接口不接受客户端传入来源、金额、门店、币种、凭证或 LINE orderId。
+- 独立 `PosOrderLinePayOfflineController` 暴露付款和状态接口,均使用 `@RequestHeader String token`;POST 使用明确 `@RequestBody` DTO,GET 使用显式 `@RequestParam`,业务校验通过 `MessageUtils.message(...)` 返回。
+
+### Offline payment and local status
+
+- `oneTimeKey` 只校验台湾 18 位数字格式并传给一次 `payOffline` 调用,不保存到 DTO 之外的对象、数据库、普通日志、异常文本、Controller 响应或 `payment_gateway_log.payload`。
+- Pay `0000` 只有在 `orderId`、字符串 `transactionId`、`payInfo` 合计和非空 `paymentProvider` 全部匹配时才能写入 `PAID`;`paymentProvider` 原值保存,已知 `TSP/EPI` 但不做封闭枚举。
+- `1145/1169/AUTH_READY/WAITING_AUTH` 对商家返回 `AUTH_REQUIRED`;Pay 超时、响应丢失、未识别返回码、重复请求待核实及 `REQUEST_UNKNOWN` 返回 `PROCESSING`;`COMPLETE/CANCEL/FAIL` 返回 `PAID/CANCELLED/FAILED`。只有白名单内明确且无资金副作用的 Pay 拒绝码才能释放活跃键,Check 的未识别返回码始终保留占用并只读重查。
+- `AUTH_REQUIRED`、`PROCESSING`、`PAID`、`AMOUNT_MISMATCH` 和 `MANUAL_REVIEW` 均阻止再次扫码。只有明确 `CANCEL/FAIL` 释放活跃键;金额不一致退款完成后原订单仍关闭支付,商家需要新建订单,避免同一订单同时承载异常退款和新付款。
+
+### Recovery and refund
+
+- 首次进入 `WAITING_AUTH` 时将创建尝试的 24 小时未知截止改写为 30 分钟认证截止,后续查询保留原截止而不顺延;`REQUEST_UNKNOWN` 和退款 UNKNOWN 复用 24 小时截止。截止前只读 Check/Retrieve,截止后进入 `MANUAL_REVIEW` 并保留活跃占用。
+- Offline Check 使用永久 `lineOrderId`;任何超时、不可解析响应、`1152/1172/1198/1199/190X/9000` 或重复请求可能性都不得重发 Pay。
+- `payInfo` 金额不一致时将真实交易标识和实际扣款额保存到 `captured_amount`,在同一事务内置 `AMOUNT_MISMATCH` 并 insert-if-absent 创建以实际扣款额为金额的全额退款;禁止正常履约,退款证据明确前不得释放支付占用。
+- 所有 Offline 状态推进检查 CAS 影响行数;失败时重读持久化状态。唯一键冲突复用已有尝试并直接返回,禁止再次调用 Pay。
+- Offline 全额退款使用 `/v4/payments/orders/{lineOrderId}/refund` 且省略 `refundAmount`;Online 继续使用 `/v4/payments/{transactionId}/refund`,唯一分派依据是持久化 `payment_mode`。
+
+### Security review gates
+
+- 服务端只信任 token 身份、数据库订单金额/门店/来源/状态和已启用凭证;客户端的 `ddId` 与 `oneTimeKey` 均在外部调用前做白名单校验和归属检查。
+- 网关 URL 仍受 `LinePayClient.baseUrl()` allowlist 限制,HMAC 使用最终 URI 和 UTF-8 JSON;不发送设备请求头、redirect URL、Capture 或 Void。
+- 商家状态接口只返回规范状态和必要交易上下文,不返回 `oneTimeKey`、Channel Secret、HMAC、网关原始请求体、堆栈或内部租约字段。
+
 ## Error and Recovery Policy
 
 | 场景 | 本地处理 | 后续动作 |
@@ -132,25 +174,52 @@ foodie-admin-vue/
 | 取消后迟到付款 | 支付仍写 `PAID` | 唯一退款 intent + 自动全退 |
 | Refund 超时/未知 | `UNKNOWN` | Retrieve 原支付并检查 `refundList` |
 | 到达恢复截止仍未知 | `MANUAL_REVIEW` | 停止自动副作用,平台人工处理 |
+| Offline Pay `1145/1169` 或 Check `AUTH_READY` | `WAITING_AUTH` | 返回 `AUTH_REQUIRED`,按 lineOrderId 继续 Check |
+| Offline Pay 超时/响应丢失 | `REQUEST_UNKNOWN` | 返回 `PROCESSING`,只读 Check,禁止重发 Pay |
+| Offline Pay/Check 未识别返回码 | 保留阻断状态 | 返回 `PROCESSING` 或继续只读 Check,禁止按失败释放或重发 Pay |
+| Offline Check `CANCEL/FAIL` | `CANCELLED_OR_EXPIRED/FAILED` | 释放活跃键,客户生成新 My Code 后可重试 |
+| Offline `paymentProvider` 缺失 | `MANUAL_REVIEW` | 不触发履约,不自动释放占用 |
+| Offline 金额不一致 | `AMOUNT_MISMATCH` | 持久化实际扣款额并以该金额创建唯一全额退款,原订单不再支付 |
 
 ## Implementation Order
 
-1. 先落实 SQL 模型、实体/Mapper/Service 和状态 CAS 测试。
-2. 实现 HMAC、HTTP 契约和凭证验证测试,再接凭证管理接口。
-3. 实现 create/query、回跳页面和追加式尝试选择规则。
-4. 实现 Retrieve/Check/Confirm 恢复、支付事实事务和取消竞态。
-5. 实现全额退款与定时任务,再接两个真实取消入口和接单门禁。
-6. 最小修改 OMG create 共享渠道门禁。
-7. 完成平台前端、四语和权限 SQL,最后做全栈验证。
+1. 扩展 `pos_order.order_source` 与 `pos_order_line_payment.payment_mode` 的 SQL、Entity、Mapper、Service 和兼容测试;既有 Online 行和历史订单使用默认值。
+2. 为商家下单入口增加身份、门店归属、单门店校验和 `MERCHANT` 来源写入,覆盖普通商家、夜市商家及其他摊位商家规则。
+3. 扩展 `LinePayClient` 的 Offline Pay/Check/Refund 契约、超时、HMAC 和响应解析测试,并实现 `oneTimeKey` 全链路脱敏。
+4. 新增商家 Offline Controller/DTO/Service,完成创建或复用尝试、状态映射、并发门禁和金额/渠道事实核对。
+5. 让定时恢复和退款按 `payment_mode` 分派,覆盖认证等待、未知结果、金额不一致、取消竞态和 Online 回归。
+6. 更新 SQL 与 Spec Kit 产物后,统一运行 JDK 21 定向测试、模块构建和 LINE Pay 完整回归;不运行前端构建,因为本增量不修改前端。
 
 ## 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 -Dtest='*LinePayOffline*Test,PosOrderShOprateControllerTest,*LinePay*Test,OrderLifecycleServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test`
 - `mvn -pl ruoyi-admin -am -DskipTests package`
-- 在 `foodie-admin-vue` 运行 `npm run lint` 和 `npm run build:prod`;若仓库已有无关 lint 错误,记录并对变更文件运行 ESLint。
+- Offline 增量不运行 `foodie-admin-vue` 校验;若后续实际修改平台前端,再恢复 `npm run lint` 和 `npm run build:prod`。
 - `git diff --check`、`git status --short`、逐文件核对换行/编码、确认未触碰废弃代码和用户已有 OMG SQL。
 
+## Offline Requirement Coverage
+
+| Requirement | Planned implementation and verification |
+|---|---|
+| FR-OFF-001 | Offline 增量仅涉及 `foodie_server`;构建与差异检查确认无前端文件 |
+| FR-OFF-002 | `payment_mode` DDL、Entity/Mapper/Service、模式分派和 Online 默认值回归 |
+| FR-OFF-003 | `order_source` DDL、商家入口显式写入、用户/历史默认 `USER` 测试 |
+| FR-OFF-004 | 商家身份、`shId/mdId` 归属、单门店和支付前二次门禁测试 |
+| FR-OFF-005 | `LinePayOfflinePayRequest` 只含 `ddId/oneTimeKey`,其余请求事实由服务端构造 |
+| FR-OFF-006 | `LinePayClient.payOffline` 自动请款契约,不实现 Capture/Void/redirect/device headers |
+| FR-OFF-007 | HTTP transport 契约测试断言 Pay 40 秒、Check/Refund 20 秒 |
+| FR-OFF-008 | `REQUEST_UNKNOWN` 只读 Check、禁止重发 Pay 与复用 My Code 的恢复测试 |
+| FR-OFF-009 | 1145/1169 和 AUTH_READY/COMPLETE/CANCEL/FAIL 的本地/接口状态映射测试 |
+| FR-OFF-010 | orderId、transactionId、payInfo 和非空 paymentProvider 的严格事实核对测试 |
+| FR-OFF-011 | `AMOUNT_MISMATCH`、唯一全退意图、禁止履约和关闭原订单支付测试 |
+| FR-OFF-012 | Online/Offline 共享 `active_dd_id` 与 Redisson 订单锁的并发测试 |
+| FR-OFF-013 | Refund 按 `payment_mode` 使用 transactionId/lineOrderId 的双模式回归 |
+| FR-OFF-014 | Controller、Client、审计和异常路径的 oneTimeKey 不落库/不泄漏测试 |
+| FR-OFF-015 | 复用现有门店凭证;quickstart 记录生产 Offline 权限前置验收 |
+| FR-OFF-016 | DDL 仅追加 `updatesql/sql.md`,验证过程不执行迁移 |
+
 ## Complexity Tracking
 
 | Decision | Why Needed | Simpler Alternative Rejected Because |
@@ -159,3 +228,10 @@ foodie-admin-vue/
 | 不可变凭证版本 + payment.credential_id | 轮换后历史交易仍能找到原渠道身份 | 一店一行覆盖会让旧交易失去凭证归属 |
 | 支付与退款分表 | `PAID` 是资金事实,退款是另一事实与状态机 | 在支付状态写 REFUNDED 会掩盖曾经扣款 |
 | durable intent + 外部调用 + CAS | 数据库事务无法覆盖 LINE 网络副作用 | 单个 `@Transactional` 无法修复响应丢失和本地提交失败 |
+| 同表保存 Online/Offline 并增加 `payment_mode` | 复用支付事实、退款、凭证、审计和恢复,同时可靠选择不同端点 | 新建 Offline 专表会复制状态机;根据 URL/交易号猜测模式不可靠 |
+| `order_source` 使用数据库默认 `USER` | 只有新商家端订单可扫码,历史订单不被误判 | 根据 userId、时间或入口日志回填来源不可审计 |
+| 金额不一致订单退款后仍关闭支付 | 避免一张订单同时存在异常退款和后续新付款,简化结算与对账 | 自动释放并重扫会扩大异常资金状态组合 |
+
+## Post-Design Constitution Re-check
+
+`.specify/memory/constitution.md` 仍为未填充模板,没有可执行的额外原则。按仓库规则复核后,设计保持 `ruoyi-admin -> ruoyi-system` 依赖方向、明确 Controller DTO、商家门店归属鉴权、SQL 只写 `updatesql/sql.md`、不修改废弃支付代码、Offline 后端单一范围和敏感数据脱敏;未发现需要例外说明的门禁冲突。

+ 36 - 4
specs/019-line-pay/quickstart.md

@@ -1,4 +1,4 @@
-# LINE Pay Implementation Quickstart
+# LINE Pay Online / Offline Implementation Quickstart
 
 ## 1. Prerequisites
 
@@ -6,6 +6,8 @@
 - MySQL schema changes from `updatesql/sql.md` applied manually by the developer
 - Redis/Redisson available
 - LINE Pay Sandbox Channel ID/Secret
+- 对应 LINE Pay 商户已开通 Offline API 权限;固定收款码但没有 API credentials 的商户不能联调
+- 官方台湾 Sandbox My Code 生成器;不得使用真实 LINE App 生成的 My Code
 - Public HTTPS API base URL reachable by LINE redirect
 - Platform frontend: `E:\QtwCode\foodie\foodie-admin-vue`
 
@@ -45,7 +47,7 @@ mvn -pl ruoyi-admin -am -Dtest='*LinePay*Test,OrderLifecycleServiceTest,OmgPayCo
 mvn -pl ruoyi-admin -am -DskipTests package
 ```
 
-Frontend:
+现有 Online 平台前端回归(仅当实际修改 `foodie-admin-vue` 时执行):
 
 ```powershell
 Set-Location E:\QtwCode\foodie\foodie-admin-vue
@@ -61,6 +63,16 @@ git diff --check
 git status --short
 ```
 
+Offline 增量的定向验证命令在全部后端功能、测试源码和 SQL 调整完成后统一执行:
+
+```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='*LinePayOffline*Test,PosOrderShOprateControllerTest,*LinePay*Test,OrderLifecycleServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test
+mvn -pl ruoyi-admin -am -DskipTests package
+```
+
 ## 4. Sandbox acceptance flow
 
 1. Platform opens Store Payment → LINE Pay.
@@ -74,7 +86,20 @@ git status --short
 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
+## 5. Offline Sandbox acceptance flow
+
+1. 使用商家 token 调 `/system/orderShOprate/createOrder` 创建 `paymentMethod="3"` 的单门店订单。
+2. 只读检查子订单 `order_source=MERCHANT`;用户端订单和历史订单保持 `USER`。
+3. 从官方台湾 Sandbox My Code 生成器取得 18 位 `oneTimeKey`,调用 `/system/orderShOprate/linePay/offline/pay`。
+4. 检查支付行 `payment_mode=OFFLINE`,且数据库、应用日志、异常和 `payment_gateway_log.payload` 均不存在原始 `oneTimeKey`。
+5. Pay 返回 `1145/1169` 或 Check 返回 `AUTH_READY` 时,状态接口返回 `AUTH_REQUIRED`;重复提交新的 My Code 只返回旧尝试,不再次调用 Pay。
+6. Pay 超时、响应不明确或返回未识别结果码时,状态接口返回 `PROCESSING`,任务只按 `lineOrderId` 调 Offline Check;Check 未识别结果码同样不得释放活跃键。
+7. Check 返回 `COMPLETE` 且订单号、交易号、金额合计、非空 `paymentProvider` 匹配时,状态最终为 `PAID`,订单结算和通知只执行一次。
+8. 使用错误商家、本人 `shId` 搭配他店 `mdId`、用户端/历史订单、多门店订单、非 `payType=3` 订单或无效 My Code 请求,确认在调用 LINE 前拒绝。
+9. 模拟金额不一致,确认 `captured_amount` 保存 LINE 实际扣款额、不触发正常履约、只创建一条以实际扣款额为金额的全额退款意图,并且原订单保持禁止再次支付。
+10. 对已支付 Offline 订单执行取消/退款,确认 URI 使用 `lineOrderId`;对 Online 订单回归确认仍使用 `transactionId`。
+
+## 6. Required concurrency and security 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.
@@ -83,8 +108,13 @@ git status --short
 - 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.
+- Online 与 Offline 同时发起时只允许一个模式创建活跃尝试。
+- 两个 Offline Pay 请求并发时只向 LINE 发送一次,后到请求的 `oneTimeKey` 不记录、不发送。
+- `AUTH_REQUIRED`、`PROCESSING`、`AMOUNT_MISMATCH` 和缺失 `paymentProvider` 的 `MANUAL_REVIEW` 均阻止重新扫码。
+- 普通商家和夜市商家按 `shId=userId` 鉴权,其他摊位商家按 `mdId=InfoUser.storeId` 鉴权。
+- Controller、审计服务和异常分支的测试均断言原始 `oneTimeKey` 不可见。
 
-## 6. Production acceptance items
+## 7. Production acceptance items
 
 Sandbox cannot prove every App handoff behavior. Before production enablement, verify on real iOS and Android devices:
 
@@ -93,3 +123,5 @@ Sandbox cannot prove every App handoff behavior. Before production enablement, v
 - 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.
+- 业务方确认该 Channel 已开通 Offline API,并用真实客户 My Code 验证 `AUTH_READY/COMPLETE/CANCEL/FAIL` 收敛。
+- 验证 `paymentProvider` 的 `TSP/EPI` 原值;若生产返回其他非空值仍保存并允许完成,缺失或空值进入人工核对。

+ 64 - 2
specs/019-line-pay/research.md

@@ -1,7 +1,7 @@
 # LINE Pay Research and Decisions
 
-**Date**: 2026-08-12  
-**API baseline**: LINE Pay Online API v4
+**Date**: 2026-08-12;Offline 增量更新 2026-08-18
+**API baseline**: LINE Pay Online API v4 + Offline API v4(台湾)
 
 ## R1. LINE 请求字段与内部 payType 分离
 
@@ -104,3 +104,65 @@
 **Rationale**: 原始日志不能提供可靠的 CAS、唯一性、扫描索引或当前状态;同时普通列表不需要批量传输 Secret。
 
 **Rejected**: 把全部状态只存日志;让 OMG 同步迁移到新日志表。
+
+## R12. Offline 使用自动请款的 My Code Pay
+
+**Decision**: 商家扫码只接 `POST /v4/payments/oneTimeKeys/pay`,台湾默认自动请款;App 仅提交 `ddId` 和 18 位 `oneTimeKey`。金额、TWD、商品摘要、门店、凭证和永久唯一 `lineOrderId` 全部从服务端事实生成。本期不发送设备请求头,不实现 Capture、Void、redirect URL 或授权/请款分离。
+
+**Rationale**: 官方台湾 Offline 流程默认付款即请款,设备头为成对出现的选填字段;当前需求是门店当面收款,不需要额外设备身份或延后请款。
+
+**Rejected**: 让 App 提交金额/币种/orderId;把扫码理解为付款完成;为了未来可能性提前实现 Capture/Void。
+
+**Sources**: https://developers-pay.line.me/zh/offline/implement-payment ; https://developers-pay.line.me/zh/offline-api-v4/request-payment
+
+## R13. 订单来源与支付模式必须持久化
+
+**Decision**: `pos_order.order_source` 使用 `USER/MERCHANT`,数据库默认 `USER`,仅 `/system/orderShOprate/createOrder` 显式写 `MERCHANT`;`pos_order_line_payment.payment_mode` 使用 `ONLINE/OFFLINE`,数据库默认 `ONLINE`,所有新尝试显式写入。历史数据不推断、不回填。
+
+**Rationale**: `payType="3"` 只能表示 LINE Pay 渠道,无法区分网页付款和商家扫码,也无法证明订单来自商家端。持久化判别字段才能稳定驱动鉴权、查单、退款和恢复。
+
+**Rejected**: 新增另一个 payType;根据 paymentUrl、transactionId、创建时间或调用入口日志猜测模式/来源;新建 Offline 专表。
+
+## R14. Offline 结果通过只读 Check 收敛
+
+**Decision**: Offline Pay Read Timeout 为 40 秒,Check/Refund 为 20 秒。`1145/1169/AUTH_READY` 进入 `WAITING_AUTH` 并对商家返回 `AUTH_REQUIRED`;超时、响应丢失、未识别返回码、重复请求待核实和 `REQUEST_UNKNOWN` 返回 `PROCESSING`。只有明确无资金副作用的 Pay 拒绝码和 Check `CANCEL/FAIL` 才能释放活跃键;其他结果只按 `lineOrderId` 调 `GET /v4/payments/orders/{orderId}/check`,禁止重发 Pay 或重用 `oneTimeKey`。
+
+**Rationale**: My Code 是一次性且有时效的授权载体;Pay 可能已经被 LINE 接收,重复提交会产生重复扣款风险。`AUTH_READY/1169` 只表示客户仍需在 LINE Pay 选择付款方式或认证,不是成功。
+
+**Rejected**: Pay 超时后自动再次 Pay;把扫码或 HTTP 200 视为成功;把所有非终态都返回同一个不可操作状态。
+
+**Sources**: https://developers-pay.line.me/zh/offline-api-v4/request-payment ; https://developers-pay.line.me/zh/offline-api-v4/check-payment-status
+
+## R15. Offline 支付事实核对与异常金额收口
+
+**Decision**: 只有 Pay `0000` 或 Check `COMPLETE` 且 `orderId`、字符串 `transactionId`、`payInfo` 金额合计和非空 `paymentProvider` 匹配,才写支付事实。`paymentProvider` 原值保存,`TSP/EPI` 为已知值但不做封闭枚举;缺失进入 `MANUAL_REVIEW`。金额不一致时持久化真实交易和 `captured_amount`,禁止履约,并在同一事务创建以实际扣款额为金额的唯一全额退款;原订单即使退款成功也不再开放支付,商家需新建订单。
+
+**Rationale**: LINE 明确要求核对 `payInfo` 合计并在不一致时退款。保留非空 provider 原值兼顾证据完整与未来渠道扩展;异常资金订单不复用可避免同一业务订单同时承载异常退款和新付款。
+
+**Rejected**: 仅凭 `returnCode=0000` 落账;把 provider 写死为 `TSP/EPI`;金额异常退款后自动释放原订单重扫。
+
+## R16. oneTimeKey 属于禁止持久化的敏感一次性数据
+
+**Decision**: `oneTimeKey` 仅在 Controller DTO 到 `LinePayClient.payOffline` 的单次内存调用链中存在。数据库、普通日志、异常消息、响应和 `payment_gateway_log.payload` 不得包含原值;Offline REQUEST 审计仅保存不含该字段的结构化摘要或固定 `<redacted>`。
+
+**Rationale**: My Code 可触发付款且五分钟内有效,泄漏会扩大未授权扣款与重放风险;现有通用审计包装必须在请求进入日志边界前脱敏。
+
+**Rejected**: 为排障保存原始请求;只在成功日志脱敏但允许异常堆栈携带 DTO;对 oneTimeKey 做可逆加密后落库。
+
+## R17. Offline 退款按持久化模式分派
+
+**Decision**: Offline 全额退款调用 `POST /v4/payments/orders/{lineOrderId}/refund`,Online 保持 `POST /v4/payments/{transactionId}/refund`,两者都省略 `refundAmount`。唯一分派依据是支付行 `payment_mode`;退款响应未知时继续通过 Retrieve/refundList 只读核实。
+
+**Rationale**: 两种 v4 模式的退款 URI 稳定键不同;猜测模式会调用错误端点。现有退款表与 `UNIQUE(payment_id)` 足以保证每笔付款最多一个全退意图。
+
+**Rejected**: 根据 transactionId 是否存在选择端点;为 Offline 再建退款表;UNKNOWN 时直接重发 Refund。
+
+**Sources**: https://developers-pay.line.me/zh/offline/handle-refund ; https://developers-pay.line.me/zh/offline-api-v4/refund
+
+## R18. Offline 并发冲突以数据库事实为准
+
+**Decision**: 所有 Offline 状态更新必须检查版本 CAS 影响行数,失败后重读持久化状态;创建尝试遇到唯一键冲突时复用已有尝试并立即返回,不能再次发送 Pay。首次从 `REQUESTING/REQUEST_UNKNOWN` 进入 `WAITING_AUTH` 时写入 30 分钟认证截止,后续查询保留原截止,不使用创建时的 24 小时未知截止,也不滚动延长。
+
+**Rationale**: 外部支付副作用无法和本地数据库合并为一个事务。把 CAS 失败后的内存对象当事实、或在唯一键冲突后继续 Pay,都会造成错误状态或重复扣款;滚动延长认证截止则会让异常尝试永久占用订单。
+
+**Rejected**: 忽略 Mapper 更新行数;DuplicateKey 后继续原调用;使用 `COALESCE` 无条件保留创建尝试时的未知截止。

+ 19 - 9
specs/019-line-pay/spec.md

@@ -12,6 +12,13 @@
 
 > 本规格取代 `brainstorm.md` 中尚未确认或后来已变更的内容。若两者冲突,以本规格为准。
 
+## Clarifications
+
+### Session 2026-08-18
+
+- Q: LINE Pay 返回等待认证或结果未知时,本地状态如何映射? → A: `1145`、`1169`、`AUTH_READY` 和 `WAITING_AUTH` 返回 `AUTH_REQUIRED`;超时、响应丢失、重复请求待核实及 `REQUEST_UNKNOWN` 返回 `PROCESSING`。
+- Q: `paymentProvider` 应如何校验? → A: 必须非空并原值保存;`TSP`、`EPI` 是已知值,其他非空值也允许;缺失或空值进入 `MANUAL_REVIEW`。
+
 ## 1. 已定决策
 
 | 议题 | 最终决策 |
@@ -565,6 +572,9 @@ Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在
 - 任何支付/退款未知结果在截止前持续 Retrieve,截止后进入人工核对且不自动释放订单支付占用。
 - 不存在跨门店凭证使用;凭证轮换后旧交易仍可查询和退款。
 - LINE Pay 变更不修改 OMG 三张表及其既有日志结构。
+- 商家扫码付款仅接受 `/system/orderShOprate/createOrder` 产生的 `MERCHANT` 单门店订单;用户端、历史、跨商家和多门店订单在调用 LINE 前全部拒绝。
+- Offline Pay 超时或不明确后只按永久 `lineOrderId` Check;重复请求、任务并发和新 My Code 均不会触发第二次 Pay。
+- 原始 `oneTimeKey` 不出现在数据库、响应、异常、普通日志和网关审计中;金额不一致不履约、只产生一个全额退款意图,且原订单永久关闭支付。
 
 ## 14. 假设
 
@@ -595,15 +605,15 @@ Sandbox 官方不支持 App payment URL,也不能模拟 EPI;以下必须在
 - **FR-OFF-001**:系统 MUST 只修改 `foodie_server` 后端;商家端为 uni-app,本期不修改 App、`foodie-store` 或 `foodie-admin-vue`。
 - **FR-OFF-002**:`payType="3"` MUST 继续表示 LINE Pay;Online 与 Offline MUST 通过 `pos_order_line_payment.payment_mode` 的 `ONLINE/OFFLINE` 区分并存。
 - **FR-OFF-003**:只有 `/system/orderShOprate/createOrder` 创建且持久化为 `order_source="MERCHANT"` 的新订单可以发起 Offline 扫码支付;历史订单及用户端订单默认 `USER`,不得推断或回填来源。
-- **FR-OFF-004**:商家下单 MUST 验证 token 用户为商家、订单门店属于该商家,并且 `items` 仅包含一个门店包;扫码支付时 MUST 再次校验来源、归属、单门店、订单状态、`payType`、金额和门店凭证。
+- **FR-OFF-004**:商家下单 MUST 验证 token 用户为商家、订单门店属于该商家,并且 `items` 仅包含一个门店包;普通/夜市商家 MUST 同时校验 `shId=userId` 与目标 `PosStore.userId=userId`,禁止本人 `shId` 组合其他商家的 `mdId`。扫码支付时 MUST 再次校验来源、归属、单门店、订单状态、`payType`、金额和门店凭证。
 - **FR-OFF-005**:App 只提交 `ddId` 和台湾 18 位 `oneTimeKey`;金额、币种、商品、门店、凭证及 LINE `orderId` MUST 取自服务端事实。
 - **FR-OFF-006**:系统 MUST 调用 Offline API v4 `POST /v4/payments/oneTimeKeys/pay` 并使用台湾默认自动请款;本期 MUST NOT 实现分开请款、Capture、Void、redirect URL 或设备请求头。
 - **FR-OFF-007**:付款请求 Read Timeout MUST 不少于 40 秒;状态查询和退款 Read Timeout MUST 不少于 20 秒。
-- **FR-OFF-008**:付款请求超时、响应丢失或结果不明确后 MUST 按 `lineOrderId` 调用 `GET /v4/payments/orders/{orderId}/check`,MUST NOT 重复提交付款请求或重用 `oneTimeKey`。
-- **FR-OFF-009**:系统 MUST 将 `AUTH_READY/COMPLETE/CANCEL/FAIL` 分别映射为等待认证、支付完成、客户取消和支付失败;`1169` MUST 被视为客户仍需选择付款方式并完成认证,不得把扫码动作直接视为支付成功。
-- **FR-OFF-010**:只有 `returnCode="0000"` 或状态查询 `COMPLETE` 且订单号、交易号、`payInfo` 金额合计和 `paymentProvider` 通过核对后,系统才能写入支付事实;币种固定使用本地请求事实 `TWD`,不得要求付款/状态响应返回未公开保证的币种字段。
-- **FR-OFF-011**:若 `payInfo` 合计与订单金额不一致,系统 MUST 记录真实交易、阻止正常履约并创建唯一全额退款意图;未知退款结果继续只读核实,不允许再次扫码。
-- **FR-OFF-012**:Online 和 Offline MUST 共用订单级支付锁和活跃尝试唯一门禁。任一模式存在活跃尝试时,另一模式不得发起;重复 Offline 请求只返回已有状态,不发送新 `oneTimeKey`。
+- **FR-OFF-008**:付款请求超时、响应丢失、未识别返回码或结果不明确后 MUST 按 `lineOrderId` 调用 `GET /v4/payments/orders/{orderId}/check`,MUST NOT 重复提交付款请求或重用 `oneTimeKey`。只有明确无资金副作用的 Pay 拒绝码或 Check `CANCEL/FAIL` 才能释放活跃键。
+- **FR-OFF-009**:系统 MUST 将 `1145`、`1169`、`AUTH_READY` 和本地 `WAITING_AUTH` 映射为 `AUTH_REQUIRED`;将付款超时、响应丢失、重复请求待核实和本地 `REQUEST_UNKNOWN` 映射为 `PROCESSING`;将 `COMPLETE/CANCEL/FAIL` 分别映射为 `PAID/CANCELLED/FAILED`。`AUTH_REQUIRED` 和 `PROCESSING` 均 MUST 阻止再次扫码;`1169` 表示客户仍需选择付款方式并完成认证,不得把扫码动作直接视为支付成功。
+- **FR-OFF-010**:只有 `returnCode="0000"` 或状态查询 `COMPLETE` 且订单号、交易号、`payInfo` 金额合计和 `paymentProvider` 通过核对后,系统才能写入支付事实;`paymentProvider` MUST 非空并原值保存,`TSP`、`EPI` 是已知值但其他非空值同样允许,缺失或空值 MUST 进入 `MANUAL_REVIEW` 且不得触发正常履约。币种固定使用本地请求事实 `TWD`,不得要求付款/状态响应返回未公开保证的币种字段。
+- **FR-OFF-011**:若 `payInfo` 合计与订单金额不一致,系统 MUST 持久化真实交易和 LINE 实际扣款额,并在同一事务内以实际扣款额创建唯一全额退款意图;阻止正常履约,未知退款结果继续只读核实。无论退款最终成功、失败或进入人工核对,原订单都永久关闭支付,不允许再次扫码,商家必须创建新订单。
+- **FR-OFF-012**:Online 和 Offline MUST 共用订单级支付锁和活跃尝试唯一门禁。任一模式存在活跃尝试时,另一模式不得发起;重复 Offline 请求和唯一键冲突只返回已有状态,不发送新 `oneTimeKey`。所有状态 CAS 失败后 MUST 重读持久化状态,不得用过期对象继续副作用。
 - **FR-OFF-013**:Offline 全额退款 MUST 使用 `POST /v4/payments/orders/{lineOrderId}/refund`;Online 退款继续使用现有按 `transactionId` 的端点,分派依据只能是持久化的 `payment_mode`。
 - **FR-OFF-014**:`oneTimeKey` MUST NOT 落库、写入普通日志、异常、响应或网关审计原文;Offline REQUEST 审计只能保存移除该字段或替换为 `<redacted>` 的摘要。
 - **FR-OFF-015**:本增量 MUST 复用现有门店级 LINE Pay 凭证和已经批准的 Channel Secret 保存/回显策略,不新增凭证表或 Offline 开关;生产启用前必须由业务方确认对应商户具备 Offline API 权限。
@@ -626,10 +636,10 @@ Header: token
 
 1. **Given** 当前商家拥有一笔新建的单门店 `MERCHANT` 订单,**When** 提交有效 My Code,**Then** 后端生成唯一 `lineOrderId`、创建 `OFFLINE` 尝试并按服务端金额请求 LINE Pay。
 2. **Given** 用户端订单、历史订单、跨商家门店或多门店输入,**When** 请求扫码支付,**Then** 在调用 LINE Pay 前拒绝。
-3. **Given** LINE 返回 `AUTH_READY` 或 `1169`,**When** 商家查询,**Then** 返回 `AUTH_REQUIRED/PROCESSING` 并阻止第二次扫码。
-4. **Given** LINE 返回 `COMPLETE` 且所有资金字段匹配,**When** 状态落库,**Then** 订单、结算和通知副作用至多执行一次。
+3. **Given** LINE 返回 `1145`、`1169`、`AUTH_READY` 或本地状态为 `WAITING_AUTH`,**When** 商家查询,**Then** 返回 `AUTH_REQUIRED`、首次使用 30 分钟认证截止且后续不顺延,并阻止第二次扫码;付款超时、响应丢失、未识别返回码、重复请求待核实或本地状态为 `REQUEST_UNKNOWN` 时返回 `PROCESSING` 并同样阻止第二次扫码。
+4. **Given** LINE 返回 `COMPLETE`、订单号和交易号匹配、`payInfo` 金额合计一致且 `paymentProvider` 非空,**When** 状态落库,**Then** 原值保存 `paymentProvider`,并且订单、结算和通知副作用至多执行一次;`paymentProvider` 缺失或为空时进入 `MANUAL_REVIEW` 且不触发正常履约。
 5. **Given** 付款请求达到超时或返回不确定结果,**When** 恢复任务运行,**Then** 只按 `lineOrderId` 查询,不再次请求付款。
 6. **Given** LINE 返回 `CANCEL/FAIL`,**When** 状态落库,**Then** 释放活跃尝试,客户必须生成新的 My Code 才能再次支付。
-7. **Given** 支付金额不一致,**When** 后端处理,**Then** 不触发正常履约,创建唯一全额退款意图并保持订单阻断直到资金结果明确。
+7. **Given** 支付金额不一致,**When** 后端处理,**Then** 持久化实际扣款额、不触发正常履约,并在同一事务创建以实际扣款额为金额的唯一全额退款意图;无论退款结果如何,原订单永久保持支付阻断,商家必须创建新订单。
 8. **Given** 已支付 Offline 流水需要退款,**When** 取消订单或平台退款流程处理,**Then** 使用 `lineOrderId` 调用 Offline Refund;Online 回归仍使用 `transactionId`。
 9. **Given** 任意日志或异常路径,**When** 请求结束,**Then** 数据库和日志中均不存在原始 `oneTimeKey`。

+ 70 - 2
specs/019-line-pay/tasks.md

@@ -1,8 +1,10 @@
-# Tasks: LINE Pay 直连支付
+# Tasks: LINE Pay Online / Offline API v4 支付
 
-**Input**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/api.md`  
+**Input**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/api.md`, `offline-merchant-scan-design.md`
 **Tests**: 本功能涉及支付、凭证、外部 HTTP 和并发状态,所有实现任务必须先添加失败测试。
 
+> `T001~T043` 及 `T033A~T033G` 是已完成的 Online API v4 任务。2026-08-18 Offline 商家扫码增量从 `T044` 开始;生产代码、测试源码、SQL、规格及统一验证已经完成。
+
 ## Phase 1: Planning and safety gates
 
 - [x] T001 将 `.specify/feature.json` 指向 `specs/019-line-pay`,并把 `spec.md` 标记为 2026-08-12 已批准
@@ -74,6 +76,51 @@
 - [x] T042 运行 `git diff --check`、检查 `git status --short` 和 diff 统计;确认无数据库执行、无废弃代码改动、无用户文件覆盖
 - [x] T043 对照 `quickstart.md` 完成可在本地模拟的验收;真实 Sandbox、LINE App 和商户 App Scheme 真机验证留在交付说明
 
+## Phase 8: Offline persistence foundation
+
+**Purpose**: 为 Online/Offline 并存提供可审计的订单来源、支付模式和状态 CAS;本阶段阻塞 US6。
+
+- [x] T044 [P] 在 `ruoyi-system/src/test/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImplTest.java` 和 `ruoyi-system/src/test/java/com/ruoyi/system/mapper/LinePayOfflinePersistenceContractTest.java` 添加失败测试,覆盖 `paymentMode=ONLINE/OFFLINE` 显式写入、历史默认 ONLINE、`order_source/payment_mode/captured_amount` Mapper 往返、Offline 状态 CAS、认证截止改写、只有 CANCEL/FAIL 释放活跃键以及 `AMOUNT_MISMATCH/MANUAL_REVIEW` 持续阻断(FR-OFF-002、FR-OFF-003、FR-OFF-009、FR-OFF-012)
+- [x] T045 修改 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java`、`ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderLinePayment.java`、`ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLinePaymentService.java`、`ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java`、`ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderMapper.java`、`ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLinePaymentMapper.java`、`ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml`、`ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml`,并在 `updatesql/sql.md` 追加 `order_source`、`payment_mode`、`captured_amount` 和索引 DDL,使 T044 通过;默认值只兼容历史,业务创建必须显式写值且不得执行 SQL(FR-OFF-002、FR-OFF-003、FR-OFF-016)
+
+**Checkpoint**: Entity、Mapper、Service 和 SQL 已能稳定区分订单来源及 Online/Offline 模式,现有 Online 行仍按 ONLINE 工作。
+
+## Phase 9: User Story 6 - 商家 My Code 扫码支付 (Priority: P1) 🎯 Offline MVP
+
+**Goal**: 商家使用自己的 token 为商家端创建的单门店 LINE Pay 订单扫描客户 My Code,后端安全完成 Offline 自动请款、状态收敛和全额退款,不修改任何前端。
+
+**Independent Test**: 使用商家 token 创建 `MERCHANT` 单门店订单并提交 Sandbox `oneTimeKey`,验证 Pay 只调用一次,`AUTH_REQUIRED/PROCESSING/PAID/CANCELLED/FAILED/MANUAL_REVIEW` 正确收敛,数据库和日志不含 My Code;用户端、历史、跨商家和多门店订单在网关调用前拒绝;Offline 退款按 `lineOrderId`、Online 退款仍按 `transactionId`。
+
+### Tests for User Story 6
+
+> 以下测试任务必须先编写并确认失败,再执行对应实现任务;不在中途运行完整回归。
+
+- [x] T046 [P] [US6] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderShOprateControllerTest.java` 添加失败测试,覆盖有效商家身份、普通/夜市商家 `shId=userId + PosStore.userId=userId`、其他摊位商家 `mdId=InfoUser.storeId`、单门店 items、跨店组合与非法归属拒绝以及新订单显式写 `orderSource=MERCHANT`(FR-OFF-003、FR-OFF-004)
+- [x] T047 [P] [US6] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/utils/linepay/LinePayClientTest.java` 添加失败契约测试,精确断言 Offline Pay/Check/Refund method、URI、UTF-8 JSON/HMAC、18 位 oneTimeKey、Pay 40 秒、Check/Refund 20 秒、无 redirect/Capture/Void/device headers 和字符串 transactionId(FR-OFF-005、FR-OFF-006、FR-OFF-007、FR-OFF-008、FR-OFF-013)
+- [x] T048 [P] [US6] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayGatewayAuditServiceTest.java` 添加失败安全测试,覆盖 Offline REQUEST 正常、异常、不可解析响应和审计失败路径均不保存或输出原始 `oneTimeKey`,且 Channel Secret/HMAC/堆栈不进入响应(FR-OFF-014、FR-OFF-015)
+- [x] T049 [P] [US6] 新建 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java`,添加失败测试覆盖订单来源/归属/payType/金额/凭证二次门禁、服务端请求事实、0000 严格核对、非空 paymentProvider 原值保存、1145/1169 映射、未知码阻断、CAS 冲突、明确失败释放和重复请求复用(FR-OFF-004、FR-OFF-005、FR-OFF-009、FR-OFF-010、FR-OFF-012)
+- [x] T050 [P] [US6] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java` 与 `ruoyi-admin/src/test/java/com/ruoyi/app/task/LinePayReconcileTaskTest.java` 添加失败测试,覆盖 Offline AUTH_READY/COMPLETE/CANCEL/FAIL、1152/1172/1198/1199/190X/9000、未识别返回码、超时后只按 lineOrderId Check、首次 30 分钟认证截止且不顺延、24 小时未知截止和任务按 paymentMode 分派(FR-OFF-007~FR-OFF-010)
+- [x] T051 [P] [US6] 在 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayRefundTest.java` 与 `ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayCancellationRaceTest.java` 添加失败测试,覆盖 Offline 按 lineOrderId 全退、Online 按 transactionId 回归、金额不一致实际扣款额持久化/原子唯一退款意图/禁止履约/原订单永久关闭、退款 UNKNOWN 只读 Retrieve 以及取消后迟到付款(FR-OFF-011、FR-OFF-013)
+
+### Implementation for User Story 6
+
+- [x] T052 [US6] 修改 `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java`,在创建父子订单前完成商家身份、`shId/mdId` 门店归属和单门店校验,并为创建出的 `PosOrder` 显式写 `orderSource=MERCHANT`,使 T046 通过;不修改 `UserOrderController` 的 USER 默认行为(FR-OFF-001、FR-OFF-003、FR-OFF-004)
+- [x] T053 [P] [US6] 在 `ruoyi-admin/src/main/java/com/ruoyi/app/utils/linepay/` 新建 `LinePayOfflineRequest.java` 并扩展 `LinePayClient.java` 的 `payOffline/checkOffline/refundOfflineFull`,复用 signer/transport/base URL allowlist,使 T047 通过(FR-OFF-005~FR-OFF-008、FR-OFF-013、FR-OFF-015)
+- [x] T054 [P] [US6] 修改 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayGatewayAuditService.java`,为 Offline REQUEST 建立只接收脱敏摘要的审计入口并阻止异常拼接请求对象,使 T048 通过;原始 `oneTimeKey` 不得离开内存调用链(FR-OFF-014)
+- [x] T055 [US6] 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflinePayRequest.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflineResult.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java` 和 `ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderLinePayOfflineController.java`,实现 `/system/orderShOprate/linePay/offline/pay` 与 `/status`、共享支付锁、OFFLINE 尝试创建/复用、明确 DTO 注解和规范状态响应,使 T049 通过(FR-OFF-001、FR-OFF-004~FR-OFF-010、FR-OFF-012)
+- [x] T056 [US6] 修改 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/task/LinePayReconcileTask.java`、`ruoyi-system/src/main/java/com/ruoyi/system/service/IPosOrderLinePaymentService.java`、`ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLinePaymentServiceImpl.java`、`ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderLinePaymentMapper.java` 和 `ruoyi-system/src/main/resources/mapper/chanting/PosOrderLinePaymentMapper.xml`,使恢复流程按 `paymentMode` 分派并落实 WAITING_AUTH、REQUEST_UNKNOWN、AMOUNT_MISMATCH、MANUAL_REVIEW、CAS 冲突重读与支付副作用至多一次,使 T050 通过(FR-OFF-008~FR-OFF-012)
+- [x] T057 [US6] 修改 `ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayRefundService.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayFactService.java` 和 `ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosOrderLineRefundServiceImpl.java`,复用现有唯一退款意图与退款状态 CAS,按持久化 `paymentMode` 选择 Offline lineOrderId 或 Online transactionId 全额退款,并以实际扣款额原子完成金额不一致及取消竞态收口,使 T051 通过(FR-OFF-011、FR-OFF-013)
+- [x] T058 [P] [US6] 在 `ruoyi-admin/src/main/resources/i18n/messages.properties`、`ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties`、`ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties`、`ruoyi-admin/src/main/resources/i18n/messages_en_US.properties`、`ruoyi-admin/src/main/resources/i18n/messages_vi.properties` 添加集合完全一致的商家扫码、归属、My Code、状态和人工核对业务错误 key;Controller/Service 只使用 `MessageUtils.message(...)`(FR-OFF-004、FR-OFF-014)
+
+**Checkpoint**: US6 可在不修改 uni-app 或管理前端的情况下独立完成 Sandbox My Code 付款、轮询、恢复和退款验收,并保持 Online 回归。
+
+## Phase 10: Offline verification and delivery
+
+**Purpose**: 所有 Offline 生产代码、测试源码、SQL 和规格完成后统一验证,不把局部测试结果描述为功能完成。
+
+- [x] T059 使用 JDK 21 依次运行 `mvn -pl ruoyi-system -am -Dtest='*Line*Test' -Dsurefire.failIfNoSpecifiedTests=false test` 与 `mvn -pl ruoyi-admin -am -Dtest='*LinePayOffline*Test,PosOrderShOprateControllerTest,*LinePay*Test,OrderLifecycleServiceTest,OmgPaymentControllerTest' -Dsurefire.failIfNoSpecifiedTests=false test`:2026-08-18 系统层 14/14 通过;管理组合命令 139/139 通过(管理模块 128、依赖模块 11),零失败、零错误、零跳过。既有 Maven model/Lombok/deprecation/动态 Java agent 警告不影响结果。
+- [x] T060 运行 `mvn -pl ruoyi-admin -am -DskipTests package`、`git diff --check` 和 `quickstart.md` 的 Offline 可执行验收:2026-08-18 JDK 21 七模块打包成功,差异、Controller 契约、oneTimeKey 生产代码流向、五套 i18n 各 9 个同名 key 和 SQL 追加范围检查通过;仅修改后端、测试、规格、`CLAUDE.md` 与 `updatesql/sql.md`。未执行数据库 DDL、真实 Sandbox My Code/生产商户 Offline 权限验证和前端构建。
+
 ## Dependencies
 
 - T004-T006 可并行写测试;T007-T010 使其通过后才能开始业务编排。
@@ -81,6 +128,24 @@
 - T018-T020 可并行;T021-T024 完成 create/query/redirect。
 - T025-T027 可并行;T028 完成后再接任务和订单入口 T029-T033。
 - 后端契约稳定后执行 T034-T038;最后统一 T039-T043。
+- Offline 增量先完成 T044-T045;T046-T051 可按不同测试文件并行编写,且必须在 T052-T057 对应实现前确认失败。
+- T052、T053、T054 可在基础字段完成后并行;T055 依赖 T052-T054,T056 依赖 T055,T057 依赖 T055-T056。
+- T058 可与 T053-T057 并行;T059-T060 必须等 T044-T058 全部完成后统一执行。
+
+## Parallel Example: User Story 6
+
+```text
+并行测试批次:T046 商家下单、T047 网关契约、T048 审计脱敏、T049 付款服务、T050 恢复、T051 退款。
+并行实现批次:T052 商家下单来源、T053 Offline Client、T054 审计脱敏、T058 i18n。
+串行集成批次:T055 Controller/付款编排 → T056 状态恢复 → T057 退款与异常金额。
+```
+
+## Offline Implementation Strategy
+
+1. **Foundation**: 完成 T044-T045,先让数据库模型可区分来源和模式。
+2. **Offline MVP**: 完成 T046-T055,可独立演示商家创建订单、扫码发起和本地状态查询。
+3. **Recovery and refund**: 完成 T056-T058,补齐未知结果、金额异常、取消和退款闭环。
+4. **Unified verification**: 最后一次性执行 T059-T060;真实商户 Offline 权限或 Sandbox My Code 不可用时仅记录为未执行,不伪报通过。
 
 ## Definition of Done
 
@@ -90,3 +155,6 @@
 - 取消竞态最终只一次全额退款;payment 保持 PAID,退款成功后 order.payStatus=2。
 - App 查询、回跳、任务、平台动作都通过稳定键定位正确尝试。
 - 平台四语、权限、SQL 和最小 OMG create 门禁完成;目标测试、编译与前端检查通过。
+- 只有 `order_source=MERCHANT` 的新商家单门店订单可发起 Offline;用户端、历史、跨商家和多门店订单在网关调用前拒绝。
+- Online/Offline 共用唯一活跃门禁并只按 `payment_mode` 选择 Pay/Check/Refund 端点,任何未知资金结果不得盲目重复副作用。
+- 原始 `oneTimeKey` 在数据库、普通日志、异常、响应和网关审计中均不存在;金额不一致不履约、只创建一次全额退款且原订单不再支付。

+ 15 - 0
updatesql/sql.md

@@ -854,3 +854,18 @@ INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_typ
 DROP TABLE IF EXISTS pos_order_omg_refund;
 DROP TABLE IF EXISTS pos_order_omg_payment;
 ```
+
+## 2026-08-18 LINE Pay 商家扫码 Offline 模式(019-line-pay)
+
+```sql
+-- 仅记录迁移脚本,由开发者确认环境后手动执行;Codex 不直接执行数据库变更。
+ALTER TABLE pos_order
+  ADD COLUMN order_source VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin
+  NOT NULL DEFAULT 'USER' COMMENT '订单来源:USER/MERCHANT' AFTER pay_type;
+
+ALTER TABLE pos_order_line_payment
+  ADD COLUMN captured_amount INT NULL COMMENT 'LINE实际扣款金额' AFTER amount,
+  ADD COLUMN payment_mode VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin
+  NOT NULL DEFAULT 'ONLINE' COMMENT 'LINE支付模式:ONLINE/OFFLINE' AFTER currency,
+  ADD KEY idx_line_payment_mode_due (payment_mode, status, next_reconcile_at, id);
+```