Parcourir la source

feat: add OMG test-stage refund endpoint

qmj il y a 2 semaines
Parent
commit
87c2d5078e

+ 29 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentController.java

@@ -4,6 +4,7 @@ import com.ruoyi.app.omgpay.dto.OmgCreatePaymentRequest;
 import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
 import com.ruoyi.app.omgpay.dto.OmgNotifyRequest;
 import com.ruoyi.app.omgpay.dto.OmgQueryPaymentRequest;
+import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.core.domain.AjaxResult;
 import com.ruoyi.common.utils.MessageUtils;
@@ -31,17 +32,20 @@ public class OmgPaymentController {
     private final OmgPaymentNotifyService notifyService;
     private final OmgIpnAuditService ipnAuditService;
     private final OmgPaymentQueryService queryService;
+    private final OmgPaymentRefundService refundService;
 
     public OmgPaymentController(OmgPaymentTokenUserResolver tokenUserResolver,
                                 OmgPaymentCreateService createService,
                                 OmgPaymentNotifyService notifyService,
                                 OmgIpnAuditService ipnAuditService,
-                                OmgPaymentQueryService queryService) {
+                                OmgPaymentQueryService queryService,
+                                OmgPaymentRefundService refundService) {
         this.tokenUserResolver = tokenUserResolver;
         this.createService = createService;
         this.notifyService = notifyService;
         this.ipnAuditService = ipnAuditService;
         this.queryService = queryService;
+        this.refundService = refundService;
     }
 
     /** OMG server-to-server final payment notification. */
@@ -129,6 +133,30 @@ public class OmgPaymentController {
         }
     }
 
+    @Anonymous
+    @Auth
+    @PostMapping("/refund")
+    public AjaxResult refund(@RequestHeader(name = "token") String token,
+                             @RequestBody(required = false) OmgRefundPaymentRequest request) {
+        String orderId = request == null ? null : request.getOrderId();
+        String safeOrderId = safeLogOrderId(orderId);
+        Long safeUserId = null;
+        try {
+            safeUserId = tokenUserResolver.requireUserId(token);
+            refundService.refund(safeUserId, orderId);
+            return AjaxResult.success();
+        } catch (OmgPaymentBusinessException error) {
+            log.warn("OMG payment refund rejected orderId={}, userId={}, code={}",
+                    safeOrderId, safeUserId, error.getCode());
+            return AjaxResult.error(MessageUtils.message(error.getMessageKey()),
+                    new OmgPaymentErrorResponse(error.getCode().name()));
+        } catch (Exception error) {
+            log.error("OMG payment refund failed orderId={}, userId={}", safeOrderId, safeUserId, error);
+            return AjaxResult.error(MessageUtils.message("omg.pay.refund.failed"),
+                    new OmgPaymentErrorResponse("PAYMENT_REFUND_FAILED"));
+        }
+    }
+
     static String safeLogOrderId(String orderId) {
         if (orderId == null) {
             return "<empty>";

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

@@ -14,7 +14,10 @@ public enum OmgPaymentErrorCode {
     PAYMENT_CONFIGURATION_INVALID("omg.pay.configuration.invalid"),
     PAYMENT_CREATION_FAILED("omg.pay.creation.failed"),
     PAYMENT_QUERY_NOT_AVAILABLE("omg.pay.query.not.available"),
-    PAYMENT_QUERY_FAILED("omg.pay.query.failed");
+    PAYMENT_QUERY_FAILED("omg.pay.query.failed"),
+    PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT(
+            "omg.pay.refund.unavailable.test.environment"),
+    PAYMENT_REFUND_FAILED("omg.pay.refund.failed");
 
     private final String messageKey;
 

+ 43 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/OmgPaymentRefundService.java

@@ -0,0 +1,43 @@
+package com.ruoyi.app.omgpay;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+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;
+
+/**
+ * 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.
+ */
+@Service
+public class OmgPaymentRefundService {
+    private static final Logger log = LoggerFactory.getLogger(OmgPaymentRefundService.class);
+
+    public Void refund(Long userId, String orderId) {
+        if (userId == null) {
+            throw business(AUTH_REQUIRED);
+        }
+        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);
+    }
+
+    private static String normalizeOrderId(String orderId) {
+        if (orderId == null || orderId.isBlank()) {
+            throw business(ORDER_REQUIRED);
+        }
+        String normalized = orderId.trim();
+        if (normalized.length() > 64) {
+            throw business(ORDER_REQUIRED);
+        }
+        return normalized;
+    }
+
+    private static OmgPaymentBusinessException business(OmgPaymentErrorCode code) {
+        return new OmgPaymentBusinessException(code);
+    }
+}

+ 14 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/omgpay/dto/OmgRefundPaymentRequest.java

@@ -0,0 +1,14 @@
+package com.ruoyi.app.omgpay.dto;
+
+/** Refund callers identify only the business order; gateway actions remain server-owned. */
+public class OmgRefundPaymentRequest {
+    private String orderId;
+
+    public String getOrderId() {
+        return orderId;
+    }
+
+    public void setOrderId(String orderId) {
+        this.orderId = orderId;
+    }
+}

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

@@ -240,3 +240,5 @@ omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
 omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
 omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试
+omg.pay.refund.unavailable.test.environment=OMG 测试环境不支持退款,请在正式环境启用后操作
+omg.pay.refund.failed=OMG 退款操作失败,请稍后重试

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

@@ -243,3 +243,5 @@ omg.pay.configuration.invalid=The OMG Pay configuration is invalid
 omg.pay.creation.failed=The OMG Pay checkout could not be created; please try again later
 omg.pay.query.not.available=There is no current OMG payment attempt to query
 omg.pay.query.failed=The OMG payment status could not be queried; please try again later
+omg.pay.refund.unavailable.test.environment=OMG refunds are unavailable in the test environment; enable the production environment first
+omg.pay.refund.failed=The OMG refund operation failed; please try again later

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

@@ -243,3 +243,5 @@ omg.pay.configuration.invalid=Cấu hình OMG Pay không hợp lệ
 omg.pay.creation.failed=Không thể tạo trang thanh toán OMG Pay; vui lòng thử lại sau
 omg.pay.query.not.available=Không có giao dịch thanh toán OMG hiện tại để truy vấn
 omg.pay.query.failed=Không thể truy vấn trạng thái thanh toán OMG; vui lòng thử lại sau
+omg.pay.refund.unavailable.test.environment=Môi trường thử nghiệm OMG không hỗ trợ hoàn tiền; hãy bật môi trường chính thức trước
+omg.pay.refund.failed=Thao tác hoàn tiền OMG thất bại; vui lòng thử lại sau

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

@@ -244,3 +244,5 @@ omg.pay.configuration.invalid=OMG 支付配置无效
 omg.pay.creation.failed=OMG 支付创建失败,请稍后重试
 omg.pay.query.not.available=当前没有可查询的 OMG 支付信息
 omg.pay.query.failed=无法查询 OMG 支付状态,请稍后重试
+omg.pay.refund.unavailable.test.environment=OMG 测试环境不支持退款,请在正式环境启用后操作
+omg.pay.refund.failed=OMG 退款操作失败,请稍后重试

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

@@ -244,3 +244,5 @@ omg.pay.configuration.invalid=OMG 支付設定無效
 omg.pay.creation.failed=OMG 支付建立失敗,請稍後再試
 omg.pay.query.not.available=目前沒有可查詢的 OMG 付款資訊
 omg.pay.query.failed=無法查詢 OMG 付款狀態,請稍後再試
+omg.pay.refund.unavailable.test.environment=OMG 測試環境不支援退款,請在正式環境啟用後操作
+omg.pay.refund.failed=OMG 退款操作失敗,請稍後再試

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentControllerTest.java

@@ -141,6 +141,7 @@ class OmgPaymentControllerTest {
     private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
                                                    OmgPaymentCreateService service) {
         return new OmgPaymentController(resolver, service, mock(OmgPaymentNotifyService.class),
-                mock(OmgIpnAuditService.class), mock(OmgPaymentQueryService.class));
+                mock(OmgIpnAuditService.class), mock(OmgPaymentQueryService.class),
+                mock(OmgPaymentRefundService.class));
     }
 }

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentNotifyControllerTest.java

@@ -69,7 +69,7 @@ class OmgPaymentNotifyControllerTest {
     private static OmgPaymentController controller(OmgPaymentNotifyService notifyService, OmgIpnAuditService audit) {
         return new OmgPaymentController(mock(OmgPaymentTokenUserResolver.class),
                 mock(OmgPaymentCreateService.class), notifyService, audit,
-                mock(OmgPaymentQueryService.class));
+                mock(OmgPaymentQueryService.class), mock(OmgPaymentRefundService.class));
     }
 
     private static ListAppender<ILoggingEvent> captureLogs() {

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentQueryControllerTest.java

@@ -71,6 +71,7 @@ class OmgPaymentQueryControllerTest {
     private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
                                                    OmgPaymentQueryService queryService) {
         return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
-                mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class), queryService);
+                mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class), queryService,
+                mock(OmgPaymentRefundService.class));
     }
 }

+ 80 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/omgpay/OmgPaymentRefundControllerTest.java

@@ -0,0 +1,80 @@
+package com.ruoyi.app.omgpay;
+
+import com.ruoyi.app.omgpay.dto.OmgPaymentErrorResponse;
+import com.ruoyi.app.omgpay.dto.OmgRefundPaymentRequest;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.omgpay.service.OmgIpnAuditService;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+class OmgPaymentRefundControllerTest {
+    @Test
+    void exposesAuthenticatedOrderIdOnlyRefundContract() throws Exception {
+        Method method = OmgPaymentController.class.getDeclaredMethod(
+                "refund", String.class, OmgRefundPaymentRequest.class);
+
+        assertArrayEquals(new String[]{"/refund"}, method.getAnnotation(PostMapping.class).value());
+        assertEquals("token", method.getParameters()[0].getAnnotation(RequestHeader.class).name());
+        assertNotNull(method.getParameters()[1].getAnnotation(RequestBody.class));
+        assertEquals(1, OmgRefundPaymentRequest.class.getDeclaredFields().length);
+        assertEquals("orderId", OmgRefundPaymentRequest.class.getDeclaredFields()[0].getName());
+        assertTrue(java.util.Arrays.stream(method.getParameterTypes())
+                .noneMatch(Map.class::isAssignableFrom));
+    }
+
+    @Test
+    void returnsStableLocalizedTestEnvironmentRejection() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+            OmgPaymentRefundService refundService = mock(OmgPaymentRefundService.class);
+            when(resolver.requireUserId("token")).thenReturn(5L);
+            when(refundService.refund(5L, "DD-1")).thenThrow(new OmgPaymentBusinessException(
+                    OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT));
+            OmgRefundPaymentRequest request = new OmgRefundPaymentRequest();
+            request.setOrderId("DD-1");
+
+            AjaxResult result = controller(resolver, refundService).refund("token", request);
+
+            assertEquals("omg.pay.refund.unavailable.test.environment", result.get("msg"));
+            assertEquals("PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT",
+                    ((OmgPaymentErrorResponse) result.get("data")).status());
+        }
+    }
+
+    @Test
+    void unexpectedFailureUsesStableRefundFailureWithoutLeakingToken() {
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(call -> call.getArgument(0));
+            OmgPaymentTokenUserResolver resolver = mock(OmgPaymentTokenUserResolver.class);
+            OmgPaymentRefundService refundService = mock(OmgPaymentRefundService.class);
+            when(resolver.requireUserId("NEVER_LOG")).thenReturn(5L);
+            when(refundService.refund(5L, "DD-1")).thenThrow(new IllegalStateException("unexpected"));
+            OmgRefundPaymentRequest request = new OmgRefundPaymentRequest();
+            request.setOrderId("DD-1");
+
+            AjaxResult result = controller(resolver, refundService).refund("NEVER_LOG", request);
+
+            assertEquals("PAYMENT_REFUND_FAILED",
+                    ((OmgPaymentErrorResponse) result.get("data")).status());
+        }
+    }
+
+    private static OmgPaymentController controller(OmgPaymentTokenUserResolver resolver,
+                                                   OmgPaymentRefundService refundService) {
+        return new OmgPaymentController(resolver, mock(OmgPaymentCreateService.class),
+                mock(OmgPaymentNotifyService.class), mock(OmgIpnAuditService.class),
+                mock(OmgPaymentQueryService.class), refundService);
+    }
+}

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

@@ -0,0 +1,32 @@
+package com.ruoyi.app.omgpay;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class OmgPaymentRefundServiceTest {
+    private final OmgPaymentRefundService service = new OmgPaymentRefundService();
+
+    @Test
+    void testEnvironmentRejectsRefundBeforeAnyGatewayOrStateChangeCanExist() {
+        OmgPaymentBusinessException error = assertThrows(OmgPaymentBusinessException.class,
+                () -> service.refund(5L, "DD-1"));
+
+        assertEquals(OmgPaymentErrorCode.PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT,
+                error.getCode());
+    }
+
+    @Test
+    void validatesAuthenticationAndOrderIdBeforeEnvironmentResponse() {
+        assertEquals(OmgPaymentErrorCode.AUTH_REQUIRED,
+                assertThrows(OmgPaymentBusinessException.class,
+                        () -> service.refund(null, "DD-1")).getCode());
+        assertEquals(OmgPaymentErrorCode.ORDER_REQUIRED,
+                assertThrows(OmgPaymentBusinessException.class,
+                        () -> service.refund(5L, " ")).getCode());
+        assertEquals(OmgPaymentErrorCode.ORDER_REQUIRED,
+                assertThrows(OmgPaymentBusinessException.class,
+                        () -> service.refund(5L, "X".repeat(65))).getCode());
+    }
+}

+ 20 - 0
specs/020-omg-payment-rebuild/contracts/api.md

@@ -154,3 +154,23 @@ The server verifies ownership and chooses the only current payment attempt. An u
 - User-triggered query is not an incoming IPN and is not inserted into `ipn_log`.
 
 The response whitelist includes normalized `status` (`PAID`, `UNPAID`, `FAILED`, `UNKNOWN`), raw `tradeStatus`, trade numbers, amount, gateway dates, payment type and fees. Invalid/tampered responses return `PAYMENT_QUERY_FAILED`; no eligible attempt returns `PAYMENT_QUERY_NOT_AVAILABLE`.
+
+## POST `/pay/omg/refund`
+
+Current scope is a test-environment safety boundary. Header `token` is required and the JSON body contains only:
+
+```json
+{"orderId":"991786433092835"}
+```
+
+The client cannot provide refund amount, `MerchantTradeNo`, `TradeNo`, `Action`, credentials or gateway URL. After authentication and basic order-ID validation, the endpoint always returns:
+
+```json
+{
+  "code":500,
+  "msg":"OMG 测试环境不支持退款,请在正式环境启用后操作",
+  "data":{"status":"PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT"}
+}
+```
+
+The endpoint does not call the production-only OMG `CreditDetail/DoAction` API, does not read or update order/payment/refund state, does not insert `ipn_log`, and does not create a refund record. Formal refund orchestration remains out of scope until the production environment is explicitly enabled.

+ 9 - 2
specs/020-omg-payment-rebuild/plan.md

@@ -4,14 +4,14 @@
 
 **Goal:** 从零实现测试环境 `POST /pay/omg/create` 和 `POST /pay/omg/notify`,按门店独立凭证创建官方 AIO 表单,并以凭证快照可信、幂等地同步最终付款结果。
 
-**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建/查询入口、外部表单边界、官方检查码、回调/查询校验、事务编排与日志。创建流程保存实际密钥快照;回调和可信查询结果共同进入同一不可逆支付状态机,查询成功可补偿丢失回调并只更新订单 `pay_status`。旧 `pos_store_omg` 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。
+**Architecture:** `ruoyi-system/com.ruoyi.system.omgpay` 负责新尝试表、订单锁、状态 CAS、凭证快照和 `ipn_log` 独立事务;`ruoyi-admin/com.ruoyi.app.omgpay` 负责 token 创建/查询/退款边界、外部表单、官方检查码、回调/查询校验、事务编排与日志。创建流程保存实际密钥快照;回调和可信查询结果共同进入同一不可逆支付状态机;退款在测试阶段由独立 Service 失败关闭,不读取或写入支付事实,也不连接正式网关。旧 `pos_store_omg` 凭证查询保持不变,其余旧 OMG 支付代码不得被新流程引用。
 
 **Tech Stack:** Java 21、Spring Boot 3、MyBatis/MyBatis-Plus、MySQL、JUnit 5、Mockito、SLF4J/Logback、Maven Surefire。
 
 ## Global Constraints
 
 - OMG AIO 官方技术文件 V1.5.3 是支付协议唯一外部事实来源;不得从旧代码或 `specs/016-omg-payment` 复制行为。
-- 只实现创建、最终付款结果回调及用户触发的当前支付查询补偿;取号通知、定时/批量补单、退款、推送、关账和正式环境全部不实现。
+- 实现创建、最终付款结果回调、用户触发查询补偿,以及测试环境退款安全拒绝;取号通知、定时/批量补单、正式退款动作、推送、关账和正式环境全部不实现。
 - 新代码只能位于 `com.ruoyi.app.omgpay`、`com.ruoyi.system.omgpay` 及对应资源/测试目录。
 - `ruoyi-admin -> ruoyi-system`;`ruoyi-system` 禁止导入 `com.ruoyi.app.*`。
 - 每个门店独立使用 `pos_store_omg` 中已启用的 `MerchantID / HashKey / HashIV`;不修改可信凭证存储与管理代码。
@@ -1101,3 +1101,10 @@ No constitution violations. The separate form factory, signer, generator and per
 4. 严格解析响应,全部实际字段参与验签,并核对 MerchantID、MerchantTradeNo 与金额。
 5. `TradeStatus=1/10200095` 进入与回调相同的订单/尝试锁和不可逆状态机;`0` 保持只读。
 6. 查询不是 IPN,不写 `ipn_log`;日志只记录必要的脱敏定位信息。
+
+### Test-stage refund boundary
+
+1. `POST /pay/omg/refund` 使用 token 和只有 `orderId` 的显式 DTO。
+2. Service 只校验身份与订单号格式,随后稳定返回环境不支持错误。
+3. 代码中不引入正式 `CreditDetail/DoAction` 地址、HTTP Gateway、退款表或订单/支付更新。
+4. Controller 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT` 及五语言提示;异常统一为 `PAYMENT_REFUND_FAILED`。

+ 6 - 1
specs/020-omg-payment-rebuild/spec.md

@@ -19,6 +19,7 @@
 - 防止同一业务订单同时产生多个未结束 OMG 支付尝试。
 - 在 `POST /pay/omg/notify` 接收 OMG 最终付款结果,按创建尝试的凭证快照验签并幂等更新支付事实。
 - 在 `POST /pay/omg/query` 只查询订单当前有效支付尝试;可信已付款结果复用回调状态机补偿丢失回调。
+- 提供 `POST /pay/omg/refund` 的测试阶段拒绝契约:明确提示测试环境不支持退款,且不调用正式网关、不改本地状态。
 - 每次回调独立写入现有 `ipn_log`,并保存完整、可重放的 form-urlencoded 回传内容。
 - 成功回调只把订单 `payStatus` 更新为已付款,不推进订单、配送或推送流程。
 - 停用旧 OMG Controller 及其补单、退款、定时任务和订单取消调用入口。
@@ -28,7 +29,7 @@
 - ATM、CVS、BarcodeATM 取号结果通知及缴费信息展示。
 - `OrderResultURL`、`PaymentInfoURL`、`ClientRedirectURL`、`ClientBackURL`。
 - 定时/批量自动补单、独立人工补单入口;用户触发的当前支付查询补偿属于本阶段范围。
-- 退款、取消交易、信用卡关账。
+- 正式环境退款动作、取消交易、信用卡关账;本阶段只实现测试环境安全拒绝入口
 - 分期、定期定额、记忆卡号、银联专用流程。
 - 正式环境开放。
 - 旧 OMG 支付流水的数据迁移、兼容或清理。
@@ -132,6 +133,7 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - 支付成功回调丢失时,用户查询当前有效尝试;完整验签及商户号、交易号、金额核对后必须原子补偿为已付款。
 - 查询返回未付款时不得修改本地状态;返回 `10200095` 时同步失败信息;任何查询不得把已付款降级。
 - 查询响应新增字段或空值字段仍必须参与检查码计算,客户端不得指定要查询的 `MerchantTradeNo`。
+- 测试环境调用退款时必须在任何支付/订单读取、网关 HTTP 或状态写入前失败关闭,返回稳定的环境不支持状态。
 
 - 业务订单号含有不适合 OMG `MerchantTradeNo` 的字符时,系统使用独立生成的英数字编号,不直接拼接或截断业务订单号。
 - 随机生成的 `MerchantTradeNo` 发生唯一索引冲突时,系统可在同一创建事务中重新生成;达到受控次数仍失败时整笔创建回滚。
@@ -153,6 +155,9 @@ OMG 向 `ReturnURL` 发送最终付款结果时,系统保存本次 HTTP 回传
 - **FR-039**: 查询响应 MUST 将全部实际回传参数(含额外与空值)纳入检查码计算,仅排除 `CheckMacValue`,并核对商户号、交易号与金额。
 - **FR-040**: `TradeStatus=1` MUST 复用回调的锁与幂等状态机更新尝试和订单为已付款;`0` MUST 不修改;`10200095` MUST 同步失败;已付款 MUST 不可逆。
 - **FR-041**: 查询只返回白名单字段且不写 `ipn_log`;HashKey、HashIV、CheckMacValue 和完整网关响应 MUST NOT 返回客户端或写入应用日志。
+- **FR-042**: `POST /pay/omg/refund` MUST 需要 token,并以显式 JSON DTO 只接收 `orderId`;不得接收客户端提供的金额、交易号、Action、凭证或地址。
+- **FR-043**: 当前测试阶段退款 MUST 返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT`,MUST NOT 调用 OMG 正式 `CreditDetail/DoAction`、查询或写入任何订单/支付/退款状态。
+- **FR-044**: 测试环境退款拒绝 MUST 提供五套 i18n 提示和必要的脱敏日志;日志不得记录 token、凭证或完整支付签名。
 
 - **FR-001**: 系统 MUST 新建 `com.ruoyi.app.omgpay` 下的 Controller、请求/响应 DTO、创建服务、表单生成器、签名器和配置类型;这些新类 MUST NOT 引用旧 `OmgPayController`、旧 `OmgPay`、旧 `OmgCheckMacValue` 或旧 OMG 支付流水服务。
 - **FR-002**: 系统 MUST 新建 `com.ruoyi.system.omgpay` 下的支付尝试 Entity、Mapper 和 Service;新支付尝试 MUST 使用 `pos_order_omg_attempt`,不得读取或写入旧 OMG 支付流水表。

+ 9 - 0
specs/020-omg-payment-rebuild/tasks.md

@@ -119,6 +119,15 @@
 - [x] T069 [US5] 增加查询 i18n、必要安全日志、API/规格说明;不写 `ipn_log`,因为它不是网关主动 IPN
 - [ ] T070 所有 OMG 功能调整完成后,统一执行本阶段测试源码、JDK 21 模块构建和完整回归
 
+## Phase 12: 测试环境退款安全拒绝接口
+
+- [x] T071 [US6] 先编写 Refund Service 与 Controller 测试源码,覆盖 token、显式 `orderId` DTO、稳定状态、异常映射和无网关/无状态依赖
+- [x] T072 [US6] 新建 `OmgRefundPaymentRequest` 与 `OmgPaymentRefundService`,在身份/订单号格式校验后直接返回测试环境不支持
+- [x] T073 [US6] 在新 Controller 增加 `POST /pay/omg/refund`,返回 `PAYMENT_REFUND_UNAVAILABLE_IN_TEST_ENVIRONMENT`,不接受金额、交易号或 Action
+- [x] T074 [US6] 增加 default、zh_CN、zh_TW、en_US、vi 一致 i18n 和脱敏拒绝日志
+- [x] T075 [US6] 更新规格与 API 契约,明确不调用正式 OMG 地址、不读取或修改订单/支付/退款状态、不新增 SQL
+- [ ] T076 所有 OMG 功能调整完成后,统一执行退款测试源码、JDK 21 模块构建和完整回归
+
 ## Dependencies & Execution Order
 
 - Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 → Phase 9 → Phase 10。