Преглед изворни кода

feat(统一支付): 上游通知验签与金额核对

alphaH пре 1 недеља
родитељ
комит
0005122aab

+ 189 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/UnifiedNotifyController.java

@@ -0,0 +1,189 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyOutcome;
+import com.payment.platform.module.payment.unified.service.UnifiedNotifyService;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * 统一支付上游异步通知入口(spec §4.4)。
+ *
+ * <p>安全路径已由 {@code SecurityConfig} 白名单放行({@code /payment/notify/unified/**}),
+ * 真实性完全由 {@link UnifiedNotifyService} 的验签与行归属校验保证。
+ *
+ * <p>报文解析:form 参数优先({@code GET query + POST form}),没有参数时才按
+ * {@code application/json} 读 body。单参数上限 64KB:先看 {@code Content-Length},
+ * 再在读取处硬截断(chunked/代理改写时长度头不可信)。JSON 只接受对象,
+ * 标量转文本、容器保留 JSON 文本。
+ *
+ * <p>应答:200 {@code SUCCESS} / 401 {@code FAIL} / 500 {@code FAIL},超限 400 {@code FAIL};
+ * 正文统一 {@code text/plain;charset=UTF-8}。未知单/重复通知在服务层是 SUCCESS。
+ */
+@Slf4j
+@Tag(name = "统一支付-通知接收")
+@RestController
+@RequestMapping("/payment/notify/unified")
+@RequiredArgsConstructor
+public class UnifiedNotifyController {
+
+    /** 上游单条通知上限,spec §4.4。 */
+    private static final int MAX_BODY_BYTES = 65536;
+
+    private static final MediaType TEXT_PLAIN_UTF8 =
+            new MediaType("text", "plain", StandardCharsets.UTF_8);
+
+    private static final String SUCCESS = "SUCCESS";
+    private static final String FAIL = "FAIL";
+
+    private static final ObjectMapper JSON = new ObjectMapper();
+
+    private final UnifiedNotifyService notifyService;
+
+    @Operation(summary = "上游订单异步通知")
+    @PostMapping("/order")
+    public ResponseEntity<String> orderNotify(HttpServletRequest request) {
+        Map<String, String> params = readParams(request);
+        if (params == null) {
+            return fail(HttpStatus.BAD_REQUEST);
+        }
+        return dispatch(() -> notifyService.handleOrder(params));
+    }
+
+    @Operation(summary = "上游退款异步通知")
+    @PostMapping("/refund")
+    public ResponseEntity<String> refundNotify(HttpServletRequest request) {
+        Map<String, String> params = readParams(request);
+        if (params == null) {
+            return fail(HttpStatus.BAD_REQUEST);
+        }
+        return dispatch(() -> notifyService.handleRefund(params));
+    }
+
+    /** 服务层约定不抛异常;这里仍兜底,避免容器回 500 而正文不是上游认识的 FAIL。 */
+    private ResponseEntity<String> dispatch(Supplier<UnifiedNotifyOutcome> action) {
+        try {
+            UnifiedNotifyOutcome outcome = action.get();
+            if (outcome == UnifiedNotifyOutcome.SUCCESS) {
+                return text(HttpStatus.OK, SUCCESS);
+            }
+            if (outcome == UnifiedNotifyOutcome.UNAUTHORIZED) {
+                return fail(HttpStatus.UNAUTHORIZED);
+            }
+            return fail(HttpStatus.INTERNAL_SERVER_ERROR);
+        } catch (Exception e) {
+            log.error("[统一支付通知] 入口处理异常: {}", UnifiedLogSanitizer.flatten(e.getMessage()));
+            return fail(HttpStatus.INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    /**
+     * 读通知参数。表单优先;否则仅在 JSON Content-Type 下读 body。
+     *
+     * @return {@code null} 表示报文不可接受(超限/非法),调用方回 400
+     */
+    private Map<String, String> readParams(HttpServletRequest request) {
+        long declaredLength = request.getContentLengthLong();
+        if (declaredLength > MAX_BODY_BYTES) {
+            log.warn("[统一支付通知] 报文超过上限,拒绝: contentLength={}", declaredLength);
+            return null;
+        }
+
+        Map<String, String[]> parameterMap = request.getParameterMap();
+        if (parameterMap != null && !parameterMap.isEmpty()) {
+            return flattenParameters(parameterMap);
+        }
+
+        String contentType = request.getContentType();
+        if (contentType == null
+                || !contentType.toLowerCase(Locale.ROOT).contains("application/json")) {
+            // 没有参数也不是 JSON:交给服务层按「缺商户号」401,而不是 400
+            return Map.of();
+        }
+        return readJsonBody(request);
+    }
+
+    private Map<String, String> flattenParameters(Map<String, String[]> parameterMap) {
+        Map<String, String> params = new LinkedHashMap<>();
+        for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
+            String[] values = entry.getValue();
+            if (values == null || values.length == 0) {
+                continue;
+            }
+            if (values.length > 1) {
+                log.warn("通知参数多值,取第一个: key={}",
+                        UnifiedLogSanitizer.flatten(entry.getKey()));
+            }
+            params.put(entry.getKey(), values[0]);
+        }
+        return params;
+    }
+
+    /** 读取上限 +1 字节:读满 65537 即说明超限,无需把整个报文吃进内存。 */
+    private Map<String, String> readJsonBody(HttpServletRequest request) {
+        byte[] bytes;
+        try {
+            bytes = request.getInputStream().readNBytes(MAX_BODY_BYTES + 1);
+        } catch (IOException e) {
+            // 读取阶段的异常消息可能带输入片段,只记固定文案
+            log.warn("[统一支付通知] 读取报文体失败");
+            return null;
+        }
+        if (bytes.length > MAX_BODY_BYTES) {
+            log.warn("[统一支付通知] 报文超过上限,拒绝: read={}", bytes.length);
+            return null;
+        }
+
+        JsonNode root;
+        try {
+            root = JSON.readTree(bytes);
+        } catch (IOException e) {
+            // Jackson 的解析异常会带原文片段(可能含 channelExtra),不得打进日志
+            log.warn("[统一支付通知] 报文不是合法 JSON,拒绝: bytes={}", bytes.length);
+            return null;
+        }
+        if (root == null || !root.isObject()) {
+            log.warn("[统一支付通知] 报文不是 JSON 对象,拒绝");
+            return null;
+        }
+
+        Map<String, String> params = new LinkedHashMap<>();
+        root.fields().forEachRemaining(entry -> {
+            JsonNode value = entry.getValue();
+            if (value == null || value.isNull()) {
+                return;
+            }
+            params.put(entry.getKey(), value.isContainerNode()
+                    ? value.toString() : value.asText());
+        });
+        return params;
+    }
+
+    private static ResponseEntity<String> fail(HttpStatus status) {
+        return text(status, FAIL);
+    }
+
+    private static ResponseEntity<String> text(HttpStatus status, String body) {
+        return ResponseEntity.status(status)
+                .contentType(TEXT_PLAIN_UTF8)
+                .body(body);
+    }
+}

+ 23 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedNotifyOutcome.java

@@ -0,0 +1,23 @@
+package com.payment.platform.module.payment.unified.enums;
+
+/**
+ * 上游通知处理结论(spec §4.4)。
+ *
+ * <p>控制器据此映射应答:{@link #SUCCESS} → 200 {@code SUCCESS};
+ * {@link #UNAUTHORIZED} → 401 {@code FAIL};{@link #FAIL} → 500 {@code FAIL}
+ * (报文超限由控制器直接 400 {@code FAIL},不到这一步)。
+ *
+ * <p>语义刻意区分:{@code UNAUTHORIZED} 表示「这不是可信的本商户通知」,
+ * {@code FAIL} 表示「通知可信但本次没能收敛」,前者上游重推也无用,后者应当重推。
+ */
+public enum UnifiedNotifyOutcome {
+
+    /** 已受理(含幂等重复、未知单/未知状态这类「不能处理但已确认收到」)。 */
+    SUCCESS,
+
+    /** 验签失败、商户未知或行归属不符 —— 拒绝。 */
+    UNAUTHORIZED,
+
+    /** 金额核对失败或处理异常 —— 让上游继续重试。 */
+    FAIL
+}

+ 299 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedNotifyService.java

@@ -0,0 +1,299 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyOutcome;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
+import com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.Map;
+import java.util.Objects;
+
+import static com.payment.platform.module.payment.unified.util.UnifiedLogSanitizer.flatten;
+
+/**
+ * 上游通知的验签、核对与状态收敛(spec §4.4 / §7.5,裁定 R23/R25/R26/R31/R35/R41)。
+ *
+ * <p>两条腿共用的闸门(顺序固定):
+ * <ol>
+ *   <li>{@code mchNo} 空白 → 401;{@link UnifiedMerchantService#requireByMchNo} 查不到 → 401;</li>
+ *   <li>{@link UnifiedSigner#verifyNotification} 失败 → 401,日志只记
+ *       {@link UnifiedLogSanitizer#notifySignatureFailure}(无参数值、无期望签名);</li>
+ *   <li>{@code appId} 带了且不符 → 401;没带 → WARN 后继续(可选字段);</li>
+ *   <li>状态取 {@code state},缺失或空白回退 {@code orderState}(R26);仍缺/非法 → WARN +
+ *       SUCCESS,<b>不查库、不落库</b>(R11:一个 {@code state=99} 不能写进状态机);</li>
+ *   <li>行必须与通知同 {@code mchNo}、同租户({@link Objects#equals},R31)→ 否则 401。</li>
+ * </ol>
+ *
+ * <p>钱安全:订单 2/5 核对 {@code amount}/{@code currency};退款 2 核对
+ * {@code payAmount}/{@code refundAmount}/{@code currency}。不符时只写错误码、不推进状态,
+ * 并返回 FAIL 让上游重推。未知单、重复通知一律 SUCCESS(幂等应答)。
+ *
+ * <p>本类不直接写库:状态收敛只走 {@link UnifiedOrderService#applyOrderState} /
+ * {@link UnifiedRefundService#applyRefundState},事件与下游回调由它们在提交后触发。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedNotifyService {
+
+    private static final String CURRENCY_CNY = "cny";
+
+    private final UnifiedMerchantService merchantService;
+    private final UnifiedOrderService orderService;
+    private final UnifiedRefundService refundService;
+
+    /** 订单通知。 */
+    public UnifiedNotifyOutcome handleOrder(Map<String, String> params) {
+        Map<String, String> p = params == null ? Map.of() : params;
+        try {
+            Auth auth = authenticate(p);
+            if (auth == null) {
+                return UnifiedNotifyOutcome.UNAUTHORIZED;
+            }
+            String state = pickState(p);
+            if (!UnifiedOrderStatus.isValid(state)) {
+                log.warn("[统一支付通知] 订单状态缺失或未知,忽略: mchNo={}, state={}",
+                        flatten(auth.mchNo()), flatten(state));
+                return UnifiedNotifyOutcome.SUCCESS;
+            }
+
+            UnifiedOrderEntity order = orderService.findForNotify(
+                    trimToNull(p.get("payOrderId")), trimToNull(p.get("mchOrderNo")));
+            if (order == null) {
+                log.error("[统一支付通知] 订单不存在,忽略: payOrderId={}, mchOrderNo={}",
+                        flatten(p.get("payOrderId")), flatten(p.get("mchOrderNo")));
+                return UnifiedNotifyOutcome.SUCCESS;
+            }
+            if (!orderBound(order, auth)) {
+                log.warn("[统一支付通知] 订单归属不符,拒绝: mchNo={}, orderNo={}",
+                        flatten(auth.mchNo()), flatten(order.getOrderNo()));
+                return UnifiedNotifyOutcome.UNAUTHORIZED;
+            }
+
+            if ((UnifiedOrderStatus.SUCCESS.equals(state)
+                    || UnifiedOrderStatus.REFUNDED.equals(state))
+                    && !orderAmountMatches(order, p)) {
+                orderService.markAmountMismatch(order, orderAmountDetail(order, p));
+                return UnifiedNotifyOutcome.FAIL;
+            }
+
+            JsonNode snapshot = UnifiedUpstreamSnapshot.ofParams(p);
+            orderService.applyOrderState(order, state, snapshot,
+                    trimToNull(p.get("errCode")), trimToNull(p.get("errMsg")));
+            return UnifiedNotifyOutcome.SUCCESS;
+        } catch (Exception e) {
+            // 不吞异常:上游必须能靠 FAIL 重推;异常消息先拍平再进日志
+            log.error("[统一支付通知] 订单通知处理异常: {}", flatten(e.getMessage()));
+            return UnifiedNotifyOutcome.FAIL;
+        }
+    }
+
+    /** 退款通知。 */
+    public UnifiedNotifyOutcome handleRefund(Map<String, String> params) {
+        Map<String, String> p = params == null ? Map.of() : params;
+        try {
+            Auth auth = authenticate(p);
+            if (auth == null) {
+                return UnifiedNotifyOutcome.UNAUTHORIZED;
+            }
+            String state = pickState(p);
+            if (!UnifiedRefundStatus.isValid(state)) {
+                log.warn("[统一支付通知] 退款状态缺失或未知,忽略: mchNo={}, state={}",
+                        flatten(auth.mchNo()), flatten(state));
+                return UnifiedNotifyOutcome.SUCCESS;
+            }
+
+            UnifiedRefundEntity refund = refundService.findForNotify(
+                    trimToNull(p.get("refundOrderId")), trimToNull(p.get("mchRefundNo")));
+            if (refund == null) {
+                log.error("[统一支付通知] 退款单不存在,忽略: refundOrderId={}, mchRefundNo={}",
+                        flatten(p.get("refundOrderId")), flatten(p.get("mchRefundNo")));
+                return UnifiedNotifyOutcome.SUCCESS;
+            }
+            if (!Objects.equals(refund.getMchNo(), auth.mchNo())
+                    || !Objects.equals(refund.getTenantId(), auth.cfg().getTenantId())) {
+                log.warn("[统一支付通知] 退款单归属不符,拒绝: mchNo={}, refundNo={}",
+                        flatten(auth.mchNo()), flatten(refund.getRefundNo()));
+                return UnifiedNotifyOutcome.UNAUTHORIZED;
+            }
+            // 退款行必须挂在一张同商户同租户的订单上,否则金额核对没有可信基准
+            UnifiedOrderEntity order = orderService.findByOrderNo(null, refund.getOrderNo());
+            if (order == null || !orderBound(order, auth)) {
+                log.warn("[统一支付通知] 退款关联订单归属不符,拒绝: mchNo={}, refundNo={}",
+                        flatten(auth.mchNo()), flatten(refund.getRefundNo()));
+                return UnifiedNotifyOutcome.UNAUTHORIZED;
+            }
+
+            Long payAmount = null;
+            if (UnifiedRefundStatus.SUCCESS.equals(state)) {
+                payAmount = matchedPayAmount(order, p);
+                if (payAmount == null || !refundAmountMatches(refund, p)) {
+                    log.error("[统一支付通知] 退款金额核对失败,状态不推进: refundNo={}, {}",
+                            flatten(refund.getRefundNo()), refundAmountDetail(order, refund, p));
+                    return UnifiedNotifyOutcome.FAIL;
+                }
+            }
+
+            JsonNode snapshot = UnifiedUpstreamSnapshot.ofParams(p);
+            refundService.applyRefundState(refund.getRefundNo(), state, snapshot, payAmount,
+                    trimToNull(p.get("errCode")), trimToNull(p.get("errMsg")));
+            return UnifiedNotifyOutcome.SUCCESS;
+        } catch (Exception e) {
+            log.error("[统一支付通知] 退款通知处理异常: {}", flatten(e.getMessage()));
+            return UnifiedNotifyOutcome.FAIL;
+        }
+    }
+
+    /**
+     * 共用闸门:商户、验签、appId。返回 {@code null} 表示拒绝(401)。
+     *
+     * <p>验签日志逐字使用 {@link UnifiedLogSanitizer#notifySignatureFailure}:
+     * 只含商户号、收到签名的前 8 位和排序后的键名,<b>不含任何参数值与期望签名</b>。
+     */
+    private Auth authenticate(Map<String, String> p) {
+        String mchNo = trimToNull(p.get("mchNo"));
+        if (mchNo == null) {
+            log.warn("[统一支付通知] 缺少商户号,拒绝");
+            return null;
+        }
+        UnifiedMerchantEntity cfg;
+        try {
+            cfg = merchantService.requireByMchNo(mchNo);
+        } catch (BusinessException e) {
+            log.warn("[统一支付通知] 商户未配置,拒绝: mchNo={}", flatten(mchNo));
+            return null;
+        }
+        String sign = p.get("sign");
+        if (!UnifiedSigner.verifyNotification(p, cfg.getAppSecret())) {
+            log.warn("[统一支付通知] 验签失败,拒绝: {}",
+                    UnifiedLogSanitizer.notifySignatureFailure(mchNo, sign, p.keySet()));
+            return null;
+        }
+        String appId = trimToNull(p.get("appId"));
+        if (appId == null) {
+            log.warn("[统一支付通知] 通知未带 appId,按商户配置继续: mchNo={}", flatten(mchNo));
+        } else if (!appId.equals(trimToNull(cfg.getAppId()))) {
+            log.warn("[统一支付通知] appId 不符,拒绝: mchNo={}", flatten(mchNo));
+            return null;
+        }
+        return new Auth(mchNo, cfg);
+    }
+
+    /** R26:{@code state} 缺失或空白才回退 {@code orderState};非法值不回退。 */
+    private static String pickState(Map<String, String> p) {
+        String state = trimToNull(p.get("state"));
+        return state != null ? state : trimToNull(p.get("orderState"));
+    }
+
+    /** R31:租户号按值比较;引用比较在 Long 缓存之外必错。 */
+    private static boolean orderBound(UnifiedOrderEntity order, Auth auth) {
+        return Objects.equals(order.getMchNo(), auth.mchNo())
+                && Objects.equals(order.getTenantId(), auth.cfg().getTenantId());
+    }
+
+    /**
+     * 订单金额核对,与查单腿同一口径:{@code amount} 必填、可解析、大于 0、与本地相等;
+     * {@code currency} 缺失时按人民币处理,出现时忽略大小写比较。
+     */
+    private static boolean orderAmountMatches(UnifiedOrderEntity order, Map<String, String> p) {
+        if (order.getAmount() == null) {
+            return false;
+        }
+        BigDecimal amount = parseDecimal(p.get("amount"));
+        if (amount == null || amount.signum() <= 0
+                || amount.compareTo(BigDecimal.valueOf(order.getAmount())) != 0) {
+            return false;
+        }
+        String localCurrency = trimToNull(order.getCurrency());
+        if (localCurrency == null) {
+            localCurrency = CURRENCY_CNY;
+        }
+        String currency = trimToNull(p.get("currency"));
+        if (currency == null) {
+            return CURRENCY_CNY.equalsIgnoreCase(localCurrency);
+        }
+        return currency.equalsIgnoreCase(localCurrency);
+    }
+
+    /** 退款 2 的 {@code payAmount} 必须等于订单金额;返回已验证的金额供事务联动使用。 */
+    private static Long matchedPayAmount(UnifiedOrderEntity order, Map<String, String> p) {
+        if (order.getAmount() == null) {
+            return null;
+        }
+        BigDecimal amount = parseDecimal(p.get("payAmount"));
+        if (amount == null || amount.compareTo(BigDecimal.valueOf(order.getAmount())) != 0) {
+            return null;
+        }
+        return order.getAmount();
+    }
+
+    /** 退款 2 的 {@code refundAmount} 必须等于退款单金额;币种出现时必须一致。 */
+    private static boolean refundAmountMatches(UnifiedRefundEntity refund, Map<String, String> p) {
+        if (refund.getAmount() == null) {
+            return false;
+        }
+        BigDecimal amount = parseDecimal(p.get("refundAmount"));
+        if (amount == null || amount.compareTo(BigDecimal.valueOf(refund.getAmount())) != 0) {
+            return false;
+        }
+        String currency = trimToNull(p.get("currency"));
+        if (currency == null) {
+            return true;
+        }
+        String localCurrency = trimToNull(refund.getCurrency());
+        return localCurrency != null && currency.equalsIgnoreCase(localCurrency);
+    }
+
+    /** 上报 / 落库的说明:上游与本地金额并排,单行({@link #flatten})。 */
+    private static String orderAmountDetail(UnifiedOrderEntity order, Map<String, String> p) {
+        return "上游 amount=" + flatten(p.get("amount"))
+                + ", currency=" + flatten(p.get("currency"))
+                + "; 本地 amount=" + order.getAmount()
+                + ", currency=" + flatten(order.getCurrency());
+    }
+
+    private static String refundAmountDetail(UnifiedOrderEntity order, UnifiedRefundEntity refund,
+                                             Map<String, String> p) {
+        return "上游 payAmount=" + flatten(p.get("payAmount"))
+                + ", refundAmount=" + flatten(p.get("refundAmount"))
+                + ", currency=" + flatten(p.get("currency"))
+                + "; 本地 orderAmount=" + order.getAmount()
+                + ", refundAmount=" + refund.getAmount()
+                + ", currency=" + flatten(refund.getCurrency());
+    }
+
+    private static BigDecimal parseDecimal(String raw) {
+        String text = trimToNull(raw);
+        if (text == null) {
+            return null;
+        }
+        try {
+            return new BigDecimal(text);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static String trimToNull(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String text = raw.trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    /** 已通过商户/验签/appId 闸门的上下文。 */
+    private record Auth(String mchNo, UnifiedMerchantEntity cfg) {
+    }
+}

+ 284 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedNotifyControllerTest.java

@@ -0,0 +1,284 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyOutcome;
+import com.payment.platform.module.payment.unified.service.UnifiedNotifyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 上游通知入口(spec §4.4)。
+ *
+ * <p>契约:form 优先,JSON 兜底;单参数 64KB 上限;应答语义 200 SUCCESS /
+ * 401 FAIL / 500 FAIL / 超限 400 FAIL,正文 {@code text/plain;charset=UTF-8}。
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedNotifyControllerTest {
+
+    private static final String ROOT = "/payment/notify/unified";
+
+    @Mock private UnifiedNotifyService notifyService;
+
+    private UnifiedNotifyController controller;
+
+    @BeforeEach
+    void setUp() {
+        controller = new UnifiedNotifyController(notifyService);
+    }
+
+    @Test
+    void endpointsMatchTheContract() throws Exception {
+        RequestMapping root = UnifiedNotifyController.class.getAnnotation(RequestMapping.class);
+        assertNotNull(root, "控制器必须声明根路径");
+        assertArrayEquals(new String[]{ROOT}, root.value());
+
+        Method order = UnifiedNotifyController.class.getMethod("orderNotify", HttpServletRequest.class);
+        PostMapping orderMapping = order.getAnnotation(PostMapping.class);
+        assertNotNull(orderMapping);
+        assertArrayEquals(new String[]{"/order"}, orderMapping.value());
+        assertEquals(ResponseEntity.class, order.getReturnType());
+
+        Method refund = UnifiedNotifyController.class.getMethod("refundNotify", HttpServletRequest.class);
+        PostMapping refundMapping = refund.getAnnotation(PostMapping.class);
+        assertNotNull(refundMapping);
+        assertArrayEquals(new String[]{"/refund"}, refundMapping.value());
+        assertEquals(ResponseEntity.class, refund.getReturnType());
+    }
+
+    @Test
+    void formOrder_returns200Success() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+
+        ResponseEntity<String> response = controller.orderNotify(formRequest("mchNo", "M1"));
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals("SUCCESS", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
+    }
+
+    @Test
+    void formRefund_returns200Success() {
+        when(notifyService.handleRefund(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+
+        ResponseEntity<String> response = controller.refundNotify(formRequest("mchNo", "M1"));
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals("SUCCESS", response.getBody());
+    }
+
+    @Test
+    void unauthorized_returns401Fail() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.UNAUTHORIZED);
+
+        ResponseEntity<String> response = controller.orderNotify(formRequest("mchNo", "M1"));
+
+        assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        assertEquals("text/plain;charset=UTF-8",
+                response.getHeaders().getContentType().toString());
+    }
+
+    @Test
+    void processingFail_returns500Fail() {
+        when(notifyService.handleRefund(any())).thenReturn(UnifiedNotifyOutcome.FAIL);
+
+        ResponseEntity<String> response = controller.refundNotify(formRequest("mchNo", "M1"));
+
+        assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+    }
+
+    @Test
+    void serviceThrows_returns500Fail() {
+        when(notifyService.handleOrder(any())).thenThrow(new IllegalStateException("boom"));
+
+        ResponseEntity<String> response = controller.orderNotify(formRequest("mchNo", "M1"));
+
+        assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+    }
+
+    @Test
+    void contentLengthOver64K_returns400Fail_withoutParsingOrDispatch() {
+        MockHttpServletRequest request = formRequest("mchNo", "M1");
+        // MockHttpServletRequest 的 Content-Length 由 content 推导:65537 即超过上限
+        request.setContent(new byte[65537]);
+
+        ResponseEntity<String> response = controller.orderNotify(request);
+
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        verifyNoInteractions(notifyService);
+    }
+
+    @Test
+    void oversizedBodyWithoutDeclaredLength_returns400Fail() {
+        // getContentLengthLong() 不可信时(chunked / 代理改写),仍必须在读取上限处拦住
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", ROOT + "/order") {
+            @Override
+            public long getContentLengthLong() {
+                return -1;
+            }
+        };
+        request.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        request.setContent(new byte[65537]);
+
+        ResponseEntity<String> response = controller.orderNotify(request);
+
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        verifyNoInteractions(notifyService);
+    }
+
+    @Test
+    void jsonBody_isFlattenedIntoParamMap() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+        MockHttpServletRequest request = jsonRequest(
+                "{\"mchNo\":\"M1\",\"state\":2,\"channelExtra\":{\"openid\":\"oid\"}}");
+
+        ResponseEntity<String> response = controller.orderNotify(request);
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        Map<String, String> params = capturedOrderParams();
+        assertEquals("M1", params.get("mchNo"));
+        assertEquals("2", params.get("state"), "数字必须转文本");
+        assertEquals("{\"openid\":\"oid\"}", params.get("channelExtra"),
+                "容器值保留 JSON 文本,交服务层决定是否落库");
+    }
+
+    @Test
+    void formParams_takePrecedenceOverJsonBody() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+        MockHttpServletRequest request = jsonRequest("{\"mchNo\":\"JSON-MCH\"}");
+        request.addParameter("mchNo", "FORM-MCH");
+
+        controller.orderNotify(request);
+
+        assertEquals(Map.of("mchNo", "FORM-MCH"), capturedOrderParams());
+    }
+
+    @Test
+    void multiValueParam_takesFirstAndWarns() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.SUCCESS);
+        MockHttpServletRequest request = formRequest();
+        request.addParameter("mchNo", "M1", "M2");
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyController.class);
+        try {
+            controller.orderNotify(request);
+        } finally {
+            detachLog(UnifiedNotifyController.class, appender);
+        }
+
+        assertEquals("M1", capturedOrderParams().get("mchNo"), "多值参数取第一个");
+        String logs = appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .collect(Collectors.joining("\n"));
+        assertTrue(logs.contains("通知参数多值,取第一个"), "多值必须留 WARN,否则静默丢参数");
+        assertTrue(logs.contains("mchNo"));
+    }
+
+    @Test
+    void malformedJson_returns400Fail() {
+        MockHttpServletRequest request = jsonRequest("{\"mchNo\":");
+
+        ResponseEntity<String> response = controller.orderNotify(request);
+
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        verifyNoInteractions(notifyService);
+    }
+
+    @Test
+    void jsonArrayBody_returns400Fail() {
+        MockHttpServletRequest request = jsonRequest("[1,2,3]");
+
+        ResponseEntity<String> response = controller.refundNotify(request);
+
+        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+        assertEquals("FAIL", response.getBody());
+        verifyNoInteractions(notifyService);
+    }
+
+    @Test
+    void emptyForm_isDispatchedWithEmptyMap_andMappedNormally() {
+        when(notifyService.handleOrder(any())).thenReturn(UnifiedNotifyOutcome.UNAUTHORIZED);
+
+        ResponseEntity<String> response = controller.orderNotify(formRequest());
+
+        assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
+        assertTrue(capturedOrderParams().isEmpty());
+    }
+
+    // ────────────────────────────── helpers ──────────────────────────────
+
+    private Map<String, String> capturedOrderParams() {
+        ArgumentCaptor<Map<String, String>> captor = mapCaptor();
+        verify(notifyService).handleOrder(captor.capture());
+        return captor.getValue();
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static ArgumentCaptor<Map<String, String>> mapCaptor() {
+        return (ArgumentCaptor) ArgumentCaptor.forClass(Map.class);
+    }
+
+    private static MockHttpServletRequest formRequest(String... pairs) {
+        MockHttpServletRequest request =
+                new MockHttpServletRequest("POST", ROOT + "/order");
+        request.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
+        for (int i = 0; i < pairs.length; i += 2) {
+            request.addParameter(pairs[i], pairs[i + 1]);
+        }
+        return request;
+    }
+
+    private static MockHttpServletRequest jsonRequest(String json) {
+        MockHttpServletRequest request =
+                new MockHttpServletRequest("POST", ROOT + "/order");
+        request.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        request.setContent(json.getBytes(StandardCharsets.UTF_8));
+        return request;
+    }
+
+    private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        return appender;
+    }
+
+    private static void detachLog(Class<?> type, ListAppender<ILoggingEvent> appender) {
+        ((Logger) LoggerFactory.getLogger(type)).detachAppender(appender);
+    }
+}

+ 817 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedNotifyServiceTest.java

@@ -0,0 +1,817 @@
+package com.payment.platform.module.payment.unified.service;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyOutcome;
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
+import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 上游通知(spec §4.4 / §7.5,裁定 R26/R31/R35/R41)。
+ *
+ * <p>本类盯住「验签/绑定不严会认假通知、金额核对不严会把少付标成已付」:
+ * <ol>
+ *   <li>验签失败、商户未知、appId 不符、行归属不符 —— 全部 401,且不得碰订单/退款;</li>
+ *   <li>{@code state} 缺失或空白回退 {@code orderState};两者都无/非法 → WARN + SUCCESS,
+ *       绝不落库(R26 / R11);</li>
+ *   <li>订单 2/5 必须核对 {@code amount}/{@code currency},不符 → FAIL 且只写错误码;</li>
+ *   <li>退款 2 必须核对 {@code payAmount}/{@code refundAmount}/{@code currency};</li>
+ *   <li>租户比较用值相等(R31);快照只含白名单、不含 channelExtra(R35);</li>
+ *   <li>未知单、重复通知 → SUCCESS(幂等应答),处理异常 → FAIL。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedNotifyServiceTest {
+
+    private static final String MCH_NO = "M9001";
+    private static final String APP_ID = "APP-9001";
+    private static final String SECRET = "secret-9001";
+    private static final String ORDER_NO = "UN202610200001";
+    private static final String REFUND_NO = "UR202610200001";
+
+    /** 超出 Long 缓存,保证两个 Long 对象引用不同 —— 才能证明比较用的是值而不是引用。 */
+    private static final Long CFG_TENANT = Long.valueOf(1000L);
+
+    @Mock private UnifiedMerchantService merchantService;
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedRefundService refundService;
+
+    private UnifiedNotifyService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new UnifiedNotifyService(merchantService, orderService, refundService);
+    }
+
+    // ────────────────────────────── 订单通知:闸门 ──────────────────────────────
+
+    @Test
+    void blankMchNo_returnsUnauthorized() {
+        Map<String, String> params = orderParams();
+        params.remove("mchNo");
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(params)),
+                "没有商户号就无从验签,必须 401");
+        verifyNoInteractions(merchantService, orderService, refundService);
+    }
+
+    @Test
+    void unknownMchNo_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO))
+                .thenThrow(new BusinessException(404, "商户不存在"));
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
+                "未配置的商户号必须 401");
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void badSignature_returnsUnauthorized_andNeverTouchesBusinessRows() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = orderParams();
+        params.put("sign", "00000000000000000000000000000000");
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(params),
+                "签名不符必须 401");
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void appIdMismatch_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = orderParams();
+        params.put("appId", "APP-OTHER");
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(params)),
+                "appId 带了且不符必须 401");
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void appIdMissing_warnsAndContinues() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.remove("appId");
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
+        UnifiedNotifyOutcome outcome;
+        try {
+            outcome = service.handleOrder(signed(params));
+        } finally {
+            detachLog(UnifiedNotifyService.class, appender);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "appId 是可选项,缺省不得拒绝");
+        assertTrue(hasLevel(appender, Level.WARN), "缺 appId 要留 WARN");
+        verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
+                eq("0"), eq("ok"));
+    }
+
+    // ────────────────────────────── 订单通知:状态与分发 ──────────────────────────────
+
+    @Test
+    void validNotify_appliesStateAndSnapshot() {
+        UnifiedMerchantEntity cfg = cfg();
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        assertNotSame(order.getTenantId(), cfg.getTenantId(),
+                "前提:两个租户号是不同 Long 对象,引用比较会漏判");
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS,
+                service.handleOrder(signed(orderParams())));
+
+        ArgumentCaptor<JsonNode> snapshot = ArgumentCaptor.forClass(JsonNode.class);
+        verify(orderService).applyOrderState(same(order), eq("2"), snapshot.capture(),
+                eq("0"), eq("ok"));
+        verify(orderService, never()).markAmountMismatch(any(), any());
+        assertNotNull(snapshot.getValue());
+        assertTrue(UnifiedUpstreamSnapshot.toJson(snapshot.getValue()).contains("\"payOrderId\":\"P-1\""),
+                "快照要带白名单内的上游单号");
+    }
+
+    @Test
+    void stateMissing_fallsBackToOrderState() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.remove("state");
+        params.put("orderState", "2");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
+
+        verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
+                eq("0"), eq("ok"));
+    }
+
+    @Test
+    void stateBlank_fallsBackToOrderState() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.put("state", "   ");
+        params.put("orderState", "2");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
+                "R26:空白 state 必须回退 orderState");
+
+        verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
+                eq("0"), eq("ok"));
+    }
+
+    @Test
+    void unknownState_returnsSuccessWithoutUpdate() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = orderParams();
+        params.put("state", "99");
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
+        UnifiedNotifyOutcome outcome;
+        try {
+            outcome = service.handleOrder(signed(params));
+        } finally {
+            detachLog(UnifiedNotifyService.class, appender);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "未知状态只能应答 SUCCESS");
+        assertTrue(hasLevel(appender, Level.WARN), "未知状态要留 WARN");
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void missingStateAndOrderState_returnsSuccessWithoutUpdate() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = orderParams();
+        params.remove("state");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
+                "没有任何状态字段也只能应答 SUCCESS");
+        verifyNoInteractions(orderService, refundService);
+    }
+
+    @Test
+    void unknownOrder_returnsSuccessAndLogsNoSensitiveValues() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(null);
+
+        Map<String, String> params = orderParams();
+        params.put("authCode", "SECRET-AUTH-123");
+        params.put("channelExtra", "{\"openid\":\"OPENID-SECRET-456\"}");
+        params.put("buyerUserId", "BUYER-SECRET-789");
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
+        UnifiedNotifyOutcome outcome;
+        try {
+            outcome = service.handleOrder(signed(params));
+        } finally {
+            detachLog(UnifiedNotifyService.class, appender);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "未知单要应答 SUCCESS,别让上游重推");
+        String logs = messages(appender);
+        assertTrue(logs.contains(ORDER_NO), "ERROR 日志要能按单号排障");
+        assertTrue(hasLevel(appender, Level.ERROR));
+        assertFalse(logs.contains("SECRET-AUTH-123"), "付款码不得进日志");
+        assertFalse(logs.contains("OPENID-SECRET-456"), "openid 不得进日志");
+        assertFalse(logs.contains("BUYER-SECRET-789"));
+        verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void orderRowOtherMchNo_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        order.setMchNo("M-OTHER");
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
+                "行上的商户号与通知商户号不符 = 伪造/串单");
+        verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void orderRowOtherTenant_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        order.setTenantId(Long.valueOf(2000L));
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
+                "R31:租户号必须按值比较,不能看引用");
+        verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void repeatedNotify_applyReturnsFalse_stillSuccess() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(false);
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(orderParams())),
+                "重复通知(条件更新落空)也应答 SUCCESS");
+        verify(orderService).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void applyThrows_returnsFail() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any()))
+                .thenThrow(new IllegalStateException("boom"));
+
+        assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(orderParams())),
+                "处理异常不得被吞成 SUCCESS,否则上游不再重试");
+    }
+
+    // ────────────────────────────── 订单通知:金额核对 ──────────────────────────────
+
+    @Test
+    void amountMismatch99_returnsFail() {
+        assertOrderAmountRejected("99");
+    }
+
+    @Test
+    void amountMismatchZero_returnsFail() {
+        assertOrderAmountRejected("0");
+    }
+
+    @Test
+    void amountMismatchNegative_returnsFail() {
+        assertOrderAmountRejected("-1");
+    }
+
+    @Test
+    void amountMissing_returnsFail() {
+        assertOrderAmountRejected(null);
+    }
+
+    @Test
+    void currencyMismatch_returnsFail() {
+        assertOrderAmountRejected("100", "usd");
+    }
+
+    @Test
+    void currencyMissing_isAcceptedForCnyOrder() {
+        assertOrderAmountAccepted("100", null);
+    }
+
+    @Test
+    void currencyIsCaseInsensitive() {
+        assertOrderAmountAccepted("100", "CNY");
+    }
+
+    @Test
+    void amountMismatchDetail_isFlattened() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+
+        Map<String, String> params = orderParams();
+        params.put("amount", "100\r\nINJECTED");
+        assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(params)));
+
+        ArgumentCaptor<String> detail = ArgumentCaptor.forClass(String.class);
+        verify(orderService).markAmountMismatch(same(order), detail.capture());
+        assertFalse(detail.getValue().contains("\r") || detail.getValue().contains("\n"),
+                "写库/日志的 detail 必须是拍平后的单行");
+        verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void orderFailedState_skipsAmountCheckAndApplies() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.put("state", "3");
+        params.remove("amount");
+        params.remove("currency");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
+                "3/4/6 不是收款成功,不要求带金额");
+        verify(orderService).applyOrderState(same(order), eq("3"), any(JsonNode.class),
+                eq("0"), eq("ok"));
+        verify(orderService, never()).markAmountMismatch(any(), any());
+    }
+
+    @Test
+    void snapshot_onlyWhitelistFields_andAtMost8KB() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.put("channelExtra", "{\"authCode\":\"SECRET-AUTH\",\"openid\":\"SECRET-OPENID\","
+                + "\"buyerUserId\":\"SECRET-BUYER\"}");
+        params.put("authCode", "SECRET-AUTH");
+        params.put("openid", "SECRET-OPENID");
+        params.put("buyerUserId", "SECRET-BUYER");
+        StringBuilder big = new StringBuilder(20000);
+        for (int i = 0; i < 20000; i++) {
+            big.append('x');
+        }
+        params.put("errMsg", big.toString());
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
+
+        ArgumentCaptor<JsonNode> snapshot = ArgumentCaptor.forClass(JsonNode.class);
+        verify(orderService).applyOrderState(same(order), eq("2"), snapshot.capture(),
+                eq("0"), any());
+        String json = UnifiedUpstreamSnapshot.toJson(snapshot.getValue());
+        assertTrue(json.getBytes(StandardCharsets.UTF_8).length <= UnifiedUpstreamSnapshot.MAX_LENGTH,
+                "R35:落库快照不得超过 8KB");
+        assertFalse(json.contains("channelExtra"), "R35:channelExtra 不在白名单");
+        assertFalse(json.contains("authCode"));
+        assertFalse(json.contains("openid"));
+        assertFalse(json.contains("buyerUserId"));
+        assertFalse(json.contains("SECRET-AUTH"));
+        assertFalse(json.contains("SECRET-OPENID"));
+        assertFalse(json.contains("SECRET-BUYER"));
+        assertFalse(json.contains("\"sign\""), "签名也不得落库");
+    }
+
+    // ────────────────────────────── 退款通知 ──────────────────────────────
+
+    @Test
+    void validRefundNotify_appliesRefundState() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS,
+                service.handleRefund(signed(refundParams())));
+
+        verify(refundService).findForNotify("RID-1", REFUND_NO);
+        verify(orderService).findByOrderNo(isNull(), eq(ORDER_NO));
+        verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
+                eq(100L), eq("0"), eq("ok"));
+    }
+
+    @Test
+    void refundWithoutUpstreamId_looksUpByMchRefundNo() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        when(refundService.findForNotify(null, REFUND_NO)).thenReturn(refund);
+        when(orderService.findByOrderNo(null, ORDER_NO))
+                .thenReturn(order(UnifiedOrderStatus.SUCCESS));
+        Map<String, String> params = refundParams();
+        params.remove("refundOrderId");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
+        verify(refundService).findForNotify(isNull(), eq(REFUND_NO));
+    }
+
+    @Test
+    void refundStateBlank_fallsBackToOrderState() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+
+        Map<String, String> params = refundParams();
+        params.put("state", " ");
+        params.put("orderState", "2");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
+        verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
+                eq(100L), eq("0"), eq("ok"));
+    }
+
+    @Test
+    void refundStateFailed_skipsAmountCheck() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+
+        Map<String, String> params = refundParams();
+        params.put("state", "3");
+        params.remove("payAmount");
+        params.remove("refundAmount");
+        params.remove("currency");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)),
+                "退款 3/4 不要求金额");
+        verify(refundService).applyRefundState(eq(REFUND_NO), eq("3"), any(JsonNode.class),
+                isNull(), eq("0"), eq("ok"));
+    }
+
+    @Test
+    void refundUnknownState_returnsSuccessWithoutLookup() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = refundParams();
+        params.put("state", "99");
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
+        UnifiedNotifyOutcome outcome;
+        try {
+            outcome = service.handleRefund(signed(params));
+        } finally {
+            detachLog(UnifiedNotifyService.class, appender);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome);
+        assertTrue(hasLevel(appender, Level.WARN));
+        verifyNoInteractions(refundService, orderService);
+    }
+
+    @Test
+    void refundBadSignature_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = refundParams();
+        params.put("sign", "00000000000000000000000000000000");
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleRefund(params));
+        verifyNoInteractions(refundService, orderService);
+    }
+
+    @Test
+    void refundAppIdMismatch_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        Map<String, String> params = refundParams();
+        params.put("appId", "APP-OTHER");
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleRefund(signed(params)));
+        verifyNoInteractions(refundService, orderService);
+    }
+
+    @Test
+    void refundUnknownMerchant_returnsUnauthorized() {
+        when(merchantService.requireByMchNo(MCH_NO))
+                .thenThrow(new BusinessException(404, "商户不存在"));
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
+                service.handleRefund(signed(refundParams())));
+        verifyNoInteractions(refundService, orderService);
+    }
+
+    @Test
+    void refundUnknownRefund_returnsSuccessAndLogsRefundNo() {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        when(refundService.findForNotify("RID-1", REFUND_NO)).thenReturn(null);
+
+        ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
+        UnifiedNotifyOutcome outcome;
+        try {
+            outcome = service.handleRefund(signed(refundParams()));
+        } finally {
+            detachLog(UnifiedNotifyService.class, appender);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome);
+        assertTrue(messages(appender).contains(REFUND_NO), "ERROR 日志要能按退款单号排障");
+        assertTrue(hasLevel(appender, Level.ERROR));
+        verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void refundRowOtherMchNo_returnsUnauthorized() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        refund.setMchNo("M-OTHER");
+        stubRefundLookup(refund);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
+                service.handleRefund(signed(refundParams())));
+        verifyNoInteractions(orderService);
+        verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void refundRowOtherTenant_returnsUnauthorized() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        refund.setTenantId(Long.valueOf(2000L));
+        stubRefundLookup(refund);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
+                service.handleRefund(signed(refundParams())),
+                "R31:退款行租户号必须按值比较");
+        verifyNoInteractions(orderService);
+    }
+
+    @Test
+    void refundOrderBindingMismatch_returnsUnauthorized() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundLookup(refund);
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.SUCCESS);
+        order.setTenantId(Long.valueOf(2000L));
+        when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(order);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
+                service.handleRefund(signed(refundParams())),
+                "退款与订单必须同商户同租户");
+        verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void refundOrderMissing_returnsUnauthorized() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundLookup(refund);
+        when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(null);
+
+        assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
+                service.handleRefund(signed(refundParams())),
+                "退款指向不存在的订单 = 数据不可信");
+        verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void refundPayAmountMismatch_returnsFail() {
+        assertRefundAmountRejected("payAmount", "99");
+    }
+
+    @Test
+    void refundPayAmountMissing_returnsFail() {
+        assertRefundAmountRejected("payAmount", null);
+    }
+
+    @Test
+    void refundAmountMismatch_returnsFail() {
+        assertRefundAmountRejected("refundAmount", "99");
+    }
+
+    @Test
+    void refundAmountMissing_returnsFail() {
+        assertRefundAmountRejected("refundAmount", null);
+    }
+
+    @Test
+    void refundCurrencyMismatch_returnsFail() {
+        assertRefundAmountRejected("currency", "usd");
+    }
+
+    @Test
+    void refundCurrencyMissing_isAccepted() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+        Map<String, String> params = refundParams();
+        params.remove("currency");
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
+        verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
+                eq(100L), eq("0"), eq("ok"));
+    }
+
+    @Test
+    void refundApplyThrows_returnsFail() {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+        doThrow(new IllegalStateException("boom")).when(refundService)
+                .applyRefundState(any(), any(), any(), any(), any(), any());
+
+        assertEquals(UnifiedNotifyOutcome.FAIL, service.handleRefund(signed(refundParams())));
+    }
+
+    // ────────────────────────────── helpers ──────────────────────────────
+
+    private void assertOrderAmountRejected(String amount) {
+        assertOrderAmountRejected(amount, "cny");
+    }
+
+    private void assertOrderAmountRejected(String amount, String currency) {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+
+        Map<String, String> params = orderParams();
+        if (amount == null) {
+            params.remove("amount");
+        } else {
+            params.put("amount", amount);
+        }
+        if (currency == null) {
+            params.remove("currency");
+        } else {
+            params.put("currency", currency);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(params)),
+                "金额/币种不符必须 FAIL,让上游继续重试");
+        verify(orderService).markAmountMismatch(same(order), any());
+        verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
+    }
+
+    private void assertOrderAmountAccepted(String amount, String currency) {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
+        when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
+        when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
+
+        Map<String, String> params = orderParams();
+        params.put("amount", amount);
+        if (currency == null) {
+            params.remove("currency");
+        } else {
+            params.put("currency", currency);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
+        verify(orderService, never()).markAmountMismatch(any(), any());
+        verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
+                eq("0"), eq("ok"));
+    }
+
+    private void assertRefundAmountRejected(String field, String value) {
+        UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
+        stubRefundFlow(refund);
+
+        Map<String, String> params = refundParams();
+        if (value == null) {
+            params.remove(field);
+        } else {
+            params.put(field, value);
+        }
+
+        assertEquals(UnifiedNotifyOutcome.FAIL, service.handleRefund(signed(params)),
+                field + " 不符/缺失必须 FAIL");
+        verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
+    }
+
+    private void stubRefundLookup(UnifiedRefundEntity refund) {
+        when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
+        when(refundService.findForNotify("RID-1", REFUND_NO)).thenReturn(refund);
+    }
+
+    private void stubRefundFlow(UnifiedRefundEntity refund) {
+        stubRefundLookup(refund);
+        when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(order(UnifiedOrderStatus.SUCCESS));
+    }
+
+    private static UnifiedMerchantEntity cfg() {
+        UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
+        cfg.setMchNo(MCH_NO);
+        cfg.setAppId(APP_ID);
+        cfg.setAppSecret(SECRET);
+        cfg.setTenantId(Long.valueOf(1000L));
+        return cfg;
+    }
+
+    private static UnifiedOrderEntity order(String tradeStatus) {
+        UnifiedOrderEntity order = new UnifiedOrderEntity();
+        order.setId(1L);
+        order.setOrderNo(ORDER_NO);
+        order.setMchNo(MCH_NO);
+        order.setTenantId(Long.valueOf(1000L));
+        order.setAmount(100L);
+        order.setCurrency("cny");
+        order.setTradeStatus(tradeStatus);
+        return order;
+    }
+
+    private static UnifiedRefundEntity refund(String state) {
+        UnifiedRefundEntity refund = new UnifiedRefundEntity();
+        refund.setId(2L);
+        refund.setOrderId(1L);
+        refund.setOrderNo(ORDER_NO);
+        refund.setRefundNo(REFUND_NO);
+        refund.setMchNo(MCH_NO);
+        refund.setTenantId(Long.valueOf(1000L));
+        refund.setAmount(100L);
+        refund.setCurrency("cny");
+        refund.setState(state);
+        return refund;
+    }
+
+    private static Map<String, String> orderParams() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("mchNo", MCH_NO);
+        params.put("appId", APP_ID);
+        params.put("payOrderId", "P-1");
+        params.put("mchOrderNo", ORDER_NO);
+        params.put("state", "2");
+        params.put("amount", "100");
+        params.put("currency", "cny");
+        params.put("successTime", "1700000000000");
+        params.put("errCode", "0");
+        params.put("errMsg", "ok");
+        return params;
+    }
+
+    private static Map<String, String> refundParams() {
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("mchNo", MCH_NO);
+        params.put("appId", APP_ID);
+        params.put("refundOrderId", "RID-1");
+        params.put("mchRefundNo", REFUND_NO);
+        params.put("state", "2");
+        params.put("payAmount", "100");
+        params.put("refundAmount", "100");
+        params.put("currency", "cny");
+        params.put("errCode", "0");
+        params.put("errMsg", "ok");
+        return params;
+    }
+
+    private static Map<String, String> signed(Map<String, String> params) {
+        Map<String, String> copy = new LinkedHashMap<>(params);
+        copy.put("sign", UnifiedSigner.sign(copy, SECRET));
+        return copy;
+    }
+
+    private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        return appender;
+    }
+
+    private static void detachLog(Class<?> type, ListAppender<ILoggingEvent> appender) {
+        ((Logger) LoggerFactory.getLogger(type)).detachAppender(appender);
+    }
+
+    private static boolean hasLevel(ListAppender<ILoggingEvent> appender, Level level) {
+        return appender.list.stream().anyMatch(event -> event.getLevel() == level);
+    }
+
+    private static String messages(ListAppender<ILoggingEvent> appender) {
+        return appender.list.stream()
+                .map(ILoggingEvent::getFormattedMessage)
+                .collect(Collectors.joining("\n"));
+    }
+}