ソースを参照

refactor(omg): 统一使用请求头和 DTO 接收参数

qmj 23 時間 前
コミット
3326cb9fb9

+ 37 - 35
ruoyi-admin/src/main/java/com/ruoyi/app/pay/OmgPayController.java

@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.alibaba.fastjson2.JSON;
 import com.alibaba.fastjson2.JSON;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.OmgCallbackRequest;
+import com.ruoyi.app.pay.dto.OmgOrderRequest;
 import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.PayPush;
 import com.ruoyi.app.utils.event.PushEventService;
 import com.ruoyi.app.utils.event.PushEventService;
@@ -30,8 +32,8 @@ import com.ruoyi.system.service.IPosStoreOmgService;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
 import com.ruoyi.system.utils.OrderLogHelper;
-import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -41,11 +43,12 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronization;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestMethod;
 import org.springframework.web.bind.annotation.RequestMethod;
-import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.bind.annotation.RestController;
 
 
 import java.io.IOException;
 import java.io.IOException;
@@ -53,7 +56,6 @@ import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.StandardCharsets;
 import java.text.SimpleDateFormat;
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.Date;
-import java.util.Enumeration;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Locale;
 import java.util.Locale;
@@ -127,8 +129,9 @@ public class OmgPayController extends BaseController {
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @RepeatSubmit(interval = 1000, message = "请求过于频繁")
     @PostMapping("/create")
     @PostMapping("/create")
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
-    public AjaxResult create(@RequestParam String orderid, HttpServletRequest request) {
-        String token = request.getHeader("token");
+    public AjaxResult create(@RequestHeader String token,
+                             @Valid @ModelAttribute OmgOrderRequest request) {
+        String orderid = request.getOrderid();
         String userId;
         String userId;
         try {
         try {
             userId = new JwtUtil().getusid(token);
             userId = new JwtUtil().getusid(token);
@@ -243,10 +246,11 @@ public class OmgPayController extends BaseController {
     @Anonymous
     @Anonymous
     @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
     @PostMapping(value = "/notify", produces = "text/plain;charset=UTF-8")
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
-    public String notify(HttpServletRequest request) {
+    public String notify(@ModelAttribute OmgCallbackRequest callback,
+                         @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) {
         Map<String, String> form;
         Map<String, String> form;
         try {
         try {
-            form = collectForm(request);
+            form = callback.toParameterMap();
         } catch (IllegalArgumentException e) {
         } catch (IllegalArgumentException e) {
             log.warn("OMG callback rejected: {}", e.getMessage());
             log.warn("OMG callback rejected: {}", e.getMessage());
             return "1|OK";
             return "1|OK";
@@ -255,7 +259,7 @@ public class OmgPayController extends BaseController {
         // 记录 IPN 日志
         // 记录 IPN 日志
         try {
         try {
             IpnLog ipnLog = new IpnLog();
             IpnLog ipnLog = new IpnLog();
-            ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
+            ipnLog.setIp(callbackIp(forwardedFor));
             ipnLog.setIpnLog(auditJson(form));
             ipnLog.setIpnLog(auditJson(form));
             ipnLogService.insertIpnLog(ipnLog);
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
         } catch (Exception e) {
@@ -377,10 +381,11 @@ public class OmgPayController extends BaseController {
      */
      */
     @Anonymous
     @Anonymous
     @RequestMapping(value = "/return", method = {RequestMethod.GET, RequestMethod.POST})
     @RequestMapping(value = "/return", method = {RequestMethod.GET, RequestMethod.POST})
-    public void returnCallback(HttpServletRequest request, HttpServletResponse response) throws IOException {
+    public void returnCallback(@ModelAttribute OmgCallbackRequest callback,
+                               HttpServletResponse response) throws IOException {
         String ddId = "";
         String ddId = "";
         try {
         try {
-            Map<String, String> form = collectForm(request);
+            Map<String, String> form = callback.toParameterMap();
             String mtn = form.get("MerchantTradeNo");
             String mtn = form.get("MerchantTradeNo");
             // 由 MerchantTradeNo 反查流水拿 ddId(MTN 不再编码 ddId)
             // 由 MerchantTradeNo 反查流水拿 ddId(MTN 不再编码 ddId)
             if (mtn != null && !mtn.isEmpty()) {
             if (mtn != null && !mtn.isEmpty()) {
@@ -410,17 +415,18 @@ public class OmgPayController extends BaseController {
      */
      */
     @Anonymous
     @Anonymous
     @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8")
     @PostMapping(value = "/paymentInfo", produces = "text/plain;charset=UTF-8")
-    public String paymentInfoCallback(HttpServletRequest request) {
+    public String paymentInfoCallback(@ModelAttribute OmgCallbackRequest callback,
+                                      @RequestHeader(value = "X-Forwarded-For", required = false) String forwardedFor) {
         Map<String, String> form;
         Map<String, String> form;
         try {
         try {
-            form = collectForm(request);
+            form = callback.toParameterMap();
         } catch (IllegalArgumentException e) {
         } catch (IllegalArgumentException e) {
             log.warn("OMG paymentInfo rejected: {}", e.getMessage());
             log.warn("OMG paymentInfo rejected: {}", e.getMessage());
             return "1|OK";
             return "1|OK";
         }
         }
         try {
         try {
             IpnLog ipnLog = new IpnLog();
             IpnLog ipnLog = new IpnLog();
-            ipnLog.setIp(new com.ruoyi.app.utils.IpUtils().getIpAddr(request));
+            ipnLog.setIp(callbackIp(forwardedFor));
             ipnLog.setIpnLog(auditJson(form));
             ipnLog.setIpnLog(auditJson(form));
             ipnLogService.insertIpnLog(ipnLog);
             ipnLogService.insertIpnLog(ipnLog);
         } catch (Exception e) {
         } catch (Exception e) {
@@ -472,14 +478,16 @@ public class OmgPayController extends BaseController {
     @Anonymous
     @Anonymous
     @Auth
     @Auth
     @GetMapping("/paymentInfo/{orderid}")
     @GetMapping("/paymentInfo/{orderid}")
-    public AjaxResult getPaymentInfo(@PathVariable String orderid, HttpServletRequest request) {
-        String token = request.getHeader("token");
+    public AjaxResult getPaymentInfo(@RequestHeader String token, @PathVariable String orderid) {
         String userId;
         String userId;
         try {
         try {
             userId = new JwtUtil().getusid(token);
             userId = new JwtUtil().getusid(token);
         } catch (Exception e) {
         } catch (Exception e) {
             return error(MessageUtils.message("no.order.id.error"));
             return error(MessageUtils.message("no.order.id.error"));
         }
         }
+        if (userId == null || userId.isEmpty()) {
+            return error("请先登录");
+        }
         PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
         PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
         if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
         if (order == null || order.getUserId() == null || !userId.equals(String.valueOf(order.getUserId()))) {
             return error("无权操作该订单");
             return error("无权操作该订单");
@@ -505,14 +513,18 @@ public class OmgPayController extends BaseController {
     @Anonymous
     @Anonymous
     @Auth
     @Auth
     @PostMapping("/refund")
     @PostMapping("/refund")
-    public AjaxResult refund(@RequestParam String orderid, HttpServletRequest request) {
-        String token = request.getHeader("token");
+    public AjaxResult refund(@RequestHeader String token,
+                             @Valid @ModelAttribute OmgOrderRequest request) {
+        String orderid = request.getOrderid();
         String userId;
         String userId;
         try {
         try {
             userId = new JwtUtil().getusid(token);
             userId = new JwtUtil().getusid(token);
         } catch (Exception e) {
         } catch (Exception e) {
             return error(MessageUtils.message("no.order.id.error"));
             return error(MessageUtils.message("no.order.id.error"));
         }
         }
+        if (userId == null || userId.isEmpty()) {
+            return error("请先登录");
+        }
         PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
         PosOrder order = posOrderService.getOne(new QueryWrapper<PosOrder>().eq("dd_id", orderid));
         if (order == null) {
         if (order == null) {
             return error("订单不存在");
             return error("订单不存在");
@@ -693,8 +705,9 @@ public class OmgPayController extends BaseController {
     @Auth
     @Auth
     @RepeatSubmit(interval = 2000, message = "查询过于频繁")
     @RepeatSubmit(interval = 2000, message = "查询过于频繁")
     @PostMapping("/query")
     @PostMapping("/query")
-    public AjaxResult query(@RequestParam String orderid, HttpServletRequest request) {
-        String token = request.getHeader("token");
+    public AjaxResult query(@RequestHeader String token,
+                            @Valid @ModelAttribute OmgOrderRequest request) {
+        String orderid = request.getOrderid();
         String userId;
         String userId;
         try {
         try {
             userId = new JwtUtil().getusid(token);
             userId = new JwtUtil().getusid(token);
@@ -919,23 +932,12 @@ public class OmgPayController extends BaseController {
 
 
     // ============================ 辅助 ============================
     // ============================ 辅助 ============================
 
 
-    /** 收集 form-urlencoded 回调参数为 Map(OMG 明文参数,含 CheckMacValue)。 */
-    Map<String, String> collectForm(HttpServletRequest req) {
-        Map<String, String> map = new LinkedHashMap<>();
-        Enumeration<String> names = req.getParameterNames();
-        while (names.hasMoreElements()) {
-            String n = names.nextElement();
-            String[] values = req.getParameterValues(n);
-            if (n == null || n.isEmpty() || n.length() > 100 || values == null || values.length != 1
-                    || values[0] == null || values[0].length() > 4096) {
-                throw new IllegalArgumentException("invalid or duplicate callback parameter");
-            }
-            map.put(n, values[0]);
-            if (map.size() > 100) {
-                throw new IllegalArgumentException("too many callback parameters");
-            }
+    private String callbackIp(String forwardedFor) {
+        if (forwardedFor == null || forwardedFor.isBlank()) {
+            return "unknown";
         }
         }
-        return map;
+        String firstIp = forwardedFor.split(",", 2)[0].trim();
+        return firstIp.isEmpty() || firstIp.length() > 64 ? "unknown" : firstIp;
     }
     }
 
 
     private int toInt(String s, int def) {
     private int toInt(String s, int def) {

+ 153 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/OmgCallbackRequest.java

@@ -0,0 +1,153 @@
+package com.ruoyi.app.pay.dto;
+
+import lombok.Data;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * OMG AIO 支付、取号及结果页回调参数。
+ *
+ * <p>OMG 使用 PascalCase/form-urlencoded 字段并基于原字段名计算 CheckMacValue,
+ * 因此属性名与 OMG 协议保持一致。未列入协议白名单的字段不会进入验签。</p>
+ */
+@Data
+public class OmgCallbackRequest {
+
+    private static final int MAX_VALUE_LENGTH = 4096;
+
+    private String[] MerchantID;
+    private String[] MerchantTradeNo;
+    private String[] StoreID;
+    private String[] RtnCode;
+    private String[] RtnMsg;
+    private String[] TradeNo;
+    private String[] TradeAmt;
+    private String[] PaymentDate;
+    private String[] PaymentType;
+    private String[] PaymentTypeChargeFee;
+    private String[] TradeDate;
+    private String[] SimulatePaid;
+    private String[] CustomField1;
+    private String[] CustomField2;
+    private String[] CustomField3;
+    private String[] CustomField4;
+    private String[] CheckMacValue;
+
+    private String[] BankCode;
+    private String[] vAccount;
+    private String[] ExpireDate;
+    private String[] PaymentNo;
+    private String[] Barcode1;
+    private String[] Barcode2;
+    private String[] Barcode3;
+    private String[] CVSStoreID;
+    private String[] CVSStoreName;
+    private String[] PayFrom;
+
+    private String[] AlipayID;
+    private String[] AlipayTradeNo;
+    private String[] ATMAccBank;
+    private String[] ATMAccNo;
+    private String[] WebATMAccBank;
+    private String[] WebATMAccNo;
+    private String[] WebATMBankName;
+    private String[] auth_code;
+    private String[] AuthCode;
+    private String[] card4no;
+    private String[] card6no;
+    private String[] eci;
+    private String[] gwsr;
+    private String[] process_date;
+    private String[] stage;
+    private String[] stast;
+    private String[] staed;
+    private String[] red_dan;
+    private String[] red_de_amt;
+    private String[] red_ok_amt;
+    private String[] red_yet;
+    private String[] PeriodType;
+    private String[] Frequency;
+    private String[] ExecTimes;
+    private String[] PeriodAmount;
+    private String[] amount;
+    private String[] TotalSuccessTimes;
+    private String[] TotalSuccessAmount;
+
+    /**
+     * 转为 OMG 验签所需的原始字段名集合,并在进入验签前限制单字段长度。
+     */
+    public Map<String, String> toParameterMap() {
+        Map<String, String> parameters = new LinkedHashMap<>();
+        put(parameters, "MerchantID", MerchantID);
+        put(parameters, "MerchantTradeNo", MerchantTradeNo);
+        put(parameters, "StoreID", StoreID);
+        put(parameters, "RtnCode", RtnCode);
+        put(parameters, "RtnMsg", RtnMsg);
+        put(parameters, "TradeNo", TradeNo);
+        put(parameters, "TradeAmt", TradeAmt);
+        put(parameters, "PaymentDate", PaymentDate);
+        put(parameters, "PaymentType", PaymentType);
+        put(parameters, "PaymentTypeChargeFee", PaymentTypeChargeFee);
+        put(parameters, "TradeDate", TradeDate);
+        put(parameters, "SimulatePaid", SimulatePaid);
+        put(parameters, "CustomField1", CustomField1);
+        put(parameters, "CustomField2", CustomField2);
+        put(parameters, "CustomField3", CustomField3);
+        put(parameters, "CustomField4", CustomField4);
+        put(parameters, "CheckMacValue", CheckMacValue);
+        put(parameters, "BankCode", BankCode);
+        put(parameters, "vAccount", vAccount);
+        put(parameters, "ExpireDate", ExpireDate);
+        put(parameters, "PaymentNo", PaymentNo);
+        put(parameters, "Barcode1", Barcode1);
+        put(parameters, "Barcode2", Barcode2);
+        put(parameters, "Barcode3", Barcode3);
+        put(parameters, "CVSStoreID", CVSStoreID);
+        put(parameters, "CVSStoreName", CVSStoreName);
+        put(parameters, "PayFrom", PayFrom);
+        put(parameters, "AlipayID", AlipayID);
+        put(parameters, "AlipayTradeNo", AlipayTradeNo);
+        put(parameters, "ATMAccBank", ATMAccBank);
+        put(parameters, "ATMAccNo", ATMAccNo);
+        put(parameters, "WebATMAccBank", WebATMAccBank);
+        put(parameters, "WebATMAccNo", WebATMAccNo);
+        put(parameters, "WebATMBankName", WebATMBankName);
+        put(parameters, "auth_code", auth_code);
+        put(parameters, "AuthCode", AuthCode);
+        put(parameters, "card4no", card4no);
+        put(parameters, "card6no", card6no);
+        put(parameters, "eci", eci);
+        put(parameters, "gwsr", gwsr);
+        put(parameters, "process_date", process_date);
+        put(parameters, "stage", stage);
+        put(parameters, "stast", stast);
+        put(parameters, "staed", staed);
+        put(parameters, "red_dan", red_dan);
+        put(parameters, "red_de_amt", red_de_amt);
+        put(parameters, "red_ok_amt", red_ok_amt);
+        put(parameters, "red_yet", red_yet);
+        put(parameters, "PeriodType", PeriodType);
+        put(parameters, "Frequency", Frequency);
+        put(parameters, "ExecTimes", ExecTimes);
+        put(parameters, "PeriodAmount", PeriodAmount);
+        put(parameters, "amount", amount);
+        put(parameters, "TotalSuccessTimes", TotalSuccessTimes);
+        put(parameters, "TotalSuccessAmount", TotalSuccessAmount);
+        return parameters;
+    }
+
+    private void put(Map<String, String> parameters, String name, String[] values) {
+        if (values == null) {
+            return;
+        }
+        if (values.length != 1 || values[0] == null) {
+            throw new IllegalArgumentException("duplicate or invalid callback parameter: " + name);
+        }
+        String value = values[0];
+        if (value.length() > MAX_VALUE_LENGTH) {
+            throw new IllegalArgumentException("callback parameter is too long: " + name);
+        }
+        parameters.put(name, value);
+    }
+}

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

@@ -0,0 +1,18 @@
+package com.ruoyi.app.pay.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * OMG 订单操作请求。
+ *
+ * <p>字段名保留 {@code orderid},兼容现有 query/form 请求契约。</p>
+ */
+@Data
+public class OmgOrderRequest {
+
+    @NotBlank(message = "订单号不能为空")
+    @Size(max = 64, message = "订单号长度不能超过64个字符")
+    private String orderid;
+}

+ 89 - 11
ruoyi-admin/src/test/java/com/ruoyi/app/pay/OmgPayControllerTest.java

@@ -4,6 +4,7 @@ import ch.qos.logback.classic.Logger;
 import ch.qos.logback.classic.spi.ILoggingEvent;
 import ch.qos.logback.classic.spi.ILoggingEvent;
 import ch.qos.logback.core.read.ListAppender;
 import ch.qos.logback.core.read.ListAppender;
 import com.ruoyi.app.order.OrderLifecycleService;
 import com.ruoyi.app.order.OrderLifecycleService;
+import com.ruoyi.app.pay.dto.OmgCallbackRequest;
 import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.pay.dto.OmgRefundOutcome;
 import com.ruoyi.app.utils.omg.OmgPay;
 import com.ruoyi.app.utils.omg.OmgPay;
 import com.ruoyi.system.domain.PosOrder;
 import com.ruoyi.system.domain.PosOrder;
@@ -17,15 +18,22 @@ import com.ruoyi.system.service.IPosStoreOmgService;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.MutablePropertyValues;
 import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.validation.DataBinder;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
 
 
-import java.util.Collections;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -37,22 +45,85 @@ import static org.mockito.Mockito.when;
 class OmgPayControllerTest {
 class OmgPayControllerTest {
 
 
     @Test
     @Test
-    void collectsSingleValuedCallbackParameters() {
-        HttpServletRequest request = mock(HttpServletRequest.class);
-        when(request.getParameterNames()).thenReturn(Collections.enumeration(Collections.singleton("RtnCode")));
-        when(request.getParameterValues("RtnCode")).thenReturn(new String[]{"1"});
+    void controllerDoesNotDependOnHttpServletRequest() {
+        boolean usesServletRequest = Arrays.stream(OmgPayController.class.getDeclaredMethods())
+                .flatMap(method -> Arrays.stream(method.getParameterTypes()))
+                .anyMatch(HttpServletRequest.class::equals);
 
 
-        assertEquals("1", new OmgPayController().collectForm(request).get("RtnCode"));
+        assertFalse(usesServletRequest);
     }
     }
 
 
     @Test
     @Test
-    void rejectsDuplicateCallbackParameters() {
-        HttpServletRequest request = mock(HttpServletRequest.class);
-        when(request.getParameterNames()).thenReturn(Collections.enumeration(Collections.singleton("MerchantID")));
-        when(request.getParameterValues("MerchantID")).thenReturn(new String[]{"first", "second"});
+    void authenticatedEndpointsReceiveTokenAsRequestHeader() {
+        for (String methodName : List.of("create", "getPaymentInfo", "refund", "query")) {
+            boolean hasTokenHeader = Arrays.stream(method(methodName).getParameters())
+                    .anyMatch(parameter -> parameter.getType() == String.class
+                            && parameter.isAnnotationPresent(RequestHeader.class)
+                            && "token".equals(parameter.getName()));
+            assertTrue(hasTokenHeader, methodName + " should receive token via @RequestHeader String token");
+        }
+    }
+
+    @Test
+    void orderCommandEndpointsReceiveTypedDtoInsteadOfRequestParams() {
+        for (String methodName : List.of("create", "refund", "query")) {
+            Parameter[] parameters = method(methodName).getParameters();
+            assertFalse(Arrays.stream(parameters)
+                    .anyMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
+            assertTrue(Arrays.stream(parameters)
+                    .anyMatch(parameter -> parameter.getType().getPackageName().equals("com.ruoyi.app.pay.dto")));
+        }
+    }
+
+    @Test
+    void omgCallbacksReceiveTypedDto() {
+        for (String methodName : List.of("notify", "returnCallback", "paymentInfoCallback")) {
+            assertTrue(Arrays.stream(method(methodName).getParameterTypes())
+                    .anyMatch(type -> type.getPackageName().equals("com.ruoyi.app.pay.dto")));
+        }
+    }
+
+    @Test
+    void callbackDtoRetainsOriginalOmgParameterNames() {
+        OmgCallbackRequest request = new OmgCallbackRequest();
+        request.setMerchantID(new String[]{"M123"});
+        request.setRtnCode(new String[]{"1"});
+        request.setCheckMacValue(new String[]{"SIGNED"});
+
+        assertEquals(Map.of("MerchantID", "M123", "RtnCode", "1", "CheckMacValue", "SIGNED"),
+                request.toParameterMap());
+    }
+
+    @Test
+    void callbackDtoRejectsOversizedParameters() {
+        OmgCallbackRequest request = new OmgCallbackRequest();
+        request.setRtnMsg(new String[]{"x".repeat(4097)});
+
+        assertThrows(IllegalArgumentException.class,
+                request::toParameterMap);
+    }
+
+    @Test
+    void callbackDtoRejectsDuplicateParameters() {
+        OmgCallbackRequest request = new OmgCallbackRequest();
+        request.setMerchantID(new String[]{"first", "second"});
 
 
         assertThrows(IllegalArgumentException.class,
         assertThrows(IllegalArgumentException.class,
-                () -> new OmgPayController().collectForm(request));
+                request::toParameterMap);
+    }
+
+    @Test
+    void springBindsOmgFormFieldNamesToCallbackDto() {
+        OmgCallbackRequest request = new OmgCallbackRequest();
+        MutablePropertyValues values = new MutablePropertyValues();
+        values.add("MerchantID", new String[]{"M123"});
+        values.add("vAccount", new String[]{"991234"});
+        values.add("auth_code", new String[]{"AUTH"});
+
+        new DataBinder(request).bind(values);
+
+        assertEquals(Map.of("MerchantID", "M123", "vAccount", "991234", "auth_code", "AUTH"),
+                request.toParameterMap());
     }
     }
 
 
     @Test
     @Test
@@ -179,6 +250,13 @@ class OmgPayControllerTest {
         return credential;
         return credential;
     }
     }
 
 
+    private Method method(String name) {
+        return Arrays.stream(OmgPayController.class.getDeclaredMethods())
+                .filter(method -> method.getName().equals(name))
+                .findFirst()
+                .orElseThrow();
+    }
+
     private static class RefundFixture {
     private static class RefundFixture {
         private OmgPayController controller;
         private OmgPayController controller;
         private IPosOrderOmgPaymentService paymentService;
         private IPosOrderOmgPaymentService paymentService;