Pārlūkot izejas kodu

实现外卖无人接单自动取消及OMG退款

qmj 4 dienas atpakaļ
vecāks
revīzija
4090189b43
44 mainītis faili ar 1424 papildinājumiem un 74 dzēšanām
  1. 4 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java
  2. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java
  3. 11 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java
  4. 17 5
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java
  5. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java
  6. 15 1
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java
  7. 5 2
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java
  8. 295 21
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java
  9. 28 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java
  10. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java
  11. 22 5
      ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java
  12. 15 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java
  13. 212 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java
  14. 42 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java
  15. 20 2
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  16. 4 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  17. 18 0
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayService.java
  18. 116 10
      ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java
  19. 1 0
      ruoyi-admin/src/main/resources/application.yml
  20. 2 1
      ruoyi-admin/src/main/resources/i18n/messages.properties
  21. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  22. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  23. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  24. 2 1
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  25. 22 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java
  26. 12 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java
  27. 27 3
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java
  28. 14 2
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java
  29. 95 0
      ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundServiceTest.java
  30. 1 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java
  31. 2 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderLifecycleServiceTest.java
  32. 65 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutServiceTest.java
  33. 32 8
      ruoyi-admin/src/test/java/com/ruoyi/app/pay/PayMethodSwitchServiceTest.java
  34. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java
  35. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java
  36. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java
  37. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java
  38. 5 0
      ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java
  39. 13 4
      ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml
  40. 7 2
      ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml
  41. 31 0
      specs/037-order-no-rider-auto-cancel/plan.md
  42. 91 0
      specs/037-order-no-rider-auto-cancel/spec.md
  43. 10 0
      specs/037-order-no-rider-auto-cancel/tasks.md
  44. 69 0
      updatesql/sql.md

+ 4 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentCreateService.java

@@ -102,6 +102,10 @@ public class OmgPaymentCreateService {
                 OmgPaymentAttempt attempt = attempts.createCreated(order.getDdId(), merchantTradeNo,
                         order.getStoreId(), credential.getMerchantId(), order.getAmount(),
                         credential.getHashKey(), credential.getHashIv());
+                if (OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL.equals(form.gatewayUrl())
+                        && attempts.markProductionEnvironment(attempt.getId()) != 1) {
+                    throw new IllegalStateException("OMG production environment snapshot failed");
+                }
                 OmgCreatePaymentResponse response = new OmgCreatePaymentResponse(form.gatewayUrl(), form.fields());
                 return new OmgPaymentCreateOutcome(response, attempt.getId(), order.getDdId(), userId,
                         order.getStoreId(), order.getAmount(), maskMerchantTradeNo(merchantTradeNo));

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentFormFactory.java

@@ -15,6 +15,8 @@ import java.util.Map;
 public class OmgPaymentFormFactory {
     public static final String STAGE_GATEWAY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/AioCheckOut/V5";
+    public static final String PRODUCTION_GATEWAY_URL =
+            "https://payment.funpoint.com.tw/Cashier/AioCheckOut/V5";
     private static final DateTimeFormatter TRADE_DATE = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
     private static final ZoneId TAIPEI = ZoneId.of("Asia/Taipei");
 
@@ -57,7 +59,8 @@ public class OmgPaymentFormFactory {
         Map<String, String> signedFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
         fields.put("CheckMacValue", signer.sign(signedFields, hashKey, hashIv));
         Map<String, String> responseFields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
-        return new OmgPaymentForm(STAGE_GATEWAY_URL, responseFields);
+        return new OmgPaymentForm(properties.isProduction() ? PRODUCTION_GATEWAY_URL : STAGE_GATEWAY_URL,
+                responseFields);
     }
 
     private static String safeOrderReference(String orderId) {

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentGatewayFacts.java

@@ -16,5 +16,15 @@ public record OmgPaymentGatewayFacts(
         Date paymentDate,
         Date tradeDate,
         BigDecimal paymentTypeChargeFee,
-        Integer simulatePaid) {
+        Integer simulatePaid,
+        String creditRefundId) {
+    public OmgPaymentGatewayFacts(OmgPaymentFactSource source, String merchantId,
+                                  String merchantTradeNo, int amount, int resultCode,
+                                  String resultMessage, String tradeNo, String paymentType,
+                                  Date paymentDate, Date tradeDate, BigDecimal paymentTypeChargeFee,
+                                  Integer simulatePaid) {
+        this(source, merchantId, merchantTradeNo, amount, resultCode, resultMessage,
+                tradeNo, paymentType, paymentDate, tradeDate, paymentTypeChargeFee,
+                simulatePaid, null);
+    }
 }

+ 17 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentNotifyService.java

@@ -40,6 +40,8 @@ import java.util.Locale;
 /** Verifies and applies one final OMG payment notification transactionally. */
 @Service
 public class OmgPaymentNotifyService {
+    @Autowired(required = false)
+    private OmgPaymentRefundService refundService;
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentNotifyService.class);
     private static final int STATUS_PAID = 1;
     private static final int STATUS_SUPERSEDED = 3;
@@ -109,7 +111,8 @@ public class OmgPaymentNotifyService {
                 OmgPaymentFactSource.NOTIFY, attempt.getMerchantId(), merchantTradeNo,
                 attempt.getAmount(), facts.rtnCode, facts.rtnMsg, facts.tradeNo,
                 facts.paymentType, facts.paymentDate, facts.tradeDate,
-                facts.paymentTypeChargeFee, facts.simulatePaid);
+                facts.paymentTypeChargeFee, facts.simulatePaid,
+                request.value("gwsr"));
         return applyVerifiedFacts(order, attempt, gatewayFacts) != null;
     }
 
@@ -170,10 +173,18 @@ public class OmgPaymentNotifyService {
 
         requireSingleUpdate(attempts.markPaid(toUpdate(attempt, facts)), "mark paid");
         attempts.supersedeOtherCreated(attempt.getDdId(), attempt.getId());
-        if (order.getPayStatus() == null || order.getPayStatus() != 1L) {
-            requireSingleUpdate(attempts.markOrderPaid(attempt.getDdId()), "mark order paid");
-            openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
-            pushPaymentSuccessToUser(order);
+        boolean canFulfill = Long.valueOf(0L).equals(order.getPayStatus())
+                && order.getState() != null && order.getState() != 4L
+                && ("2".equals(order.getPayType()) || "5".equals(order.getPayType()));
+        if (canFulfill) {
+            if (attempts.markOrderPaid(attempt.getDdId()) == 1) {
+                openDeliveryOrderToRiders(attempt.getDdId(), order.getState());
+                pushPaymentSuccessToUser(order);
+            } else if (refundService != null) {
+                refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
+            }
+        } else if (refundService != null) {
+            refundService.createRefundIntentForAttempt(attempt.getId(), "LATE_PAYMENT");
         }
         int otherPaid = attempts.countOtherPaidAttempts(attempt.getDdId(), attempt.getId());
         if (order.getState() != null && order.getState() == 4L) {
@@ -321,6 +332,7 @@ public class OmgPaymentNotifyService {
         OmgPaymentAttempt update = new OmgPaymentAttempt();
         update.setId(attempt.getId());
         update.setTradeNo(facts.tradeNo());
+        update.setCreditRefundId(facts.creditRefundId());
         update.setRtnCode(facts.resultCode());
         update.setRtnMsg(limit(facts.resultMessage(), 200));
         update.setPaymentType(limit(facts.paymentType(), 20));

+ 17 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentProperties.java

@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.stereotype.Component;
 
 import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
 
 @Component
 @ConfigurationProperties(prefix = "omgpay")
@@ -14,6 +16,21 @@ public class OmgPaymentProperties {
     private String returnUrl;
     private String orderResultUrl;
     private String appReturnUrl;
+    /** TEST is the safe default. Production credentials must explicitly enable PRODUCTION. */
+    private String environment = "TEST";
+    /** MerchantID -> CreditCheckCode, supplied through protected deployment configuration. */
+    private Map<String, String> creditCheckCodes = new HashMap<>();
+
+    public boolean isProduction() {
+        return "PRODUCTION".equalsIgnoreCase(environment);
+    }
+
+    public String getEnvironment() { return environment; }
+    public void setEnvironment(String environment) { this.environment = environment; }
+    public Map<String, String> getCreditCheckCodes() { return creditCheckCodes; }
+    public void setCreditCheckCodes(Map<String, String> creditCheckCodes) {
+        this.creditCheckCodes = creditCheckCodes == null ? new HashMap<>() : creditCheckCodes;
+    }
 
     public String getReturnUrl() {
         return returnUrl;

+ 15 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryGateway.java

@@ -10,6 +10,7 @@ import org.apache.http.impl.client.HttpClients;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.http.util.EntityUtils;
 import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Autowired;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -22,6 +23,8 @@ import java.util.Map;
 public class OmgPaymentQueryGateway {
     static final String STAGE_QUERY_URL =
             "https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
+    static final String PRODUCTION_QUERY_URL =
+            "https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5";
     private static final int MAX_RESPONSE_BYTES = 32 * 1024;
     private static final RequestConfig REQUEST_CONFIG = RequestConfig.custom()
             .setConnectTimeout(5_000)
@@ -29,10 +32,21 @@ public class OmgPaymentQueryGateway {
             .setSocketTimeout(10_000)
             .build();
 
+    private final OmgPaymentProperties properties;
+
+    @Autowired
+    public OmgPaymentQueryGateway(OmgPaymentProperties properties) {
+        this.properties = properties;
+    }
+
     public String query(Map<String, String> fields) {
+        return query(fields, properties.isProduction() ? "PRODUCTION" : "TEST");
+    }
+
+    public String query(Map<String, String> fields, String environment) {
         List<NameValuePair> form = new ArrayList<>();
         fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
-        HttpPost post = new HttpPost(STAGE_QUERY_URL);
+        HttpPost post = new HttpPost("PRODUCTION".equals(environment) ? PRODUCTION_QUERY_URL : STAGE_QUERY_URL);
         post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
         try (CloseableHttpClient client = HttpClients.custom()
                 .setDefaultRequestConfig(REQUEST_CONFIG)

+ 5 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentQueryService.java

@@ -99,7 +99,9 @@ public class OmgPaymentQueryService {
     private OmgQueryPaymentResponse queryAttempt(OmgPaymentAttempt attempt, String orderId,
                                                   Long storeId, String source) {
         try {
-            Map<String, String> response = parser.parse(gateway.query(buildRequest(attempt)));
+            Map<String, String> response = parser.parse(attempt.getGatewayEnvironment() == null
+                    ? gateway.query(buildRequest(attempt))
+                    : gateway.query(buildRequest(attempt), attempt.getGatewayEnvironment()));
             validateResponse(response, attempt);
             String tradeStatus = response.get("TradeStatus");
             String status = synchronizeIfFinal(tradeStatus, response, attempt);
@@ -223,7 +225,8 @@ public class OmgPaymentQueryService {
                 attempt.getMerchantId(), attempt.getMerchantTradeNo(), attempt.getAmount(),
                 tradeStatus, "TradeStatus=" + tradeStatus, tradeNo, paymentType,
                 parseDate(fields.get("PaymentDate"), paid),
-                parseDate(fields.get("TradeDate"), paid), fee, null);
+                parseDate(fields.get("TradeDate"), paid), fee, null,
+                blankToNull(fields.get("gwsr")));
     }
 
     private static OmgQueryPaymentResponse toResponse(String status, Map<String, String> fields) {

+ 295 - 21
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java

@@ -1,43 +1,317 @@
 package com.ruoyi.app.omgpay;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.alibaba.fastjson2.JSONArray;
+import com.ruoyi.system.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.domain.OmgPaymentOrderSnapshot;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.AUTH_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.ORDER_REQUIRED;
-import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT;
+import java.time.LocalTime;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 
-/**
- * Test-stage refund boundary. OMG publishes no refund endpoint for its test environment,
- * so this service must fail closed before reading payment facts or changing local state.
- */
+import static com.ruoyi.app.omgpay.OmgPaymentErrorCode.*;
+
+/** 持久化 OMG 退款意图,并按正式信用卡交易事实推进退款。 */
 @Service
 public class OmgPaymentRefundService {
     private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
+    private final OmgPaymentProperties properties;
+    private final IOmgPaymentAttemptService attempts;
+    private final JdbcTemplate jdbc;
+    private final OmgCheckMacSigner signer;
+    private final OmgRefundGateway gateway;
+    private final OmgQueryResponseParser parser;
+    private final Clock clock;
+
+    /** 保留测试环境边界测试的无参构造。 */
+    public OmgPaymentRefundService() {
+        this(new OmgPaymentProperties(), null, null, null, null, null);
+    }
+
+    @Autowired
+    public OmgPaymentRefundService(OmgPaymentProperties properties,
+                                   IOmgPaymentAttemptService attempts,
+                                   JdbcTemplate jdbc,
+                                   OmgCheckMacSigner signer,
+                                   OmgRefundGateway gateway,
+                                   OmgQueryResponseParser parser) {
+        this(properties, attempts, jdbc, signer, gateway, parser, Clock.systemUTC());
+    }
+
+    OmgPaymentRefundService(OmgPaymentProperties properties,
+                            IOmgPaymentAttemptService attempts, JdbcTemplate jdbc,
+                            OmgCheckMacSigner signer, OmgRefundGateway gateway,
+                            OmgQueryResponseParser parser, Clock clock) {
+        this.properties = properties;
+        this.attempts = attempts;
+        this.jdbc = jdbc;
+        this.signer = signer;
+        this.gateway = gateway;
+        this.parser = parser;
+        this.clock = clock;
+    }
 
     public Void refund(Long userId, String orderId) {
         if (userId == null) {
-            throw business(AUTH_REQUIRED);
+            throw new OmgPaymentBusinessException(AUTH_REQUIRED);
+        }
+        if (orderId == null || orderId.isBlank() || orderId.trim().length() > 64) {
+            throw new OmgPaymentBusinessException(ORDER_REQUIRED);
+        }
+        if (!properties.isProduction()) {
+            log.warn("OMG refund unavailable in test environment orderId={}",
+                    OmgPaymentController.safeLogOrderId(orderId));
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        }
+        OmgPaymentOrderSnapshot order = attempts.selectOrder(orderId.trim());
+        if (order == null || !userId.equals(order.getUserId())
+                || !Long.valueOf(4L).equals(order.getState())
+                || attempts.selectPaidByDdId(orderId.trim()) == null) {
+            throw new OmgPaymentBusinessException(PAYMENT_REFUND_FAILED);
         }
-        String normalizedOrderId = normalizeOrderId(orderId);
-        log.warn("OMG refund blocked environment=TEST, orderId={}, userId={}, reason=GATEWAY_UNAVAILABLE",
-                OmgPaymentController.safeLogOrderId(normalizedOrderId), userId);
-        throw business(PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT);
+        createRefundIntent(orderId.trim(), "USER_REQUEST");
+        return null;
     }
 
-    private static String normalizeOrderId(String orderId) {
-        if (orderId == null || orderId.isBlank()) {
-            throw business(ORDER_REQUIRED);
+    /** 与取消/迟到回调同事务,按支付尝试唯一键幂等写入。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntent(String orderId, String source) {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE dd_id = ? AND attempt_status = 1",
+                source, orderId);
+        Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE dd_id = ?",
+                Integer.class, orderId);
+        if (count == null || count == 0) {
+            throw new IllegalStateException("OMG captured payment missing for refund");
         }
-        String normalized = orderId.trim();
-        if (normalized.length() > 64) {
-            throw business(ORDER_REQUIRED);
+    }
+
+    /** 迟到或重复支付只退本次实收,不能误退订单上另一笔有效付款。 */
+    @Transactional(rollbackFor = Exception.class)
+    public void createRefundIntentForAttempt(Long attemptId, String source) {
+        int inserted = jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                        + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                        + "SELECT id, dd_id, ?, 'PENDING', NOW(), NOW() "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                source, attemptId);
+        if (inserted == 0) {
+            Integer existing = jdbc.queryForObject("SELECT COUNT(*) FROM pos_order_omg_refund WHERE attempt_id=?",
+                    Integer.class, attemptId);
+            if (existing == null || existing == 0) {
+                throw new IllegalStateException("OMG captured attempt missing for refund");
+            }
         }
-        return normalized;
     }
 
-    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
-        return new OmgPaymentBusinessException(code);
+    /** 补偿已取消或已切换渠道后的迟到实收,唯一键保证重复扫描幂等。 */
+    public void ensureOrphanedCapturedIntents() {
+        jdbc.update("INSERT IGNORE INTO pos_order_omg_refund "
+                + "(attempt_id, dd_id, source, status, create_time, update_time) "
+                + "SELECT a.id, a.dd_id, 'LATE_PAYMENT_SCAN', 'PENDING', NOW(), NOW() "
+                + "FROM pos_order_omg_attempt a JOIN pos_order o ON o.dd_id=a.dd_id "
+                + "WHERE a.attempt_status=1 AND (o.state=4 OR o.pay_type NOT IN ('2','5')) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_refund r WHERE r.attempt_id=a.id)");
+    }
+
+    /** 退款事实已确认后补偿订单支付状态,避免单次任务在两条更新之间中断。 */
+    public void reconcileRefundedOrderStatuses() {
+        jdbc.update("UPDATE pos_order o SET o.pay_status=2 WHERE o.state=4 AND o.pay_status=1 "
+                + "AND o.pay_type IN ('2','5') "
+                + "AND EXISTS (SELECT 1 FROM pos_order_omg_attempt a WHERE a.dd_id=o.dd_id AND a.attempt_status=1) "
+                + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                + "WHERE a.dd_id=o.dd_id AND a.attempt_status=1 "
+                + "AND (r.id IS NULL OR r.status!='REFUNDED'))");
+    }
+
+    public List<Long> pendingIds(int limit) {
+        return jdbc.queryForList("SELECT id FROM pos_order_omg_refund "
+                        + "WHERE status IN ('PENDING', 'ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW()) ORDER BY id LIMIT ?",
+                Long.class, limit);
     }
+
+    /** 不确定结果必须先查询事实;同一状态不重放同一退款动作。 */
+    public void processOne(Long refundId) {
+        if (!properties.isProduction()) {
+            jdbc.update("UPDATE pos_order_omg_refund SET last_error='TEST_ENVIRONMENT', update_time=NOW() "
+                    + "WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN')", refundId);
+            return;
+        }
+        int claimed = jdbc.update("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD(NOW(), INTERVAL 2 MINUTE), "
+                        + "update_time=NOW() WHERE id=? AND status IN ('PENDING','ACTION_UNKNOWN') "
+                        + "AND (lease_until IS NULL OR lease_until < NOW())", refundId);
+        if (claimed != 1) {
+            return;
+        }
+        boolean actionStarted = false;
+        String priorStatus = "PENDING";
+        try {
+            RefundRow row = jdbc.queryForObject("SELECT attempt_id, status, last_action, last_gateway_status "
+                            + "FROM pos_order_omg_refund WHERE id=?", (rs, n) ->
+                    new RefundRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4)), refundId);
+            priorStatus = row.status();
+            OmgPaymentAttempt attempt = loadAttempt(row.attemptId());
+            String checkCode = properties.getCreditCheckCodes().get(attempt.getMerchantId());
+            if (!"PRODUCTION".equals(attempt.getGatewayEnvironment())) {
+                defer(refundId, "PAYMENT_CREATED_IN_TEST_ENVIRONMENT", "MANUAL_REVIEW");
+                return;
+            }
+            if (attempt.getCreditRefundId() == null || !attempt.getCreditRefundId().matches("[0-9]+")
+                    || checkCode == null || !checkCode.matches("[0-9]+") || attempt.getTradeNo() == null) {
+                defer(refundId, "MISSING_CREDIT_REFUND_CREDENTIAL", priorStatus);
+                return;
+            }
+            String gatewayStatus = creditStatus(attempt, checkCode);
+            if (List.of("已取消", "銀行已追回", "FULL_REFUND_CONFIRMED").contains(gatewayStatus)) {
+                markRefunded(refundId, attempt);
+                return;
+            }
+            String action = nextAction(gatewayStatus);
+            if (action == null) {
+                defer(refundId, "GATEWAY_STATUS_REQUIRES_REVIEW", "MANUAL_REVIEW");
+                return;
+            }
+            if (row.lastAction() != null
+                    && (row.lastAction().equals(action) || Objects.equals(row.lastGatewayStatus(), gatewayStatus))) {
+                defer(refundId, "WAITING_FOR_GATEWAY_STATE_CHANGE", "ACTION_UNKNOWN");
+                return;
+            }
+            if (inAutoCloseWindow()) {
+                defer(refundId, "AUTO_CLOSE_WINDOW", priorStatus);
+                return;
+            }
+            // 先持久化动作可能已发出,超时后只查事实。
+            jdbc.update("UPDATE pos_order_omg_refund SET status='ACTION_UNKNOWN', last_action=?, "
+                            + "last_gateway_status=?, last_action_time=NOW(), attempt_count=attempt_count+1, "
+                            + "last_error=NULL, update_time=NOW() WHERE id=?",
+                    action, gatewayStatus, refundId);
+            actionStarted = true;
+            Map<String, String> response = parser.parse(gateway.doAction(actionRequest(attempt, action)));
+            if (!attempt.getMerchantId().equals(response.get("MerchantID"))
+                    || !attempt.getMerchantTradeNo().equals(response.get("MerchantTradeNo"))
+                    || !attempt.getTradeNo().equals(response.get("TradeNo"))) {
+                defer(refundId, "REFUND_RESPONSE_IDENTITY_MISMATCH", "MANUAL_REVIEW");
+            } else if (!"1".equals(response.get("RtnCode"))) {
+                defer(refundId, "REFUND_ACTION_REJECTED", "MANUAL_REVIEW");
+            }
+        } catch (Exception error) {
+            log.error("OMG refund result uncertain refundId={}", refundId, error);
+            defer(refundId, "GATEWAY_RESULT_UNKNOWN", actionStarted ? "ACTION_UNKNOWN" : priorStatus);
+        } finally {
+            jdbc.update("UPDATE pos_order_omg_refund SET lease_until=NULL WHERE id=?", refundId);
+        }
+    }
+
+    private OmgPaymentAttempt loadAttempt(Long attemptId) {
+        return jdbc.queryForObject("SELECT id, dd_id, merchant_trade_no, merchant_id, amount, "
+                        + "hash_key_snapshot, hash_iv_snapshot, trade_no, credit_refund_id, gateway_environment "
+                        + "FROM pos_order_omg_attempt WHERE id=? AND attempt_status=1",
+                (rs, n) -> {
+                    OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+                    attempt.setId(rs.getLong("id"));
+                    attempt.setDdId(rs.getString("dd_id"));
+                    attempt.setMerchantTradeNo(rs.getString("merchant_trade_no"));
+                    attempt.setMerchantId(rs.getString("merchant_id"));
+                    attempt.setAmount(rs.getInt("amount"));
+                    attempt.setHashKeySnapshot(rs.getString("hash_key_snapshot"));
+                    attempt.setHashIvSnapshot(rs.getString("hash_iv_snapshot"));
+                    attempt.setTradeNo(rs.getString("trade_no"));
+                    attempt.setCreditRefundId(rs.getString("credit_refund_id"));
+                    attempt.setGatewayEnvironment(rs.getString("gateway_environment"));
+                    return attempt;
+                }, attemptId);
+    }
+
+    private String creditStatus(OmgPaymentAttempt attempt, String checkCode) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("CreditRefundId", attempt.getCreditRefundId());
+        fields.put("CreditAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CreditCheckCode", checkCode);
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        JSONObject response = JSON.parseObject(gateway.queryCredit(fields));
+        if (response == null || (response.getString("RtnMsg") != null
+                && !response.getString("RtnMsg").isBlank())) {
+            throw new IllegalStateException("OMG credit query rejected");
+        }
+        JSONObject value = response.getJSONObject("RtnValue");
+        if (value == null || !attempt.getCreditRefundId().equals(value.getString("TradeID"))
+                || attempt.getAmount() != value.getIntValue("amount")
+                || value.getString("status") == null) {
+            throw new IllegalStateException("OMG credit query identity mismatch");
+        }
+        JSONArray closeData = value.getJSONArray("close_data");
+        if (closeData != null) {
+            long recovered = 0;
+            for (int i = 0; i < closeData.size(); i++) {
+                JSONObject item = closeData.getJSONObject(i);
+                if (item != null && "銀行已追回".equals(item.getString("status"))) {
+                    recovered += item.getLongValue("amount");
+                }
+            }
+            if (recovered == attempt.getAmount()) {
+                return "FULL_REFUND_CONFIRMED";
+            }
+        }
+        return value.getString("status");
+    }
+
+    private Map<String, String> actionRequest(OmgPaymentAttempt attempt, String action) {
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("MerchantID", attempt.getMerchantId());
+        fields.put("MerchantTradeNo", attempt.getMerchantTradeNo());
+        fields.put("TradeNo", attempt.getTradeNo());
+        fields.put("Action", action);
+        fields.put("TotalAmount", String.valueOf(attempt.getAmount()));
+        fields.put("CheckMacValue", signer.sign(fields, attempt.getHashKeySnapshot(), attempt.getHashIvSnapshot()));
+        return fields;
+    }
+
+    static String nextAction(String status) {
+        return switch (status) {
+            case "已授權" -> "N";
+            case "要關帳" -> "E";
+            case "已關帳" -> "R";
+            default -> null;
+        };
+    }
+
+    private boolean inAutoCloseWindow() {
+        LocalTime now = ZonedDateTime.now(clock.withZone(ZoneId.of("Asia/Taipei"))).toLocalTime();
+        return !now.isBefore(LocalTime.of(20, 15)) && now.isBefore(LocalTime.of(20, 30));
+    }
+
+    private void markRefunded(Long refundId, OmgPaymentAttempt attempt) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status='REFUNDED', last_error=NULL, "
+                + "refund_time=NOW(), update_time=NOW() WHERE id=?", refundId);
+        jdbc.update("UPDATE pos_order SET pay_status=2 WHERE dd_id=? AND state=4 "
+                        + "AND pay_status=1 AND pay_type IN ('2','5') "
+                        + "AND NOT EXISTS (SELECT 1 FROM pos_order_omg_attempt a "
+                        + "LEFT JOIN pos_order_omg_refund r ON r.attempt_id=a.id "
+                        + "WHERE a.dd_id=pos_order.dd_id AND a.attempt_status=1 "
+                        + "AND (r.id IS NULL OR r.status!='REFUNDED'))", attempt.getDdId());
+    }
+
+    private void defer(Long refundId, String reason, String status) {
+        jdbc.update("UPDATE pos_order_omg_refund SET status=?, last_error=?, update_time=NOW() WHERE id=?",
+                status, reason, refundId);
+    }
+
+    private record RefundRow(Long attemptId, String status, String lastAction, String lastGatewayStatus) { }
 }

+ 28 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundTask.java

@@ -0,0 +1,28 @@
+package com.ruoyi.app.omgpay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/** 由若依 sys_job 调度;每条意图有数据库租约,重复执行不会并发发送动作。 */
+@Component("omgPaymentRefundTask")
+public class OmgPaymentRefundTask {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundTask.class);
+    private final OmgPaymentRefundService refunds;
+
+    public OmgPaymentRefundTask(OmgPaymentRefundService refunds) {
+        this.refunds = refunds;
+    }
+
+    public void reconcilePending() {
+        refunds.ensureOrphanedCapturedIntents();
+        for (Long id : refunds.pendingIds(100)) {
+            try {
+                refunds.processOne(id);
+            } catch (RuntimeException error) {
+                log.error("OMG refund reconciliation failed id={}", id, error);
+            }
+        }
+        refunds.reconcileRefundedOrderStatuses();
+    }
+}

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgRefundGateway.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.omgpay;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.util.EntityUtils;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/** OMG 正式信用卡查询/退款 HTTP 边界;调用前由服务确认环境。 */
+@Component
+public class OmgRefundGateway {
+    private static final String QUERY_URL = "https://payment.funpoint.com.tw/CreditDetail/QueryTrade/V2";
+    private static final String ACTION_URL = "https://payment.funpoint.com.tw/CreditDetail/DoAction";
+    private static final int MAX_RESPONSE_BYTES = 32 * 1024;
+    private static final RequestConfig CONFIG = RequestConfig.custom()
+            .setConnectTimeout(5_000).setConnectionRequestTimeout(5_000)
+            .setSocketTimeout(10_000).build();
+
+    public String queryCredit(Map<String, String> fields) {
+        return post(QUERY_URL, fields);
+    }
+
+    public String doAction(Map<String, String> fields) {
+        return post(ACTION_URL, fields);
+    }
+
+    private String post(String url, Map<String, String> fields) {
+        List<NameValuePair> form = new ArrayList<>();
+        fields.forEach((name, value) -> form.add(new BasicNameValuePair(name, value)));
+        HttpPost post = new HttpPost(url);
+        post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8));
+        try (CloseableHttpClient client = HttpClients.custom()
+                .setDefaultRequestConfig(CONFIG).disableRedirectHandling().build();
+             CloseableHttpResponse response = client.execute(post)) {
+            int status = response.getStatusLine().getStatusCode();
+            if (status < 200 || status >= 300 || response.getEntity() == null
+                    || response.getEntity().getContentLength() > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway returned invalid HTTP response");
+            }
+            byte[] bytes = EntityUtils.toByteArray(response.getEntity());
+            if (bytes.length > MAX_RESPONSE_BYTES) {
+                throw new IllegalStateException("OMG refund gateway response is oversized");
+            }
+            return new String(bytes, StandardCharsets.UTF_8);
+        } catch (IOException error) {
+            throw new IllegalStateException("OMG refund gateway unavailable", error);
+        }
+    }
+}

+ 22 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/order/DeliveryOrderNotificationService.java

@@ -100,10 +100,18 @@ public class DeliveryOrderNotificationService {
         });
     }
 
-    /** 已分配骑手的外送订单取消后,通知除取消人外的相关方。 */
+    /** 外送订单取消后,通知除取消人外的相关方。 */
     public void notifyOrderCancelled(PosOrder order, Long operatorId) {
+        notifyOrderCancelled(order, operatorId, false);
+    }
+
+    public void notifyNoRiderCancelled(PosOrder order) {
+        notifyOrderCancelled(order, null, true);
+    }
+
+    private void notifyOrderCancelled(PosOrder order, Long operatorId, boolean noRiderTimeout) {
         if (order == null || !Long.valueOf(0L).equals(order.getType())
-                || order.getQsId() == null || infoUserService == null) {
+                || infoUserService == null) {
             return;
         }
         runAfterCommit(() -> {
@@ -120,7 +128,7 @@ public class DeliveryOrderNotificationService {
             }
             List<InfoUser> recipients = infoUserService.listByIds(recipientIds);
             if (recipients != null) {
-                recipients.forEach(recipient -> sendCancelledOrder(recipient, order));
+                recipients.forEach(recipient -> sendCancelledOrder(recipient, order, noRiderTimeout));
             }
             if (merchantNotificationRouter != null && shouldNotifyMerchant(operatorId)) {
                 String ddId = String.valueOf(order.getDdId());
@@ -155,6 +163,10 @@ public class DeliveryOrderNotificationService {
     }
 
     void sendCancelledOrder(InfoUser recipient, PosOrder order) {
+        sendCancelledOrder(recipient, order, false);
+    }
+
+    private void sendCancelledOrder(InfoUser recipient, PosOrder order, boolean noRiderTimeout) {
         if (recipient == null || recipient.getUserId() == null) {
             return;
         }
@@ -174,8 +186,11 @@ public class DeliveryOrderNotificationService {
                     // 只有原链路实际通知的用户带场景,取消操作者和非用户通知范围不变。
                     String userBody = OrderPushBodyDto.getUserJson(ddId, "4", 0, 1,
                             UserNotificationScene.ORDER_CANCELLED);
+                    String messageKey = noRiderTimeout && "3".equals(order.getPayType())
+                            ? "no.message.push.order.no.rider.line.cancelled"
+                            : "no.message.push.order.cancelled";
                     PayPush.userPushHandleLocal(push, pushEventService, recipient.getUserId(), recipient.getCid(),
-                            "no.message.push.message", "no.message.push.order.cancelled", userBody, "", ddId);
+                            "no.message.push.message", messageKey, userBody, "", ddId);
                 }
             }
         });
@@ -199,7 +214,9 @@ public class DeliveryOrderNotificationService {
     }
 
     private boolean isAvailableForRider(PosOrder order) {
-        return isPaidUnassignedDeliveryOrder(order) && !Integer.valueOf(1).equals(order.getSelfDelivery());
+        return isPaidUnassignedDeliveryOrder(order)
+                && Boolean.TRUE.equals(order.getIsDisplay())
+                && !Integer.valueOf(1).equals(order.getSelfDelivery());
     }
 
     private boolean isPaidUnassignedDeliveryOrder(PosOrder order) {

+ 15 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderLifecycleService.java

@@ -171,7 +171,9 @@ public class OrderLifecycleService {
         if (!Long.valueOf(0L).equals(order.getType()) || !Long.valueOf(0L).equals(order.getState())
                 || !Long.valueOf(0L).equals(order.getDeliveryStatus())
                 || !Long.valueOf(1L).equals(order.getPayStatus())
-                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null) {
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus()) || order.getQsId() != null
+                || !Boolean.TRUE.equals(order.getIsDisplay())
+                || Integer.valueOf(1).equals(order.getSelfDelivery())) {
             throw conflict();
         }
         PosOrder update = new PosOrder();
@@ -284,8 +286,14 @@ public class OrderLifecycleService {
 
         PosOrder update = new PosOrder();
         update.setPayStatus(1L);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
         applyCas(order, update);
         order.setPayStatus(1L);
+        if (update.getEffectiveAt() != null) {
+            order.setEffectiveAt(update.getEffectiveAt());
+        }
         logStatusChange(order, request, operatorId, operatorName,
                 request.getExpectedState(), request.getExpectedDeliveryStatus(),
                 request.getExpectedPayStatus(), request.getExpectedAfterSaleStatus(),
@@ -497,6 +505,12 @@ public class OrderLifecycleService {
                 wrapper.eq(PosOrder::getDeliveryStatus, order.getDeliveryStatus());
             }
         }
+        if (update.getQsId() != null) {
+            wrapper.eq(PosOrder::getIsDisplay, true)
+                    .isNull(PosOrder::getQsId)
+                    .and(w -> w.isNull(PosOrder::getSelfDelivery)
+                            .or().eq(PosOrder::getSelfDelivery, 0));
+        }
         if (!posOrderService.update(update, wrapper)) {
             throw conflict();
         }

+ 212 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutService.java

@@ -0,0 +1,212 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.ruoyi.app.omgpay.OmgPaymentRefundService;
+import com.ruoyi.app.pay.LinePayRefundService;
+import com.ruoyi.app.utils.DateUtil;
+import com.ruoyi.common.core.domain.entity.SysDictData;
+import com.ruoyi.common.utils.DictUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderLinePayment;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+import java.util.List;
+
+/** 独立外送单的预约开放和无人接单收口。每单取消在独立事务中完成。 */
+@Service
+public class PosOrderNoRiderTimeoutService {
+    private final IPosOrderService orders;
+    private final IPosOrderLinePaymentService linePayments;
+    private final LinePayRefundService lineRefunds;
+    private final OmgPaymentRefundService omgRefunds;
+    private final OrderLogHelper orderLog;
+    private final DeliveryOrderNotificationService notifications;
+    private final JdbcTemplate jdbc;
+
+    public PosOrderNoRiderTimeoutService(IPosOrderService orders,
+                                        IPosOrderLinePaymentService linePayments,
+                                        LinePayRefundService lineRefunds,
+                                        OmgPaymentRefundService omgRefunds,
+                                        OrderLogHelper orderLog,
+                                        DeliveryOrderNotificationService notifications,
+                                        JdbcTemplate jdbc) {
+        this.orders = orders;
+        this.linePayments = linePayments;
+        this.lineRefunds = lineRefunds;
+        this.omgRefunds = omgRefunds;
+        this.orderLog = orderLog;
+        this.notifications = notifications;
+        this.jdbc = jdbc;
+    }
+
+    public int timeoutMinutes() {
+        try {
+            List<SysDictData> values = DictUtils.getDictCache("pos_order_auto_cancel");
+            if (values != null && !values.isEmpty()) {
+                int minutes = Integer.parseInt(values.get(0).getDictValue());
+                if (minutes >= 1 && minutes <= 1440) {
+                    return minutes;
+                }
+            }
+        } catch (RuntimeException ignored) {
+            // 字典不可用时仍按已确认的 30 分钟口径处理。
+        }
+        return 30;
+    }
+
+    public List<PosOrder> scheduledToOpen(Date now, int limit) {
+        Date openBefore = Date.from(now.toInstant().plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .isNotNull(PosOrder::getDelryTime).ne(PosOrder::getDelryTime, "")
+                .apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", openBefore)
+                .orderByAsc(PosOrder::getId).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean openScheduled(Long id, Date now) {
+        PosOrder order = orders.getById(id);
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getEffectiveAt() == null
+                || Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || !order.getDdId().equals(order.getParentDdId())
+                || order.getDelryTime() == null || order.getDelryTime().isBlank()) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (start == null || start.toInstant().isAfter(now.toInstant()
+                .plusSeconds((long) DateUtil.getAppoinmentOrderHandleTime() * 60))) {
+            return false;
+        }
+        boolean opened = orders.update(new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .isNotNull(PosOrder::getEffectiveAt)
+                .eq(PosOrder::getIsDisplay, false).isNull(PosOrder::getQsId)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .eq(PosOrder::getDelryTime, order.getDelryTime())
+                .set(PosOrder::getIsDisplay, true).set(PosOrder::getDisplayTime, now));
+        if (opened) {
+            order.setIsDisplay(true);
+            order.setDisplayTime(now);
+            notifications.notifyOrderAvailable(order);
+        }
+        return opened;
+    }
+
+    public List<PosOrder> expiredCandidates(Date now, int minutes, int limit) {
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        return orders.list(new LambdaQueryWrapper<PosOrder>()
+                .eq(PosOrder::getType, 0L).eq(PosOrder::getState, 0L)
+                .eq(PosOrder::getDeliveryStatus, 0L).eq(PosOrder::getPayStatus, 1L)
+                .eq(PosOrder::getAfterSaleStatus, 0L).eq(PosOrder::getIsDisplay, true)
+                .isNull(PosOrder::getQsId).isNotNull(PosOrder::getEffectiveAt)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .and(w -> w.isNull(PosOrder::getDelryTime).or().eq(PosOrder::getDelryTime, "")
+                        .or().apply("STR_TO_DATE(LEFT(delry_time, 16), '%Y-%m-%d %H:%i') <= {0}", cutoff))
+                .orderByAsc(PosOrder::getEffectiveAt).last("LIMIT " + limit));
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public boolean cancelExpired(Long id, Date now, int minutes) {
+        PosOrder order = orders.getById(id);
+        if (!eligible(order, now, minutes)) {
+            return false;
+        }
+        Date cutoff = Date.from(now.toInstant().minusSeconds((long) minutes * 60));
+        LambdaUpdateWrapper<PosOrder> guard = new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, id).eq(PosOrder::getType, 0L)
+                .eq(PosOrder::getState, 0L).eq(PosOrder::getDeliveryStatus, 0L)
+                .eq(PosOrder::getPayStatus, 1L).eq(PosOrder::getAfterSaleStatus, 0L)
+                .eq(PosOrder::getIsDisplay, true).isNull(PosOrder::getQsId)
+                .le(PosOrder::getEffectiveAt, cutoff).le(PosOrder::getDisplayTime, cutoff)
+                .and(w -> w.isNull(PosOrder::getSelfDelivery).or().eq(PosOrder::getSelfDelivery, 0))
+                .apply("parent_dd_id = dd_id")
+                .set(PosOrder::getState, 4L);
+        if (order.getDelryTime() == null) {
+            guard.isNull(PosOrder::getDelryTime);
+        } else {
+            guard.eq(PosOrder::getDelryTime, order.getDelryTime());
+        }
+        boolean cancelled = orders.update(guard);
+        if (!cancelled) {
+            return false;
+        }
+        order.setState(4L);
+        if ("3".equals(order.getPayType())) {
+            for (PosOrderLinePayment payment : linePayments.getByDdId(order.getDdId())) {
+                if ("PAID".equals(payment.getStatus())) {
+                    lineRefunds.createRefundIntent(payment.getId(), "AUTO_NO_RIDER");
+                }
+            }
+        } else if (OrderLifecycleService.isCardWalletPayType(order.getPayType())) {
+            omgRefunds.createRefundIntent(order.getDdId(), "AUTO_NO_RIDER");
+        } else if (OrderLifecycleService.PAY_TYPE_TRANSFER.equals(order.getPayType())) {
+            jdbc.update("INSERT IGNORE INTO pos_order_manual_refund (dd_id, reason, status, create_time) VALUES (?, ?, 'PENDING', NOW())",
+                    order.getDdId(), "AUTO_NO_RIDER");
+        }
+        orderLog.logSync(order.getDdId(), 0, null, "SYSTEM", "超时无骑手自动取消");
+        notifications.notifyNoRiderCancelled(order);
+        return true;
+    }
+
+    static boolean eligible(PosOrder order, Date now, int minutes) {
+        if (order == null || !Long.valueOf(0L).equals(order.getType())
+                || !Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(1L).equals(order.getPayStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || !Boolean.TRUE.equals(order.getIsDisplay()) || order.getQsId() != null
+                || Integer.valueOf(1).equals(order.getSelfDelivery())
+                || order.getEffectiveAt() == null || order.getDisplayTime() == null
+                || !order.getDdId().equals(order.getParentDdId())) {
+            return false;
+        }
+        Date start = appointmentStart(order.getDelryTime());
+        if (order.getDelryTime() != null && !order.getDelryTime().isBlank() && start == null) {
+            return false;
+        }
+        long baseline = Math.max(order.getEffectiveAt().getTime(), order.getDisplayTime().getTime());
+        if (start != null) {
+            baseline = Math.max(baseline, start.getTime());
+        }
+        return baseline <= now.getTime() - (long) minutes * 60_000;
+    }
+
+    static Date appointmentStart(String window) {
+        if (window == null || window.isBlank()) {
+            return null;
+        }
+        try {
+            String[] parts = window.trim().split(" ");
+            String start = parts[1].split("-")[0];
+            return Date.from(LocalDateTime.parse(parts[0] + "T" + start)
+                    .atZone(ZoneId.of("Asia/Taipei")).toInstant());
+        } catch (RuntimeException invalid) {
+            return null;
+        }
+    }
+}

+ 42 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutTask.java

@@ -0,0 +1,42 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+/** 由若依 sys_job 每分钟调度,事务由逐单服务方法提供。 */
+@Component("posOrderNoRiderTimeoutTask")
+public class PosOrderNoRiderTimeoutTask {
+    private static final Logger log = LoggerFactory.getLogger(PosOrderNoRiderTimeoutTask.class);
+    private final PosOrderNoRiderTimeoutService service;
+
+    public PosOrderNoRiderTimeoutTask(PosOrderNoRiderTimeoutService service) {
+        this.service = service;
+    }
+
+    public void openScheduledOrders() {
+        Date now = new Date();
+        for (PosOrder order : service.scheduledToOpen(now, 200)) {
+            try {
+                service.openScheduled(order.getId(), new Date());
+            } catch (RuntimeException error) {
+                log.error("Open scheduled food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+
+    public void cancelExpiredOrders() {
+        Date now = new Date();
+        int minutes = service.timeoutMinutes();
+        for (PosOrder order : service.expiredCandidates(now, minutes, 200)) {
+            try {
+                service.cancelExpired(order.getId(), new Date(), minutes);
+            } catch (RuntimeException error) {
+                log.error("Cancel no-rider food order failed, id={}", order.getId(), error);
+            }
+        }
+    }
+}

+ 20 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -633,7 +633,14 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setPayStatus(1L);
-        posOrderService.saveOrUpdate(update);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
+        if (!posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId()).eq(PosOrder::getPayType, OrderLifecycleService.PAY_TYPE_CASH)
+                .eq(PosOrder::getPayStatus, 0L).ne(PosOrder::getState, 4L))) {
+            throw new ServiceException(MessageUtils.message("no.order.cash.cancelled"));
+        }
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认现金收款");
@@ -666,7 +673,18 @@ public class PosOrderShOprateController extends BaseController {
         PosOrder update = new PosOrder();
         update.setId(order.getId());
         update.setPayStatus(1L);
-        posOrderService.saveOrUpdate(update);
+        if (Long.valueOf(0L).equals(order.getType()) && order.getEffectiveAt() == null) {
+            update.setEffectiveAt(new Date());
+        }
+        if (!posOrderService.update(update, new LambdaUpdateWrapper<PosOrder>()
+                .eq(PosOrder::getId, order.getId()).eq(PosOrder::getPayType, OrderLifecycleService.PAY_TYPE_TRANSFER)
+                .eq(PosOrder::getPayStatus, 0L).ne(PosOrder::getState, 4L))) {
+            throw new ServiceException(MessageUtils.message("no.order.transfer.cancelled"));
+        }
+        order.setPayStatus(1L);
+        if (update.getEffectiveAt() != null) {
+            order.setEffectiveAt(update.getEffectiveAt());
+        }
         InfoUser shUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, Long.valueOf(new JwtUtil().getusid(token))));
         String shName = shUser != null ? shUser.getNickName() : "";
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "确认转账收款");

+ 4 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -255,7 +255,6 @@ public class UserOrderController extends BaseController {
             String subddId = isMultiStore ? ddId + String.format("%03d", index) : ddId;
             PosOrder posOrder = new PosOrder();
             posOrder.setIsDisplay(false);
-            handleIsDisplay(posOrder);
             posOrder.setShdzId(input.getShdzId());
             // 设置子订单的基本信息
             posOrder.setDdId(subddId);
@@ -269,6 +268,7 @@ public class UserOrderController extends BaseController {
             posOrder.setRemarks(item.getRemarks());
             posOrder.setType(item.getType());
             posOrder.setDelryTime(item.getDelryTime());
+            handleIsDisplay(posOrder);
             posOrder.setFood(JSON.toJSONString(item.getFood()));
             if("1".equals(input.getPaymentMethod())){
                 posOrder.setCollectPayment("1");
@@ -299,6 +299,9 @@ public class UserOrderController extends BaseController {
             // 外送到付(payType=1):payStatus=1;现金(4)自取/堂食同到付线下收款语义:payStatus=1;其他:payStatus=0
             if (item.getType() != null && item.getType() == 0 && "1".equals(input.getPaymentMethod())) {
                 posOrder.setPayStatus(1L);
+                if (!isMultiStore) {
+                    posOrder.setEffectiveAt(new Date());
+                }
             } else if ("4".equals(input.getPaymentMethod())
                     && (item.getType() != null && (item.getType() == 1 || item.getType() == 2))) {
                 posOrder.setPayStatus(1L);

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

@@ -193,6 +193,24 @@ public class LinePayService {
         return reconcilePayment(paymentId, source, null);
     }
 
+    /** 支付方式切换前只读取网关收款事实;不确认付款,查询失败则拒绝切换。 */
+    public boolean isCapturedForSwitch(PosOrderLinePayment payment) {
+        PosStoreLinePay credential = credentialService.getById(payment.getCredentialId());
+        if (credential == null) {
+            throw new IllegalStateException("LINE Pay credential version not found");
+        }
+        try {
+            LinePayResponse response = retrieveWithCredentialRecovery(payment, "PAY_SWITCH",
+                    credential, toCredential(credential)).response();
+            if (response == null || !(response.isSuccess() || "1150".equals(response.returnCode()))) {
+                throw new IllegalStateException("LINE Pay switch query is inconclusive");
+            }
+            return capturedPayment(response, payment) != null;
+        } catch (Exception error) {
+            throw new IllegalStateException("LINE Pay switch query failed", error);
+        }
+    }
+
     public String reconcilePayment(Long paymentId, String source, String reconcileLeaseOwner) {
         PosOrderLinePayment payment = paymentService.getById(paymentId);
         if (payment == null) {

+ 116 - 10
ruoyi-admin/src/main/java/com/ruoyi/app/pay/PayMethodSwitchService.java

@@ -1,9 +1,12 @@
 package com.ruoyi.app.pay;
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
+import com.ruoyi.app.omgpay.OmgPaymentQueryService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
 import com.ruoyi.app.omgpay.OmgPaymentMethod;
+import com.ruoyi.app.order.DeliveryOrderNotificationService;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.pay.dto.PayMethodSwitchRequest;
 import com.ruoyi.common.core.domain.AjaxResult;
@@ -11,12 +14,19 @@ 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.omgpay.domain.OmgPaymentAttempt;
+import com.ruoyi.system.omgpay.service.IOmgPaymentAttemptService;
 import com.ruoyi.system.service.IInfoUserService;
 import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
 import com.ruoyi.system.utils.OrderLogHelper;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.LinkedHashMap;
+import java.util.Date;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -45,17 +55,39 @@ public class PayMethodSwitchService {
     private final LinePayService linePayService;
     private final OrderLogHelper orderLogHelper;
     private final IInfoUserService infoUserService;
+    private final IPosOrderLinePaymentService linePayments;
+    private final IOmgPaymentAttemptService omgAttempts;
+    private final OmgPaymentQueryService omgQueryService;
+    private final DeliveryOrderNotificationService notifications;
 
     public PayMethodSwitchService(IPosOrderService orderService,
                                   OmgPaymentRetryService omgPaymentRetryService,
                                   LinePayService linePayService,
                                   OrderLogHelper orderLogHelper,
                                   IInfoUserService infoUserService) {
+        this(orderService, omgPaymentRetryService, linePayService, orderLogHelper,
+                infoUserService, null, null, null, null);
+    }
+
+    @Autowired
+    public PayMethodSwitchService(IPosOrderService orderService,
+                                  OmgPaymentRetryService omgPaymentRetryService,
+                                  LinePayService linePayService,
+                                  OrderLogHelper orderLogHelper,
+                                  IInfoUserService infoUserService,
+                                  IPosOrderLinePaymentService linePayments,
+                                  IOmgPaymentAttemptService omgAttempts,
+                                  OmgPaymentQueryService omgQueryService,
+                                  DeliveryOrderNotificationService notifications) {
         this.orderService = orderService;
         this.omgPaymentRetryService = omgPaymentRetryService;
         this.linePayService = linePayService;
         this.orderLogHelper = orderLogHelper;
         this.infoUserService = infoUserService;
+        this.linePayments = linePayments;
+        this.omgAttempts = omgAttempts;
+        this.omgQueryService = omgQueryService;
+        this.notifications = notifications;
     }
 
     /**
@@ -68,17 +100,50 @@ public class PayMethodSwitchService {
         String orderId = normalizeOrderId(request == null ? null : request.getOrderId());
         String method = normalizeMethod(request == null ? null : request.getPaymentMethod());
         PosOrder order = requireSwitchableOrder(userId, orderId);
-        // 到付/现金是线下收款,仅自取/堂食可用(与下单现金闸门同规则,外送单不放行)
+        // 外送单允许切到到付,现金仍只允许自取/堂食。
         requireOfflineAllowed(method, order);
 
         String previous = order.getPayType();
-        if (!method.equals(previous)) {
-            // 先回写再拉起:两个渠道的创建校验都按 order.payType 把关,顺序颠倒会被拒
-            order.setPayType(method);
-            if (!orderService.updateById(order)) {
+        boolean deliveryCod = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
+                && Long.valueOf(0L).equals(order.getType());
+        Date effectiveAt = deliveryCod ? new Date() : null;
+        if (deliveryCod && (!Long.valueOf(0L).equals(order.getState())
+                || !Long.valueOf(0L).equals(order.getDeliveryStatus())
+                || !Long.valueOf(0L).equals(order.getAfterSaleStatus())
+                || order.getQsId() != null)) {
+            throw new ServiceException(MessageUtils.message("no.pay.switch.order.invalid"));
+        }
+        if (!method.equals(previous) || deliveryCod) {
+            if (deliveryCod) {
+                verifyNoCapturedPayment(userId, orderId);
+            }
+            LambdaUpdateWrapper<PosOrder> update = new LambdaUpdateWrapper<PosOrder>()
+                    .eq(PosOrder::getDdId, orderId)
+                    .eq(PosOrder::getUserId, userId)
+                    .eq(PosOrder::getPayType, previous)
+                    .eq(PosOrder::getPayStatus, 0L)
+                    .isNull(PosOrder::getEffectiveAt)
+                    .in(PosOrder::getState, 0L, 1L, 2L)
+                    .set(PosOrder::getPayType, method);
+            if (deliveryCod) {
+                update.set(PosOrder::getPayStatus, 1L)
+                        .set(PosOrder::getEffectiveAt, effectiveAt)
+                        .eq(PosOrder::getType, 0L)
+                        .eq(PosOrder::getDeliveryStatus, 0L)
+                        .eq(PosOrder::getAfterSaleStatus, 0L)
+                        .isNull(PosOrder::getQsId);
+            }
+            if (!orderService.update(update)) {
                 throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
             }
-            writeSwitchLog(orderId, userId, previous, method);
+            if (!method.equals(previous)) {
+                writeSwitchLog(orderId, userId, previous, method);
+            }
+            if (deliveryCod && notifications != null) {
+                order.setPayStatus(1L);
+                order.setEffectiveAt(effectiveAt);
+                notifications.notifyOrderAvailable(order);
+            }
         }
 
         Map<String, Object> data = new LinkedHashMap<>();
@@ -88,6 +153,48 @@ public class PayMethodSwitchService {
         return AjaxResult.success(data);
     }
 
+    /** 查询未结尝试;已收款或查证失败时停止切换,迟到实收由渠道退款意图兜底。 */
+    private void verifyNoCapturedPayment(Long userId, String orderId) {
+        if (linePayments != null) {
+            List<PosOrderLinePayment> payments = linePayments.getByDdId(orderId);
+            if (payments == null) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+            }
+            for (PosOrderLinePayment payment : payments) {
+                if ("PAID".equals(payment.getStatus())) {
+                    throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+                }
+                if (!List.of("FAILED", "CANCELLED_OR_EXPIRED", "REFUNDED").contains(payment.getStatus())) {
+                    if ("MANUAL_REVIEW".equals(payment.getStatus())
+                            || "CONFIRM_UNKNOWN".equals(payment.getStatus())) {
+                        throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                    }
+                    try {
+                        if (linePayService.isCapturedForSwitch(payment)) {
+                            throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+                        }
+                    } catch (ServiceException known) {
+                        throw known;
+                    } catch (RuntimeException inconclusive) {
+                        throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                    }
+                }
+            }
+        }
+        if (omgAttempts != null) {
+            if (omgAttempts.selectPaidByDdId(orderId) != null) {
+                throw new ServiceException(MessageUtils.message("no.pay.switch.already.paid"));
+            }
+            OmgPaymentAttempt created = omgAttempts.selectActiveCreatedByDdId(orderId);
+            if (created != null) {
+                String status = omgQueryService.reconcile(created).status();
+                if (!"UNPAID".equals(status) && !"FAILED".equals(status)) {
+                    throw new ServiceException(MessageUtils.message("no.pay.switch.failed"));
+                }
+            }
+        }
+    }
+
     /** 按新方式路由到对应渠道拉起支付;到付/现金无支付环节返回 null。 */
     private Object createPayment(Long userId, String orderId, String method) {
         if (OrderLifecycleService.isCardWalletPayType(method)) {
@@ -127,11 +234,10 @@ public class PayMethodSwitchService {
         return order;
     }
 
-    /** 到付(1)/现金(4) 仅自取(type=1)/堂食(type=2)订单可切换,外送单保持仅商家创建时可用。 */
+    /** 外送可切到付(1);现金(4)仅自取/堂食。 */
     private void requireOfflineAllowed(String method, PosOrder order) {
-        boolean offline = OrderLifecycleService.PAY_TYPE_OFFLINE.equals(method)
-                || OrderLifecycleService.PAY_TYPE_CASH.equals(method);
-        if (offline && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
+        if (OrderLifecycleService.PAY_TYPE_CASH.equals(method)
+                && (order.getType() == null || (order.getType() != 1L && order.getType() != 2L))) {
             throw new ServiceException(MessageUtils.message("no.pay.switch.offline.restricted"));
         }
     }

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

@@ -38,6 +38,7 @@ newebpay:
   mpg-version: "2.3"
 
 omgpay:
+  environment: ${OMG_PAY_ENVIRONMENT:TEST}
   return-url: https://foodieapi.waimai-paotui.com/pay/omg/notify
   order-result-url: https://foodieapi.waimai-paotui.com/pay/omg/result
   # 支付结果页固定跳回 App 支付成功页面,并由后端追加 ddId。

+ 2 - 1
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -123,6 +123,7 @@ no.message.push.message=訊息
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.order.cancelled=訂單已取消
+no.message.push.order.no.rider.line.cancelled=訂單因無騎手接單已取消,LINE Pay 退款處理中
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.recharge.success=充值成功
@@ -433,7 +434,7 @@ no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 no.pay.switch.order.invalid=Đơn hàng không tồn tại hoặc không thể thanh toán
 no.pay.switch.already.paid=Đơn hàng đã được thanh toán
 no.pay.switch.method.invalid=Phương thức thanh toán không hợp lệ
-no.pay.switch.offline.restricted=Tiền mặt / thanh toán khi nhận hàng chỉ dành cho đơn tự lấy hoặc dùng tại chỗ
+no.pay.switch.offline.restricted=Đơn giao hàng không thể chuyển sang thanh toán bằng tiền mặt
 no.pay.switch.failed=Đổi phương thức thanh toán thất bại, vui lòng thử lại sau
 flash.delivery.billing.save.failed=Không thể ghi nhận thu nhập giao hàng nhanh, vui lòng thử lại
 flash.delivery.billing.income=Thu nhập giao hàng nhanh (thanh toán trực tiếp cho tài xế)

+ 2 - 1
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -123,6 +123,7 @@ no.message.push.message=Message
 no.message.push.delivery.personnel.receiving.order=Rider has received the order
 no.message.push.rider.accepted.merchant=A rider has accepted the order. Please confirm and prepare it
 no.message.push.order.cancelled=The order has been cancelled
+no.message.push.order.no.rider.line.cancelled=Your order was cancelled because no rider accepted it. Your LINE Pay refund is being processed.
 no.order.rider.accept.required=Please wait for a rider to accept the order before operating it
 no.message.push.new.order=New order
 no.message.push.new.food.order=New food delivery order
@@ -419,7 +420,7 @@ no.unifiedorder.param.invalid=Invalid query parameters
 no.pay.switch.order.invalid=Order not found or not payable
 no.pay.switch.already.paid=Order already paid
 no.pay.switch.method.invalid=Invalid payment method
-no.pay.switch.offline.restricted=Cash / cash-on-delivery only for pickup or dine-in orders
+no.pay.switch.offline.restricted=Delivery orders cannot switch to cash payment
 no.pay.switch.failed=Failed to change payment method, please try again later
 flash.delivery.billing.save.failed=Failed to record flash delivery income, please try again
 flash.delivery.billing.income=Flash delivery income (paid directly to rider)

+ 2 - 1
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -122,6 +122,7 @@ no.message.push.message=ข้อความ
 no.message.push.delivery.personnel.receiving.order=ไรเดอร์รับคำสั่งซื้อแล้ว
 no.message.push.rider.accepted.merchant=ไรเดอร์รับคำสั่งซื้อแล้ว โปรดยืนยันและเตรียมอาหาร
 no.message.push.order.cancelled=ยกเลิกคำสั่งซื้อแล้ว
+no.message.push.order.no.rider.line.cancelled=คำสั่งซื้อถูกยกเลิกเนื่องจากไม่มีไรเดอร์รับงาน ระบบกำลังดำเนินการคืนเงินผ่าน LINE Pay
 no.order.rider.accept.required=โปรดรอให้ไรเดอร์รับคำสั่งซื้อก่อนดำเนินการ
 no.message.push.new.order=มีคำสั่งซื้อใหม่
 no.message.push.new.food.order=มีออเดอร์ส่งอาหารใหม่
@@ -420,7 +421,7 @@ no.unifiedorder.param.invalid=พາรາມິเตอร์ทີ่กາ
 no.pay.switch.order.invalid=ไม่พบคำสั่งซื้อหรือไม่สามารถชำระเงินได้
 no.pay.switch.already.paid=ชำระเงินคำสั่งซื้อแล้ว
 no.pay.switch.method.invalid=วิธีชำระเงินไม่ถูกต้อง
-no.pay.switch.offline.restricted=เงินสด / ชำระเงินปลายทางใช้ได้เฉพาะคำสั่งซื้อแบบรับสินค้าหรือทานที่ร้านเท่านั้น
+no.pay.switch.offline.restricted=คำสั่งซื้อแบบจัดส่งไม่สามารถเปลี่ยนเป็นการชำระเงินสดได้
 no.pay.switch.failed=เปลี่ยนวิธีชำระเงินไม่สำเร็จ กรุณาลองใหม่ภายหลัง
 flash.delivery.billing.save.failed=บันทึกรายได้จากการจัดส่งด่วนไม่สำเร็จ กรุณาลองอีกครั้ง
 flash.delivery.billing.income=รายได้จากการจัดส่งด่วน (ชำระให้ผู้ขับขี่โดยตรง)

+ 2 - 1
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -123,6 +123,7 @@ no.message.push.message=消息
 no.message.push.delivery.personnel.receiving.order=骑手已接单
 no.message.push.rider.accepted.merchant=骑手已接单,请及时确认并备餐
 no.message.push.order.cancelled=订单已取消
+no.message.push.order.no.rider.line.cancelled=订单因无人接单已取消,LINE Pay 退款处理中
 no.order.rider.accept.required=请等待骑手接单后再操作订单
 no.message.push.new.order=有新订单
 no.message.push.new.food.order=有新的外卖订单
@@ -421,7 +422,7 @@ no.unifiedorder.param.invalid=查询参数不合法
 no.pay.switch.order.invalid=订单不存在或当前不可支付
 no.pay.switch.already.paid=订单已支付,无法更换支付方式
 no.pay.switch.method.invalid=支付方式无效
-no.pay.switch.offline.restricted=仅自取/堂食订单可更换为现金或货到付款
+no.pay.switch.offline.restricted=外送订单不能更换为现金支付
 no.pay.switch.failed=更换支付方式失败,请稍后重试
 flash.delivery.billing.save.failed=闪送骑手账单生成失败,请重试
 flash.delivery.billing.income=闪送收入(款项直付骑手)

+ 2 - 1
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -123,6 +123,7 @@ no.message.push.message=訊息
 no.message.push.delivery.personnel.receiving.order=騎手已接單
 no.message.push.rider.accepted.merchant=騎手已接單,請及時確認並備餐
 no.message.push.order.cancelled=訂單已取消
+no.message.push.order.no.rider.line.cancelled=訂單因無騎手接單已取消,LINE Pay 退款處理中
 no.order.rider.accept.required=請等待騎手接單後再操作訂單
 no.message.push.new.order=有新訂單
 no.message.push.new.food.order=有新的外送訂單
@@ -420,7 +421,7 @@ no.unifiedorder.param.invalid=查詢參數不合法
 no.pay.switch.order.invalid=訂單不存在或當前不可支付
 no.pay.switch.already.paid=訂單已付款,無法更換支付方式
 no.pay.switch.method.invalid=支付方式無效
-no.pay.switch.offline.restricted=僅自取/堂食訂單可更換為現金或貨到付款
+no.pay.switch.offline.restricted=外送訂單不能更換為現金支付
 no.pay.switch.failed=更換支付方式失敗,請稍後重試
 flash.delivery.billing.save.failed=閃送騎手帳單產生失敗,請重試
 flash.delivery.billing.income=閃送收入(款項直付騎手)

+ 22 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentCreateServiceTest.java

@@ -144,6 +144,28 @@ class OmgPaymentCreateServiceTest {
                 "KEY", "IV");
     }
 
+    @Test
+    void productionCheckoutPersistsEnvironmentSnapshotBeforeReturningForm() {
+        OmgPaymentAttempt attempt = new OmgPaymentAttempt();
+        attempt.setId(9L);
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());
+        when(credentials.getEnabledCredential(77L)).thenReturn(credential());
+        when(generator.generate()).thenReturn("OMG12345678901234567");
+        when(formFactory.create(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString(),
+                any(OmgPaymentMethod.class))).thenReturn(new OmgPaymentForm(
+                OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL, Map.of("CheckMacValue", "A".repeat(64))));
+        when(attempts.createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString())).thenReturn(attempt);
+        when(attempts.markProductionEnvironment(9L)).thenReturn(1);
+
+        service.create(5L, "DD-1", OmgPaymentMethod.CREDIT);
+
+        var calls = inOrder(attempts);
+        calls.verify(attempts).createCreated(anyString(), anyString(), anyLong(), anyString(), anyInt(),
+                anyString(), anyString());
+        calls.verify(attempts).markProductionEnvironment(9L);
+    }
+
     @Test
     void duplicateActiveRowWinsOverTradeNumberRetry() {
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(payableOrder());

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentFormFactoryTest.java

@@ -25,6 +25,18 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class OmgPaymentFormFactoryTest {
+    @Test
+    void productionConfigurationUsesProductionCheckoutEndpoint() {
+        OmgPaymentProperties properties = properties();
+        properties.setEnvironment("PRODUCTION");
+        OmgPaymentFormFactory factory = new OmgPaymentFormFactory(properties,
+                new OmgCheckMacSigner(), Clock.fixed(Instant.parse("2026-10-15T07:00:00Z"), ZoneOffset.UTC));
+
+        OmgPaymentForm form = factory.create("DD-1", 100, "1000031", "KEY", "IV",
+                "OMG12345678901234567", OmgPaymentMethod.CREDIT);
+
+        assertEquals(OmgPaymentFormFactory.PRODUCTION_GATEWAY_URL, form.gatewayUrl());
+    }
     @Test
     void buildsSignedCreditCardForm() {
         OmgPaymentProperties properties = properties();

+ 27 - 3
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyServiceTest.java

@@ -83,11 +83,12 @@ class OmgPaymentNotifyServiceTest {
     }
 
     @Test
-    void successfulCallbackUsesSnapshotAndMarksOnlyPaymentFacts() {
+    void cancelledOrderKeepsPaymentFactAndCreatesRefundIntent() {
         OmgPaymentOrderSnapshot order = order(4L, 0L);
+        OmgPaymentRefundService refunds = mock(OmgPaymentRefundService.class);
+        ReflectionTestUtils.setField(service, "refundService", refunds);
         when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order);
         when(attempts.markPaid(any())).thenReturn(1);
-        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
 
         assertTrue(service.process(signedRequest(1, 0, 100)));
 
@@ -97,7 +98,8 @@ class OmgPaymentNotifyServiceTest {
         assertEquals(1, facts.getValue().getRtnCode());
         assertEquals(0, facts.getValue().getSimulatePaid());
         verify(attempts).supersedeOtherCreated("DD-1", 7L);
-        verify(attempts).markOrderPaid("DD-1");
+        verify(attempts, never()).markOrderPaid("DD-1");
+        verify(refunds).createRefundIntentForAttempt(7L, "LATE_PAYMENT");
     }
 
     @Test
@@ -114,6 +116,19 @@ class OmgPaymentNotifyServiceTest {
         assertEquals(1, facts.getValue().getSimulatePaid());
     }
 
+    @Test
+    void signedCallbackPersistsCreditAuthorizationIdForRefund() {
+        when(attempts.selectOrderForUpdate("DD-1")).thenReturn(order(0L, 0L));
+        when(attempts.markPaid(any())).thenReturn(1);
+        when(attempts.markOrderPaid("DD-1")).thenReturn(1);
+
+        assertTrue(service.process(signedRequest(1, 0, 100, "1000031", "123456")));
+
+        var update = org.mockito.ArgumentCaptor.forClass(OmgPaymentAttempt.class);
+        verify(attempts).markPaid(update.capture());
+        assertEquals("123456", update.getValue().getCreditRefundId());
+    }
+
     @Test
     void failureMarksAttemptFailedWithoutChangingOrder() {
         when(attempts.markFailed(any())).thenReturn(1);
@@ -288,6 +303,11 @@ class OmgPaymentNotifyServiceTest {
     }
 
     private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount, String merchantId) {
+        return signedRequest(rtnCode, simulatePaid, amount, merchantId, null);
+    }
+
+    private OmgNotifyRequest signedRequest(int rtnCode, int simulatePaid, int amount,
+                                           String merchantId, String creditRefundId) {
         Map<String, String> fields = new LinkedHashMap<>();
         fields.put("MerchantID", merchantId);
         fields.put("MerchantTradeNo", "OMG123");
@@ -301,6 +321,9 @@ class OmgPaymentNotifyServiceTest {
         fields.put("PaymentTypeChargeFee", "0");
         fields.put("TradeDate", "2026/08/13 11:59:00");
         fields.put("SimulatePaid", String.valueOf(simulatePaid));
+        if (creditRefundId != null) {
+            fields.put("gwsr", creditRefundId);
+        }
         fields.put("CustomField1", "");
         fields.put("CustomField2", "");
         fields.put("CustomField3", "");
@@ -317,6 +340,7 @@ class OmgPaymentNotifyServiceTest {
         order.setDdId("DD-1");
         order.setState(state);
         order.setPayStatus(payStatus);
+        order.setPayType("2");
         return order;
     }
 }

+ 14 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryServiceTest.java

@@ -45,7 +45,7 @@ class OmgPaymentQueryServiceTest {
 
     @Test
     void paidGatewayResultCompensatesAQuitePossiblyLostCallback() {
-        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of("FutureEmptyField", "")));
+        when(gateway.query(anyMap())).thenReturn(signedResponse("1", Map.of("FutureEmptyField", "", "gwsr", "123456")));
         when(settlement.synchronizeVerifiedQuery(any())).thenReturn(OmgPaymentSettlementResult.PAID);
 
         OmgQueryPaymentResponse response = service.query(5L, "DD-1");
@@ -55,7 +55,8 @@ class OmgPaymentQueryServiceTest {
         verify(settlement).synchronizeVerifiedQuery(argThat(facts ->
                 facts.source() == OmgPaymentFactSource.QUERY
                         && facts.merchantTradeNo().equals("OMG123")
-                        && facts.resultCode() == 1));
+                        && facts.resultCode() == 1
+                        && "123456".equals(facts.creditRefundId())));
         @SuppressWarnings("unchecked")
         var request = org.mockito.ArgumentCaptor.forClass(Map.class);
         verify(gateway).query(request.capture());
@@ -78,6 +79,17 @@ class OmgPaymentQueryServiceTest {
         verify(attempts, never()).selectOrder(anyString());
     }
 
+    @Test
+    void queryUsesAttemptEnvironmentSnapshot() {
+        attempt.setGatewayEnvironment("PRODUCTION");
+        when(gateway.query(anyMap(), org.mockito.ArgumentMatchers.eq("PRODUCTION")))
+                .thenReturn(signedResponse("0", Map.of()));
+
+        assertEquals("UNPAID", service.query(5L, "DD-1").status());
+
+        verify(gateway).query(anyMap(), org.mockito.ArgumentMatchers.eq("PRODUCTION"));
+    }
+
     @Test
     void unpaidResultIsReadOnlyAndExplicitFailureIsSynchronized() {
         when(gateway.query(anyMap())).thenReturn(signedResponse("0", Map.of()));

+ 95 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundServiceTest.java

@@ -1,6 +1,24 @@
 package com.ruoyi.app.omgpay;
 
 import org.junit.jupiter.api.Test;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import java.sql.ResultSet;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -29,4 +47,81 @@ class OmgPaymentRefundServiceTest {
                 assertThrows(OmgPaymentBusinessException.class,
                         () -> service.refund(5L, "X".repeat(65))).getCode());
     }
+
+    @Test
+    void testEnvironmentRecordsExpectedFailureWithoutCallingProductionGateway() {
+        JdbcTemplate jdbc = mock(JdbcTemplate.class);
+        OmgRefundGateway gateway = mock(OmgRefundGateway.class);
+        OmgPaymentRefundService refunds = new OmgPaymentRefundService(new OmgPaymentProperties(),
+                null, jdbc, null, gateway, null);
+
+        refunds.processOne(8L);
+
+        verify(jdbc).update(org.mockito.ArgumentMatchers.contains("TEST_ENVIRONMENT"),
+                org.mockito.ArgumentMatchers.eq(8L));
+        verifyNoInteractions(gateway);
+    }
+
+    @Test
+    void usesGatewayCreditStateToChooseOfficialAction() {
+        assertEquals("N", OmgPaymentRefundService.nextAction("已授權"));
+        assertEquals("E", OmgPaymentRefundService.nextAction("要關帳"));
+        assertEquals("R", OmgPaymentRefundService.nextAction("已關帳"));
+        assertEquals(null, OmgPaymentRefundService.nextAction("關帳中"));
+    }
+
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void productionQueriesCreditBeforeActionAndDoesNotReplayUnchangedAction() throws Exception {
+        OmgPaymentProperties properties = new OmgPaymentProperties();
+        properties.setEnvironment("PRODUCTION");
+        properties.setCreditCheckCodes(Map.of("1000031", "123456"));
+        JdbcTemplate jdbc = mock(JdbcTemplate.class);
+        OmgRefundGateway gateway = mock(OmgRefundGateway.class);
+        OmgPaymentRefundService refunds = new OmgPaymentRefundService(properties, null, jdbc,
+                new OmgCheckMacSigner(), gateway, new OmgQueryResponseParser(),
+                Clock.fixed(Instant.parse("2026-10-15T07:00:00Z"), ZoneOffset.UTC));
+        when(jdbc.update(startsWith("UPDATE pos_order_omg_refund SET lease_until=DATE_ADD"), eq(8L)))
+                .thenReturn(1);
+        AtomicInteger reads = new AtomicInteger();
+        doAnswer(call -> {
+            RowMapper mapper = call.getArgument(1);
+            ResultSet rs = mock(ResultSet.class);
+            boolean first = reads.getAndIncrement() == 0;
+            when(rs.getLong(1)).thenReturn(7L);
+            when(rs.getString(2)).thenReturn(first ? "PENDING" : "ACTION_UNKNOWN");
+            when(rs.getString(3)).thenReturn(first ? null : "N");
+            when(rs.getString(4)).thenReturn(first ? null : "已授權");
+            return mapper.mapRow(rs, 0);
+        }).when(jdbc).queryForObject(startsWith("SELECT attempt_id"), any(RowMapper.class), eq(8L));
+        doAnswer(call -> {
+            RowMapper mapper = call.getArgument(1);
+            ResultSet rs = mock(ResultSet.class);
+            when(rs.getLong("id")).thenReturn(7L);
+            when(rs.getString("dd_id")).thenReturn("DD-1");
+            when(rs.getString("merchant_trade_no")).thenReturn("OMG123");
+            when(rs.getString("merchant_id")).thenReturn("1000031");
+            when(rs.getInt("amount")).thenReturn(100);
+            when(rs.getString("hash_key_snapshot")).thenReturn("KEY");
+            when(rs.getString("hash_iv_snapshot")).thenReturn("IV");
+            when(rs.getString("trade_no")).thenReturn("GW123");
+            when(rs.getString("credit_refund_id")).thenReturn("123");
+            when(rs.getString("gateway_environment")).thenReturn("PRODUCTION");
+            return mapper.mapRow(rs, 0);
+        }).when(jdbc).queryForObject(startsWith("SELECT id, dd_id"), any(RowMapper.class), eq(7L));
+        when(gateway.queryCredit(any())).thenReturn(
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已授權\"}}",
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已授權\"}}",
+                "{\"RtnMsg\":\"\",\"RtnValue\":{\"TradeID\":\"123\",\"amount\":\"100\",\"status\":\"已關帳\",\"close_data\":[{\"status\":\"銀行已追回\",\"amount\":\"100\"}]}}");
+        when(gateway.doAction(any())).thenReturn("MerchantID=1000031&MerchantTradeNo=OMG123&TradeNo=GW123&RtnCode=1");
+
+        refunds.processOne(8L);
+        refunds.processOne(8L);
+        refunds.processOne(8L);
+
+        verify(gateway, times(3)).queryCredit(any());
+        verify(gateway, times(1)).doAction(org.mockito.ArgumentMatchers.argThat(fields ->
+                "N".equals(((Map<?, ?>) fields).get("Action"))));
+        verify(jdbc).update(startsWith("UPDATE pos_order_omg_refund SET status='REFUNDED'"), eq(8L));
+    }
 }

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTest.java

@@ -207,6 +207,7 @@ class DeliveryOrderNotificationServiceTest {
         order.setDeliveryStatus(0L);
         order.setPayStatus(1L);
         order.setAfterSaleStatus(0L);
+        order.setIsDisplay(true);
         order.setLongitude(new BigDecimal("121.5000"));
         order.setLatitude(new BigDecimal("25.0300"));
         return order;

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

@@ -201,6 +201,7 @@ class OrderLifecycleServiceTest {
         order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);
+        order.setIsDisplay(true);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
 
@@ -231,6 +232,7 @@ class OrderLifecycleServiceTest {
         order.setState(0L);
         order.setDeliveryStatus(0L);
         order.setQsId(null);
+        order.setIsDisplay(true);
         when(posOrderService.getById(10L)).thenReturn(order);
         when(posOrderService.update(any(PosOrder.class), any(Wrapper.class))).thenReturn(true);
 

+ 65 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderNoRiderTimeoutServiceTest.java

@@ -0,0 +1,65 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.system.domain.PosOrder;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PosOrderNoRiderTimeoutServiceTest {
+    @Test
+    void appointmentUsesLatestOfPaymentSlotStartAndActualRiderOpening() {
+        PosOrder order = readyOrder();
+        order.setDelryTime("2026-10-15 16:00-17:00");
+        order.setEffectiveAt(at(15, 0));
+        order.setDisplayTime(at(15, 0));
+
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(16, 29), 30));
+        assertTrue(PosOrderNoRiderTimeoutService.eligible(order, at(16, 30), 30));
+
+        order.setDisplayTime(at(16, 10));
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(16, 39), 30));
+        assertTrue(PosOrderNoRiderTimeoutService.eligible(order, at(16, 40), 30));
+    }
+
+    @Test
+    void unpaidHiddenAssignedAndSelfDeliveredOrdersNeverExpire() {
+        PosOrder order = readyOrder();
+        order.setEffectiveAt(at(15, 0));
+        order.setDisplayTime(at(15, 0));
+        order.setPayStatus(0L);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setPayStatus(1L);
+        order.setIsDisplay(false);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setIsDisplay(true);
+        order.setQsId(8L);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+        order.setQsId(null);
+        order.setSelfDelivery(1);
+        assertFalse(PosOrderNoRiderTimeoutService.eligible(order, at(17, 0), 30));
+    }
+
+    private static PosOrder readyOrder() {
+        PosOrder order = new PosOrder();
+        order.setDdId("DD-1");
+        order.setParentDdId("DD-1");
+        order.setType(0L);
+        order.setState(0L);
+        order.setDeliveryStatus(0L);
+        order.setPayStatus(1L);
+        order.setAfterSaleStatus(0L);
+        order.setIsDisplay(true);
+        order.setSelfDelivery(0);
+        return order;
+    }
+
+    private static Date at(int hour, int minute) {
+        return Date.from(LocalDateTime.of(2026, 10, 15, hour, minute)
+                .atZone(ZoneId.of("Asia/Taipei")).toInstant());
+    }
+}

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

@@ -1,6 +1,9 @@
 package com.ruoyi.app.pay;
 
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import com.ruoyi.app.omgpay.OmgPaymentCreateOutcome;
 import com.ruoyi.app.omgpay.OmgPaymentCreateService;
 import com.ruoyi.app.omgpay.OmgPaymentRetryService;
@@ -51,6 +54,7 @@ class PayMethodSwitchServiceTest {
 
     @BeforeAll
     static void initializeMessages() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
         originalBeanFactory = (ConfigurableListableBeanFactory)
                 ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
         // MessageUtils 取 key 本身,断言直接比对 i18n key
@@ -101,15 +105,13 @@ class PayMethodSwitchServiceTest {
         when(infoUserService.getById(7L)).thenReturn(user);
         PosOrder order = baseOrder();
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
-        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        when(orderService.update(any(Wrapper.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
                 omgPaymentRetryService, linePayService, orderLogHelper, infoUserService);
 
         AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
 
-        ArgumentCaptor<PosOrder> saved = ArgumentCaptor.forClass(PosOrder.class);
-        verify(orderService).updateById(saved.capture());
-        assertEquals("1", saved.getValue().getPayType(), "应回写为目标方式货到付款");
+        verify(orderService).update(any(Wrapper.class));
         verify(orderLogHelper).log(eq("dd-1"), eq(4), eq(7L), eq("小明"), anyString());
         verify(linePayService, never()).create(any(), anyString());
         assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
@@ -129,7 +131,29 @@ class PayMethodSwitchServiceTest {
         ServiceException exception = assertThrows(ServiceException.class,
                 () -> service.switchMethod(7L, request("dd-1", "4")));
         assertEquals("no.pay.switch.offline.restricted", exception.getMessage());
-        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(orderService, never()).update(any(Wrapper.class));
+    }
+
+    @Test
+    void switchUnpaidDeliveryLineOrderToCodSetsEffectiveFact() {
+        IPosOrderService orders = mock(IPosOrderService.class);
+        PosOrder order = baseOrder();
+        order.setType(0L);
+        order.setDeliveryStatus(0L);
+        order.setAfterSaleStatus(0L);
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+        when(orders.update(any(Wrapper.class))).thenReturn(true);
+        PayMethodSwitchService service = new PayMethodSwitchService(orders,
+                mock(OmgPaymentRetryService.class), mock(LinePayService.class),
+                mock(OrderLogHelper.class), mock(IInfoUserService.class));
+
+        AjaxResult result = service.switchMethod(7L, request("dd-1", "1"));
+
+        @SuppressWarnings("rawtypes")
+        ArgumentCaptor<Wrapper> updates = ArgumentCaptor.forClass(Wrapper.class);
+        verify(orders).update(updates.capture());
+        assertNotNull(updates.getValue());
+        assertEquals("1", ((Map<?, ?>) result.get("data")).get("payType"));
     }
 
     @Test
@@ -141,7 +165,7 @@ class PayMethodSwitchServiceTest {
                 .thenReturn(new OmgPaymentCreateOutcome(mock(OmgCreatePaymentResponse.class), 1L, "dd-1", 7L, 5L, 90, "abcde***fghi"));
         PosOrder order = baseOrder();
         when(orderService.getOne(any(Wrapper.class))).thenReturn(order);
-        when(orderService.updateById(any(PosOrder.class))).thenReturn(true);
+        when(orderService.update(any(Wrapper.class))).thenReturn(true);
         PayMethodSwitchService service = new PayMethodSwitchService(orderService,
                 omgPaymentRetryService, mock(LinePayService.class), mock(OrderLogHelper.class),
                 mock(IInfoUserService.class));
@@ -151,7 +175,7 @@ class PayMethodSwitchServiceTest {
         // 顺序约束:先回写 payType 再拉起渠道;走 retry 路径:旧 Apple Pay 尝试仍激活时
         // 替换重建而非 PAYMENT_ATTEMPT_EXISTS 死锁(组内 5->2 切换线上事故场景)
         InOrder inOrder = inOrder(orderService, omgPaymentRetryService);
-        inOrder.verify(orderService).updateById(any(PosOrder.class));
+        inOrder.verify(orderService).update(any(Wrapper.class));
         inOrder.verify(omgPaymentRetryService).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
         Map<?, ?> data = (Map<?, ?>) result.get("data");
         assertEquals("2", data.get("payType"));
@@ -173,7 +197,7 @@ class PayMethodSwitchServiceTest {
 
         service.switchMethod(7L, request("dd-1", "2"));
 
-        verify(orderService, never()).updateById(any(PosOrder.class));
+        verify(orderService, never()).update(any(Wrapper.class));
         verify(omgPaymentRetryService, times(1)).retry(eq(7L), eq("dd-1"), eq(OmgPaymentMethod.CREDIT));
     }
 

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

@@ -337,6 +337,10 @@ public class PosOrder {
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date displayTime;
 
+    /** 首次具备履约资格的服务端时间;重复付款和回调不覆盖。 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date effectiveAt;
+
     /**
      * 配送状态:0待接单,1骑手已接单,2配送中,3已送达(仅外送订单)
      */

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/domain/OmgPaymentAttempt.java

@@ -29,6 +29,10 @@ public class OmgPaymentAttempt {
     private Integer attemptStatus;
     private String activeDdId;
     private String tradeNo;
+    /** OMG gwsr,正式信用卡明细查询所需的授权单号。 */
+    private String creditRefundId;
+    /** 创建收银台时的网关环境,历史行默认 TEST。 */
+    private String gatewayEnvironment;
     private Integer rtnCode;
     private String rtnMsg;
     private String paymentType;

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/mapper/OmgPaymentAttemptMapper.java

@@ -40,6 +40,8 @@ public interface OmgPaymentAttemptMapper {
 
     int insertCreated(OmgPaymentAttempt row);
 
+    int markProductionEnvironment(@Param("id") Long id);
+
     int markPaid(OmgPaymentAttempt row);
 
     int markFailed(OmgPaymentAttempt row);

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/IOmgPaymentAttemptService.java

@@ -34,6 +34,8 @@ public interface IOmgPaymentAttemptService {
                                     String merchantId, Integer amount,
                                     String hashKeySnapshot, String hashIvSnapshot);
 
+    int markProductionEnvironment(Long attemptId);
+
     int markPaid(OmgPaymentAttempt paymentFacts);
 
     int markFailed(OmgPaymentAttempt paymentFacts);

+ 5 - 0
ruoyi-system/src/main/java/com/ruoyi/system/omgpay/service/impl/OmgPaymentAttemptServiceImpl.java

@@ -103,6 +103,11 @@ public class OmgPaymentAttemptServiceImpl implements IOmgPaymentAttemptService {
         return row;
     }
 
+    @Override
+    public int markProductionEnvironment(Long attemptId) {
+        return mapper.markProductionEnvironment(attemptId);
+    }
+
     private static void validateCreatedFacts(String ddId, String merchantTradeNo, Long storeId,
                                              String merchantId, Integer amount,
                                              String hashKeySnapshot, String hashIvSnapshot) {

+ 13 - 4
ruoyi-system/src/main/resources/mapper/omgpay/OmgPaymentAttemptMapper.xml

@@ -7,7 +7,8 @@
         store_id AS storeId, merchant_id AS merchantId, amount,
         hash_key_snapshot AS hashKeySnapshot, hash_iv_snapshot AS hashIvSnapshot,
         attempt_status AS attemptStatus, active_dd_id AS activeDdId,
-        trade_no AS tradeNo, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
+        trade_no AS tradeNo, credit_refund_id AS creditRefundId,
+        gateway_environment AS gatewayEnvironment, rtn_code AS rtnCode, rtn_msg AS rtnMsg,
         payment_type AS paymentType, payment_date AS paymentDate, trade_date AS tradeDate,
         payment_type_charge_fee AS paymentTypeChargeFee, simulate_paid AS simulatePaid,
         last_notify_time AS lastNotifyTime,
@@ -101,10 +102,16 @@
            #{nextQueryTime}, #{queryCount}, #{createTime}, #{updateTime})
     </insert>
 
+    <update id="markProductionEnvironment">
+        UPDATE pos_order_omg_attempt SET gateway_environment='PRODUCTION'
+        WHERE id=#{id} AND gateway_environment='TEST' AND attempt_status=0
+    </update>
+
     <!-- PAID is irreversible; a late success may upgrade FAILED or SUPERSEDED. -->
     <update id="markPaid">
         UPDATE pos_order_omg_attempt
-        SET attempt_status = 1, trade_no = #{tradeNo}, rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
+        SET attempt_status = 1, trade_no = #{tradeNo}, credit_refund_id = #{creditRefundId},
+            rtn_code = #{rtnCode}, rtn_msg = #{rtnMsg},
             payment_type = #{paymentType}, payment_date = #{paymentDate}, trade_date = #{tradeDate},
             payment_type_charge_fee = #{paymentTypeChargeFee}, simulate_paid = #{simulatePaid},
             last_notify_time = #{lastNotifyTime}, update_time = #{updateTime}
@@ -134,8 +141,10 @@
 
     <!-- Payment notification owns only the payment fact; no order/delivery state is changed here. -->
     <update id="markOrderPaid">
-        UPDATE pos_order SET pay_status = 1
-        WHERE dd_id = #{ddId} AND pay_status != 1
+        UPDATE pos_order SET pay_status = 1,
+            effective_at = CASE WHEN type = 0 AND state != 4 THEN COALESCE(effective_at, NOW()) ELSE effective_at END
+        WHERE dd_id = #{ddId} AND pay_status = 0 AND pay_type IN ('2', '5')
+          AND state IN (0, 1, 2)
     </update>
 
     <select id="countOtherPaidAttempts" resultType="int">

+ 7 - 2
ruoyi-system/src/main/resources/mapper/system/PosOrderMapper.xml

@@ -58,13 +58,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="isAccepted" column="is_accepted" />
         <result property="isDisplay" column="is_display" />
         <result property="displayTime" column="display_time" />
+        <result property="effectiveAt" column="effective_at" />
         <result property="deliveryStatus" column="delivery_status" />
         <result property="payStatus" column="pay_status" />
         <result property="afterSaleStatus" column="after_sale_status" />
     </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,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
+        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,effective_at,delivery_status,pay_status,after_sale_status from pos_order
     </sql>
 
     <select id="selectPosOrderList" parameterType="PosOrder" resultMap="PosOrderResult">
@@ -193,6 +194,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">is_accepted,</if>
             <if test="isDisplay != null">is_display,</if>
             <if test="displayTime != null">display_time,</if>
+            <if test="effectiveAt != null">effective_at,</if>
             <if test="deliveryStatus != null">delivery_status,</if>
             <if test="payStatus != null">pay_status,</if>
             <if test="afterSaleStatus != null">after_sale_status,</if>
@@ -237,6 +239,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">#{isAccepted},</if>
             <if test="isDisplay != null">#{isDisplay},</if>
             <if test="displayTime != null">#{displayTime},</if>
+            <if test="effectiveAt != null">#{effectiveAt},</if>
             <if test="deliveryStatus != null">#{deliveryStatus},</if>
             <if test="payStatus != null">#{payStatus},</if>
             <if test="afterSaleStatus != null">#{afterSaleStatus},</if>
@@ -287,6 +290,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="isAccepted != null">is_accepted = #{isAccepted},</if>
             <if test="isDisplay != null">is_display = #{isDisplay},</if>
             <if test="displayTime != null">display_time = #{displayTime},</if>
+            <if test="effectiveAt != null">effective_at = #{effectiveAt},</if>
             <if test="deliveryStatus != null">delivery_status = #{deliveryStatus},</if>
             <if test="payStatus != null">pay_status = #{payStatus},</if>
             <if test="afterSaleStatus != null">after_sale_status = #{afterSaleStatus},</if>
@@ -376,7 +380,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
     <update id="markLinePaid">
-        UPDATE pos_order SET pay_status = 1
+        UPDATE pos_order SET pay_status = 1,
+            effective_at = CASE WHEN type = 0 AND state != 4 THEN COALESCE(effective_at, NOW()) ELSE effective_at END
         WHERE dd_id = #{ddId} AND pay_type = '3' AND pay_status = 0
           AND state IN (0, 1, 2, 4)
           AND EXISTS (SELECT 1 FROM pos_order_line_payment p

+ 31 - 0
specs/037-order-no-rider-auto-cancel/plan.md

@@ -0,0 +1,31 @@
+# Implementation Plan: 外卖无人接单自动取消与退款
+
+**Date**: 2026-10-15 | **Spec**: [spec.md](spec.md) | **Status**: 已提交;数据库部署与联调待执行
+
+## Summary
+
+仅处理当前独立外送单。订单首次可履约时写 `effective_at`;预约单以生效、预约开始、实际开放给骑手三者最晚时间计时。未支付在线单可安全切换到到付。无人接单超过配置阈值时用与骑手接单互斥的条件更新取消,记录退款意图、日志并通知用户和商家。
+
+OMG 正式退款使用支付尝试保存的环境快照和商户 CreditCheckCode 调用信用卡交易查询与 DoAction;测试环境没有退款能力,退款意图保持待处理。正式退款结果不确定时先查询交易事实,再决定下一动作,不盲目重复调用。切换部署环境不会把旧测试流水送到正式接口。
+
+## Components
+
+1. `ruoyi-system`:`pos_order.effective_at` 映射、条件支付切换/超时取消 SQL、退款意图持久层。
+2. `ruoyi-admin`:订单创建和付款写点、切换服务、预约开放与超时任务、退款任务与 OMG 网关、取消通知。
+3. `updatesql/sql.md`:按时间顺序追加字段、退款表、阈值字典、调度任务;不在此执行数据库迁移。
+4. `specs/037-order-no-rider-auto-cancel`:规格、计划、任务与验证结果。
+
+## Safety and acceptance
+
+- 支付与切换的条件更新互斥,`effective_at` 只首次写入;迟到付款产生退款意图。
+- 接单与取消的条件更新互斥,预约单未开放或未到阈值不会取消。
+- LINE 复用现有退款意图;OMG 退款保留尝试、网关状态、动作和错误,重复扫描幂等。
+- 测试环境不调用正式 OMG API,不把测试环境退款失败记作成功。
+- 完成所有接口源码后统一运行 JDK 21 定向测试、模块构建与回归;提交前审计暂存范围,排除已有脏文件。
+
+## 验证记录
+
+- JDK 21 定向测试:037 涉及的 9 个测试类、99 个测试通过。
+- `mvn -q -pl ruoyi-admin -am -DskipTests package` 通过。
+- `mvn -q -pl ruoyi-admin -am test` 执行 1057 个测试,只有 `FlashDeliveryV2Test` 的 7 个断言/错误未通过;该文件对应的闪送服务在本任务开始前已有未提交修改,本任务不改动其代码。
+- SQL 仅登记在 `updatesql/sql.md`,没有执行数据库迁移;测试环境 OMG 无真实信用卡退款接口,正式退款仍待正式凭证和网关联调验收。

+ 91 - 0
specs/037-order-no-rider-auto-cancel/spec.md

@@ -0,0 +1,91 @@
+# 功能规格:外卖订单超时无骑手自动取消并退款
+
+**功能标识**:`037-order-no-rider-auto-cancel`
+
+**创建日期**:2026-10-14
+
+**状态**:已提交;数据库部署与联调待执行
+
+**输入**:需求「外卖订单如果订单生效后 30 分钟没有骑手接单,订单自动取消,退款给用户」。`specs/036-multi-store-checkout/spec.md:140-142,181` 已明确将「最迟接单/无人接单自动取消」甩为本独立后续事项,不复用废弃的 TestTask。
+
+## 已确认口径(2026-10-14 对话定稿)
+
+| 口径 | 结论 |
+|---|---|
+| 生效事实与计时起点 | 新增 `effective_at DATETIME NULL`,仅在订单首次具备履约资格时写入;在线支付以服务端确认成功、到付以服务端确认订单可履约、转账以确认收款为准。`pay_status=1` 仍须参与资格校验,但不能单独充当生效时间;`is_display` / `display_time` 表示骑手开放状态和实际开放时间,不是生效事实。 |
+| 未支付单 | 不处理(payStatus=0 一律不动) |
+| 到付单(payType=1) | 创建时选择到付,或未支付外送单从在线方式合法切换到到付后,订单立即生效;到时自动取消但不退款(本就没收过钱)。 |
+| 转账单(payType=6,已确认收款) | 纳入取消;退款走线下人工(027 的「转账单不做超时自动取消」仅限**未确认收款**期间,spec.md:80,121;FR-006 确认收款后回归现有状态机) |
+| 范围排除 | 堂食/自取(delivery_status 为 NULL 天然不命中)、商家自配送(self_delivery=1 不进骑手池)、闪送(独立表独立状态机)、未支付单 |
+| 阈值 | **数据字典**配置(新 dict_type,value=30 分钟,DictUtils 读取,缺省 30) |
+| 预约单取消时点 | `max(effective_at, 预约时段开始时间, 实际骑手开放时间 display_time) + 阈值`,且必须仍可接单;提前付款不能在预约开始前触发取消。 |
+| OMG 已付单退款 | 本轮实现正式环境自动退款;测试环境退款失败属预期,保留可查询的退款意图/未完成状态,不标记已退款,不调用正式网关进行测试。 |
+
+## 已确认补充口径
+
+1. **预约单规则**:提前付款或切换到付时立即记 `effective_at`,但不能从该时刻直接计 30 分钟并在预约开始前取消。`delryTime` 是 `yyyy-MM-dd HH:mm-HH:mm` 时间段字符串,取开始时间并与 `effective_at`、`display_time` 求最晚时间。若 15:00 生效、16:00 预约开始且正常开放,最早 16:30 判断;若 16:10 才开放,最早 16:40 判断。即时单从 `effective_at` 计时。
+2. **OMG 正式自动退款**:取消已实收 OMG 单时持久化幂等退款意图,正式环境工作任务通过信用卡明细查询判断授权状态,并按官方状态选择 `N`(已授权)、`E` 后 `N`(要关账且全额退款)或 `R`(已关账)。测试环境不调用正式退款接口,意图保持未完成并明确原因;不能把测试失败写成退款成功。超时或不确定网关结果不能盲重试同一动作,须查询事实再继续或进入人工核查。支付尝试保存创建时的 `gateway_environment`,支付查询沿用该环境;退款要求当前配置及尝试快照都为正式环境,避免将测试交易发送到正式退款接口。正式环境每个 MerchantID 还需配置对应 CreditCheckCode。参考官方文档:https://developers.omg.com.tw/payment/aio/api/05_refund.html 及 https://developers.omg.com.tw/payment/aio/api/04_order_query.html 。
+
+## 设计方案
+
+### ① 订单生效事实(新增 effective_at 列)
+
+`pos_order` 加 `effective_at DATETIME NULL`。该字段只在首次合法生效时写一次,不因支付重试、重复回调或状态重放覆盖;渠道实际支付时间仍由支付渠道事实表记录。现有独立订单按以下服务端事实写入:
+
+- 到付外送:`UserOrderController.java:301` 创建时服务端确认订单可履约;到付尚未实际收款,不能把该时间称为支付时间。
+- LINE:`LinePayFactService.applyPaidFactInternal` 调用 `PosOrderMapper.xml:378` 的 `markLinePaid` 成功时,随订单支付事实原子写入。
+- OMG:`OmgPaymentNotifyService.applyVerifiedFacts` 调用 `OmgPaymentAttemptMapper.xml:136` 的 `markOrderPaid` 成功时,随订单支付事实原子写入;`:205` 只是修改用于推送的对象,不是数据库写点。
+- 线下转账:商家 `PosOrderShOprateController.confirmTransferPayment:668` 或平台 `OrderLifecycleService.confirmOfflinePayment:286` 确认收款时写入。其他在用的线下确认入口实施前再按外送资格核对;自取、堂食不在本任务范围。
+- 废弃写点不改:`TestTask.java:145`、`NewebpayPayController.java:323`
+
+**切换支付方式**:现有 `POST /pay/switch` 仅允许 `payStatus=0` 的独立订单,但错误地限制外送单不能切换为到付;本功能应允许未支付外送单从 LINE Pay 等在线方式切换到可用的到付(现金 `payType=4` 仍按原订单类型规则)。在线方式之间切换只改变 `pay_type`,`effective_at` 保持 `NULL`,待新渠道可信付款事实使订单首次生效时写入。切换到到付则须在同一事务内校验订单与目标方式可用、原渠道没有已确认实收;有未决尝试先查询现有支付事实,无法确认时暂缓切换。确认未实收后原子更新 `pay_type=1`、`pay_status=1`、`effective_at=NOW()`,仅在提交后开放接单。已生效订单禁止切换,重复付款或支付重试不得清空、覆盖生效时间。实现时将 `PayMethodSwitchService` 当前的整对象 `updateById(order)` 改为仅更新必要列的条件更新,条件至少包括 `pay_status=0 AND effective_at IS NULL` 及可切换状态,防止与支付回调并发时写回旧值。旧渠道若仍有迟到实收,只记渠道收款事实并创建幂等退款意图,不重开订单或再次改写 `effective_at`。新多店批次逐子单改方式及到付确认仍按 036 的父批次生效规则处理,不套用本独立订单入口。
+
+**不回填历史**:`effective_at IS NULL` 的存量单不参与本功能,从上线起干净生效(避免任务首跑批量取消历史滞留单;旧滞留单人工处理)。
+
+**多店批次后续接入**:`specs/036-multi-store-checkout` 当前仍是草案。其新批次须以全部有效子单就绪后父批次原子生效的状态及父批次生效时间为准;单个子单先付款不启动整组无人接单倒计时。037 先覆盖现有独立外送单,多店组接入时复核整组接单与取消条件。
+
+### ② 匹配条件(镜像骑手接单 CAS,保证「骑手本可以接但没人接」)
+
+```
+type=0 AND state=0 AND delivery_status=0 AND pay_status=1
+AND after_sale_status=0 AND qs_id IS NULL AND IFNULL(self_delivery,0)=0
+AND effective_at IS NOT NULL AND is_display=1
+计时:即时单  effective_at + 阈值分钟 ≤ now
+     预约单  max(effective_at, 预约时段开始时间, display_time) + 阈值分钟 ≤ now
+```
+
+state=0 即「骑手池」状态——商家接单反而要求骑手已接单(`PosOrderShOprateController.acceptOrder:337` requireRiderAssigned),不存在「商家已接单但无骑手」的中间态。本条件与骑手接单 CAS(`OrderLifecycleService.acceptDeliveryByRiderLocked:171-181`:state=0 + deliveryStatus=0 + payStatus=1 + afterSaleStatus=0 + qsId IS NULL)完全对偶。
+
+### ③ 取消动作(逐单事务)
+
+1. CAS `UPDATE pos_order SET state=4 WHERE id=? AND 全套池条件`——与骑手抢单天然防竞态,影响行数=0 则跳过该单(本轮已被抢/已变化)。
+2. LINE 已付(payType=3):`linePayRefundService.createRefundIntent(paymentId, "AUTO_NO_RIDER")`——幂等退款意图落 `pos_order_line_refund`,实际网关退款由现有 `LinePayReconcileTask` 异步执行(复用 USER_CANCEL/STORE_CANCEL 的既有模式)。
+3. 到付不退款;OMG 已付创建正式退款意图并由工作任务按网关状态执行,测试环境保持未完成;已收款转账进入持久化人工退款待办,不能仅凭一条应用日志充当待办。
+4. `orderLogHelper.logSync`(operatorType=0 系统,文案「超时无骑手自动取消」,与取消事务一致)。
+5. 推送:用户(订单已取消;LINE 单附退款说明)+ 商家门店(`merchantNotificationRouter.sendStoreNotification`)。骑手无接收人(qsId 为空)。注意 `notifyOrderCancelled`(`DeliveryOrderNotificationService.java:104-132`)仅 type=0 且 qsId!=null 才推,**不能直接复用**,需用户侧推送变体。推送文案新增后端 i18n key(沿用 no.message.push.* 模式,四语言)。
+6. payStatus / afterSaleStatus 不随取消改动(已付单 payStatus 由退款事实异步回写,同现有取消链路)。
+
+### ④ 调度
+
+`@Component("posOrderNoRiderTimeoutTask")` 公开方法 + sys_job 注册(cron 每分钟),样板照抄 `FlashDeliveryAutoCompleteTask`(其类注释明确「由若依任务调度调 Bean 公开方法,避免应用内 @Scheduled 重复执行」)。SQL(sys_job INSERT)登记 `updatesql/sql.md` 由开发者手动执行。逐单 CAS 幂等,双跑安全。
+
+### ⑤ 阈值配置
+
+数据字典:新 dict_type(如 `pos_order_auto_cancel`,字典项「无骑手自动取消分钟数」=30),任务每次执行时 DictUtils 读取,读取失败/未配置缺省 30。INSERT 登记 `updatesql/sql.md`。
+
+## 关键代码事实(实现锚点)
+
+- 骑手接单 CAS:`OrderLifecycleService.java:169-181`(只更新 deliveryStatus=1 + qsId,state 不变)
+- 骑手池精确画像(同本功能匹配条件):`PosOrderQsOprateController.orderTabWrapper` newTask 分支 `:447-451`
+- 用户取消样板(CAS + LINE 退款意图 + 日志 + 推送):`UserOrderController.cancelOrder:746-790`
+- LINE 退款:`LinePayRefundService.createRefundIntent:178`(幂等);`LinePayCancellationCompensationService`(source=CANCEL_SCAN 补偿「已取消已付无退款意图」的单——**注意**:本功能上线后该补偿扫描可能把 AUTO_NO_RIDER 取消的 OMG/到付单也补出 LINE 意图吗?不会,补偿只针对有 PAID LINE payment 的单,实现时验证 source 语义即可)
+- 开放状态:`UserOrderController.handleIsDisplay:477-491` 即时单下单即写、预约单临近才写;远期预约翻显示的原定时任务已随 TestTask 废弃。骑手新任务列表在 `PosOrderQsOprateController.orderTabWrapper:445` 要求 `is_display=true`;实施预约自动取消前须补持久化开放与重启补扫。
+- pos_order 现无订单生效时间列;渠道支付时间分别在 `pos_order_line_payment.pay_time` / `pos_order_omg_attempt.payment_date`,不能替代到付和多店批次的生效时间。
+- 状态值域:state 0待处理/1已接单/2已出餐/3已完成/4已取消(`specs/006-orderstate`);payStatus 0未支付/1已支付/2已退款;afterSaleStatus 0无售后…
+- 任务样板:`ruoyi-admin/.../flashdelivery/task/FlashDeliveryAutoCompleteTask.java`;sys_job 登记样例 `updatesql/sql.md:1346-1368`
+
+## 部署与联调
+
+1. 开发者按 `updatesql/sql.md` 的 2026-10-15 节手动执行 DDL、字典和调度任务;历史订单不回填 `effective_at`。
+2. 测试环境只用模拟网关响应验证 OMG 退款状态机,测试退款意图维持未完成。正式环境配置 `omgpay.environment=PRODUCTION` 和各 MerchantID 的 CreditCheckCode 后,再用真实交易验收查询及退款。
+3. 验证记录与未通过的全量回归项见 [plan.md](plan.md)。

+ 10 - 0
specs/037-order-no-rider-auto-cancel/tasks.md

@@ -0,0 +1,10 @@
+# Tasks: 外卖无人接单自动取消与退款
+
+**状态**:已提交;数据库部署与联调待执行。所有 SQL 仅写入 `updatesql/sql.md`,不直接执行。
+
+- [x] T001 `effective_at` 字段与映射;到付创建、LINE/OMG 实收、线下收款首次生效写点;对应测试源码与 SQL。
+- [x] T002 修复未支付外送单在线支付切换到到付,原子更新并处理并发/迟到付款;对应测试源码和规格更新。
+- [x] T003 持久化开放预约单、阻止未开放订单直接接单;对应测试源码和调度 SQL。
+- [x] T004 无人接单扫描、预约计时、CAS 取消、LINE 退款/转账待办、日志与通知;对应测试源码、字典和调度 SQL。
+- [x] T005 OMG 正式环境信用卡交易状态查询与自动退款、幂等退款意图、环境隔离和模拟网关测试;测试环境保持待处理,不宣称真实退款成功。
+- [x] T006 统一静态检查、JDK 21 定向测试、模块构建与回归;检查暂存清单并提交中文提交信息。完整回归的闪送失败项与部署联调限制见 `plan.md`。

+ 69 - 0
updatesql/sql.md

@@ -2071,3 +2071,72 @@ DELETE FROM sys_dict_type WHERE dict_type IN ('flash_show_multi_pickup', 'flash_
 ### 2026-10-14 测试库执行记录
 
 用户明确要求后,已在 `application-druid.yml` 标注的测试站点 `foodie`(MySQL 5.7.44)按“已执行旧版多站点 SQL”路径执行增量补丁、两项上限字典初始化及废弃开关字典清理;未执行互斥的完整结构语句。执行前核对 18 条旧站点记录:被删除的城市/区域及取货站物品值均可由订单级字段保留。执行后站点仍为 18 条,新增 `remark/receiver_user_id/delivery_pin_code`,删除旧 6 列;订单新增 `delivery_stop_count/delivered_stop_count`。字典仅剩取货/收货上限各一条,值均为 1。正式库及其他环境未执行;真实 App 联调未验证。
+
+## 2026-10-15 外卖无人接单超时取消与退款(037)
+
+> 仅登记,未执行。先执行 DDL,再初始化字典和任务。上线前须完成全部代码验证。`effective_at` 不回填历史订单;历史订单不参与自动取消。OMG 测试环境没有信用卡退款 API,测试单退款意图保持待处理;正式环境需配置 `omgpay.environment=PRODUCTION` 及每个 MerchantID 对应的 `omgpay.credit-check-codes.<MerchantID>`,并确认该商户使用正式凭证。禁止用正式退款接口处理测试交易。
+
+```sql
+ALTER TABLE pos_order
+  ADD COLUMN effective_at DATETIME NULL COMMENT '首次具备履约资格的服务端时间' AFTER display_time,
+  ADD INDEX idx_order_no_rider (type, state, delivery_status, pay_status, effective_at);
+
+ALTER TABLE pos_order_omg_attempt
+  ADD COLUMN credit_refund_id VARCHAR(30) NULL COMMENT 'OMG gwsr,信用卡授权单号' AFTER trade_no,
+  ADD COLUMN gateway_environment VARCHAR(12) NOT NULL DEFAULT 'TEST' COMMENT '创建支付尝试时的网关环境' AFTER credit_refund_id;
+
+CREATE TABLE pos_order_omg_refund (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  attempt_id BIGINT NOT NULL,
+  dd_id VARCHAR(64) NOT NULL,
+  source VARCHAR(32) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  last_action CHAR(1) NULL,
+  last_gateway_status VARCHAR(40) NULL,
+  last_action_time DATETIME NULL,
+  attempt_count INT NOT NULL DEFAULT 0,
+  last_error VARCHAR(100) NULL,
+  lease_until DATETIME NULL,
+  refund_time DATETIME NULL,
+  create_time DATETIME NOT NULL,
+  update_time DATETIME NOT NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_omg_refund_attempt (attempt_id),
+  KEY idx_omg_refund_pending (status, lease_until),
+  KEY idx_omg_refund_dd (dd_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OMG 退款意图及交易状态';
+
+CREATE TABLE pos_order_manual_refund (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  dd_id VARCHAR(64) NOT NULL,
+  reason VARCHAR(40) NOT NULL,
+  status VARCHAR(24) NOT NULL,
+  create_time DATETIME NOT NULL,
+  completed_at DATETIME NULL,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_manual_refund_dd (dd_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='线下转账人工退款待办';
+
+INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
+SELECT '外卖无人接单自动取消', 'pos_order_auto_cancel', '0', 'admin', NOW(), '超时分钟数,缺省30'
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type='pos_order_auto_cancel');
+INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, is_default, status, create_by, create_time)
+SELECT 1, '无人接单自动取消分钟数', '30', 'pos_order_auto_cancel', 'Y', '0', 'admin', NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type='pos_order_auto_cancel');
+
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '预约外卖开放接单', 'DEFAULT', 'posOrderNoRiderTimeoutTask.openScheduledOrders()',
+       '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '预约外卖进入开放窗口后持久化开放时间'
+WHERE NOT EXISTS (SELECT 1 FROM sys_job WHERE invoke_target='posOrderNoRiderTimeoutTask.openScheduledOrders()');
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT '外卖无人接单自动取消', 'DEFAULT', 'posOrderNoRiderTimeoutTask.cancelExpiredOrders()',
+       '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '生效并开放接单超过配置阈值仍无骑手时取消'
+WHERE NOT EXISTS (SELECT 1 FROM sys_job WHERE invoke_target='posOrderNoRiderTimeoutTask.cancelExpiredOrders()');
+INSERT INTO sys_job
+  (job_name, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, create_by, create_time, remark)
+SELECT 'OMG 退款对账', 'DEFAULT', 'omgPaymentRefundTask.reconcilePending()',
+       '0 0/1 * * * ?', '3', '1', '0', 'admin', NOW(), '正式环境信用卡退款;测试环境仅保留未完成意图'
+WHERE NOT EXISTS (SELECT 1 FROM sys_job WHERE invoke_target='omgPaymentRefundTask.reconcilePending()');
+```