支付网关收官:Spring Boot 里,策略、模板方法、观察者怎么一起干活

作者:鱼宵 | 「Java 后端实战精通营」实战驱动系列・第 10 篇(系列收官)

完整可运行工程已开源:

https://gitee.com/j67mk2/design-patterns

一、真实场景:9 篇之前那份烂代码,今天该交出终卷了

先把时间拨回第 1 篇。当时我给你看了一份"故意写烂"的外卖订单代码:一个快 200 行的 processOrder,字段全 public 裸奔,else 分支只打印一行字、不抛异常。那篇总结表最后一行我给你记着账:

字段 public 裸奔、静默失败不报错 → 第 10 课企业级收官。

9 篇过去,今天兑现。"订单"这个主题从头到尾没变:第 5 篇用策略干掉优惠 if-else,第 6 篇用观察者干掉通知连锁,第 9 篇做完整台手术。今天把它放大十倍------一个能同时接微信、支付宝、银联的支付网关 ,按生产标准写:回调从公网打进来不能是假的,渠道反复重发不能发三遍短信,渠道临时出问题不能改代码发版。代码全在 lesson-10 工程里(26 个类),clone 下来 mvn spring-boot:run,启动日志自己会把整条链路演一遍。

二、核心原理:这条链路长什么样,三个模式各自站哪个岗

2.1 先看懂全链路:下单是同步的,回调是异步的

复制代码
业务方(电商App) --下单--> 收单平台 --策略路由--> 微信/支付宝/银联
                              ^                    |
                              |                    | 用户付钱之后
                              |                    v
                              +---- 异步回调 <---- 渠道服务器
                                     (验签→幂等→落库→发事件)
                                          |
                          +---------------+---------------+
                          v               v               v
                       短信通知         邮件通知          写对账流水

两个方向,脾气完全不同。下单是业务方主动调你,可信;回调是渠道服务器从公网打你的------谁都能往这个地址 POST,天然不可信。所以回调进来第一件事不是动数据,而是先证明"你真是渠道、报文没被改过"。

2.2 三个模式怎么分工(这就是全系列的毕业答卷)

环节 用什么模式 对应的坏代码(第 1 篇那种写法)
按渠道码挑微信/支付宝/银联 策略模式 if ("wechat".equals(channel)) ... else if
回调处理的固定骨架 模板方法 各渠道各写一套,顺序没人管
支付成功后发短信/邮件/对账 观察者 sendSms(); sendEmail(); saveReconcile(); 写死在主流程

挨个说。策略模式(Strategy):把"怎么向一个渠道发起支付"抽成接口,每渠道一个实现类------类比打车软件后面挂着滴滴/高德/T3,加一家花小猪 = 加一个实现类,核心代码一行不改;本课让 Spring 启动时把所有渠道 Bean 自动收进 Map,连注册都省了。

模板方法(Template Method) :父类用 final 把流程骨架焊死、只留可变步骤给子类------类比银行办卡:取号→填表→核身份→录系统→发卡,顺序不能乱,"核身份"用指纹还是刷脸由网点定。回调就是这个:必须先验签、先幂等,才能动业务数据,顺序是安全红线。

观察者(Observer):发布者只管发事件,谁关心谁来监听------类比 B 站 UP 主发新视频、平台自动通知所有粉丝。支付成功后要发短信、发邮件、写对账,主流程只发一个事件,三个监听器各自接单。

2.3 企业级三件套(模式之外的资损红线)

模式解决"怎么扩展",这三件事解决"怎么不出事"(细节后面代码节逐个展开):

  1. 验签(HMAC-SHA256):拿你和渠道共享的密钥对报文算摘要,没密钥算不出同样摘要------对不上 = 报文被改过。和 MD5 的区别:MD5 谁都能算,HMAC 带密钥,只有持证的人能算对。
  2. 幂等(Idempotent):同一请求处理一次和处理 N 次结果一样。渠道回调是"至少一次"投递,怕你没收到会反复重发,你必须扛住。
  3. 防重放:攻击者把合法回调原样隔几天再发一遍骗你重复发货。防御 = 报文带时间戳,和服务器时间差超 5 分钟直接拒,再加幂等键保证一笔业务只处理一次。

三、关键代码:26 个类,我们挑主链路这 6 个看

其余类(支付宝/银联的渠道实现、配置绑定、订单仓储)都是同一套思路的复制,仓库里都有。工程结构:

复制代码
com.dp.lesson10
├── channel/      【策略】PaymentChannel 接口 + 三实现 + ChannelRouter
├── callback/     【模板方法】AbstractChannelCallbackHandler + 三子类 + Dispatcher
├── event/        【观察者】PaymentSuccessEvent + 短信/邮件/对账三监听器
├── idempotent/   【幂等】IdempotentService(Redis SETNX)
├── sign/         【验签】SignatureUtil(HMAC-SHA256)
├── service/      PayService 下单编排
├── controller/   PayController 接口 + GlobalExceptionHandler 全局兜底
└── demo/         DemoRunner 启动自动演一遍全链路

3.1 策略模式:接口就三个方法,路由器靠 Spring 自动收

java 复制代码
package com.dp.lesson10.channel;

import com.dp.lesson10.model.PayOrder;

/**
 * 策略模式------支付渠道策略接口。
 * 大白话:把"怎么向一个渠道发起支付"抽象成一个接口,
 * 微信有微信的干法、支付宝有支付宝的干法,但对路由器来说统一都是"我给你一个订单,你去收钱"。
 */
public interface PaymentChannel {

    /** 渠道码:wechat / alipay / unionpay。Spring 会用它做 Map 的 key。 */
    String getChannelCode();

    /** 发起支付(模拟),返回给业务方的支付凭据(教学里是 mock 支付链接)。 */
    String pay(PayOrder order);

    /** 渠道中文名(打日志用)。 */
    String getDisplayName();
}
java 复制代码
package com.dp.lesson10.channel;

import com.dp.lesson10.config.ChannelProperties;
import com.dp.lesson10.model.PayOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 策略模式------上下文(ChannelRouter)。
 * 启动时把所有 PaymentChannel 实现收集成 Map;下单时按渠道码挑策略;挑之前先查 yml 开关。
 */
@Component
public class ChannelRouter {

    private static final Logger log = LoggerFactory.getLogger(ChannelRouter.class);

    /** 渠道码 → 策略实现。构造时一次性建好,运行期只读。 */
    private final Map<String, PaymentChannel> channelMap = new HashMap<>();

    private final ChannelProperties props;

    /**
     * Spring 把容器里所有 PaymentChannel Bean 注入成 List 传进来。
     * 新增实现类后,这里不用动------自动收集。这就是开闭原则落地的样子。
     */
    public ChannelRouter(List<PaymentChannel> channels, ChannelProperties props) {
        this.props = props;
        for (PaymentChannel c : channels) {
            channelMap.put(c.getChannelCode(), c);   // 加新渠道 = 加一个 Bean,这里自动收进去
        }
        log.info("[策略路由] 已加载渠道: {}", channelMap.keySet());
    }

    /** 按渠道码选策略并发起支付;渠道不存在或被开关关闭都在这里兜底拒绝。 */
    public String routeAndPay(String channelCode, PayOrder order) {
        PaymentChannel channel = channelMap.get(channelCode);
        if (channel == null) {
            log.warn("[策略路由] 不支持的渠道: {}", channelCode);
            throw new IllegalArgumentException("不支持的支付渠道: " + channelCode);
        }
        // 再查 yml 开关:enabled=false = 该渠道临时下线,改配置重启就行,不用改代码发版
        ChannelProperties.ChannelConfig cfg = props.getChannels().get(channelCode);
        if (cfg == null || !cfg.isEnabled()) {
            log.warn("[策略路由] 渠道 {} 已被开关关闭,拒绝路由", channelCode);
            throw new IllegalStateException("渠道维护中,暂不可用: " + channelCode);
        }
        log.info("[策略路由] 订单 {} 路由到 {}", order.outTradeNo(), channel.getDisplayName());
        return channel.pay(order);
    }
}

这段代码讲了什么:注意构造器收的是 List<PaymentChannel>------Spring 会把所有这个类型的 Bean 打包成 List 传进来。新增渠道 = 加一个 @Component 实现类,路由器一行不改 。挑完渠道还要查 yml 开关:渠道出问题临时下线,改个 enabled: false 重启就行。

3.2 模板方法:final handle() 是整个项目的灵魂

java 复制代码
package com.dp.lesson10.callback;

import com.dp.lesson10.config.ChannelProperties;
import com.dp.lesson10.event.PaymentSuccessEvent;
import com.dp.lesson10.idempotent.IdempotentService;
import com.dp.lesson10.model.NotifyRequest;
import com.dp.lesson10.model.PayOrder;
import com.dp.lesson10.repo.OrderRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;

import java.util.Optional;

/**
 * 模板方法模式------渠道回调处理骨架。
 * 所有渠道的回调流程都一样:时间戳校验 → 验签 → 幂等 → 更新订单 → 发事件 → 返回答串。
 * 固定流程在父类用 final handle() 焊死,子类只填"各渠道不一样"的那点差异。
 */
public abstract class AbstractChannelCallbackHandler {

    protected final Logger log = LoggerFactory.getLogger(getClass());

    protected final ChannelProperties props;
    protected final IdempotentService idempotent;
    protected final OrderRepository repository;
    protected final ApplicationEventPublisher publisher;

    protected AbstractChannelCallbackHandler(ChannelProperties props,
                                              IdempotentService idempotent,
                                              OrderRepository repository,
                                              ApplicationEventPublisher publisher) {
        this.props = props;
        this.idempotent = idempotent;
        this.repository = repository;
        this.publisher = publisher;
    }

    /** 这个处理器管哪个渠道。 */
    public abstract String supportChannel();

    /** 步骤2(差异):验签。各渠道签名串拼接规则不同,子类实现。 */
    protected abstract void verifySign(NotifyRequest req);

    /** 步骤4(差异):把订单更新为成功。各渠道状态码映射不同,子类实现。 */
    protected abstract PayOrder updateOrder(NotifyRequest req);

    /** 步骤6(差异):返回渠道期望的成功应答串(微信要 "success",银联要 "OK")。 */
    protected abstract String buildSuccessAck();

    /**
     * 模板方法:回调处理主流程。
     * final 修饰 = 子类不许改流程顺序(安全红线:必须先验签、先幂等,再动业务数据)。
     */
    public final String handle(NotifyRequest req) {
        log.info("[回调骨架] 开始处理 渠道={}, 订单={}", req.getChannel(), req.getOutTradeNo());

        // 步骤1(固定):时间戳窗口校验------防重放。报文时间和服务器差太多,直接拒
        long windowMs = props.getCallback().getTimestampWindowSeconds() * 1000;
        long diff = Math.abs(System.currentTimeMillis() - req.getTimestamp());
        if (diff > windowMs) {
            log.warn("[回调骨架] 时间戳超出窗口({}ms),疑似重放攻击。订单={}, 偏差={}ms",
                    windowMs, req.getOutTradeNo(), diff);
            throw new SecurityException("回调时间戳超出允许窗口,拒绝处理");
        }

        // 步骤2(差异):验签------报文被改过、密钥不对,直接拒。这是安全核心
        verifySign(req);

        // 步骤3(固定):幂等------同一个订单的重复回调,直接回成功 ack,不重复落库/通知
        boolean firstTime = idempotent.tryAcquire(req.getOutTradeNo());
        if (!firstTime) {
            log.warn("[回调骨架] 重复回调,直接返回成功 ack,不重复处理。订单={}", req.getOutTradeNo());
            return buildSuccessAck();
        }

        // 步骤4(差异):更新订单状态(子类实现各渠道状态映射)
        PayOrder updated = updateOrder(req);
        repository.save(updated);
        log.info("[回调骨架] 订单已落库为 SUCCESS: {}", updated.outTradeNo());

        // 步骤5(固定):发布支付成功事件------观察者(短信/邮件/对账)各自响应,这里不加 if
        publisher.publishEvent(new PaymentSuccessEvent(this, updated));
        log.info("[回调骨架] 已发布 PaymentSuccessEvent,观察者们开始干活");

        // 步骤6(差异):返回渠道期望的应答串
        return buildSuccessAck();
    }

    /** 公共子流程:按订单号查单,查不到 = 回调里的订单根本没下过单,可疑。 */
    protected PayOrder requireOrder(String outTradeNo) {
        Optional<PayOrder> opt = repository.findByOutTradeNo(outTradeNo);
        if (opt.isEmpty()) {
            log.error("[回调骨架] 收到不存在订单的回调,疑似伪造报文。订单={}", outTradeNo);
            throw new SecurityException("订单不存在: " + outTradeNo);
        }
        return opt.get();
    }
}

这段代码讲了什么:final 修饰的 handle() 把六步顺序焊死:时间戳窗口 → 验签 → 幂等 → 落库 → 发事件 → 应答,子类能填的差异只有三个抽象方法。你想把"落库"挪到"验签"前面?编译都不让你过------先落库后验签,等于伪造报文已经把你的库写脏了。

3.3 子类:微信渠道只填三件事

java 复制代码
package com.dp.lesson10.callback;

import com.dp.lesson10.config.ChannelProperties;
import com.dp.lesson10.idempotent.IdempotentService;
import com.dp.lesson10.model.NotifyRequest;
import com.dp.lesson10.model.PayOrder;
import com.dp.lesson10.repo.OrderRepository;
import com.dp.lesson10.sign.SignatureUtil;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

import java.util.Map;

/**
 * 微信渠道回调处理器(模板方法的具体子类)。
 * 只填三件微信特有的事:验签规则、订单更新、应答串。
 */
@Component
public class WechatCallbackHandler extends AbstractChannelCallbackHandler {

    public WechatCallbackHandler(ChannelProperties props,
                                 IdempotentService idempotent,
                                 OrderRepository repository,
                                 ApplicationEventPublisher publisher) {
        super(props, idempotent, repository, publisher);
    }

    @Override
    public String supportChannel() {
        return "wechat";
    }

    @Override
    protected void verifySign(NotifyRequest req) {
        // 微信的签名串:把参与签名的业务参数按字典序拼起来,用微信密钥算 HMAC-SHA256
        Map<String, String> params = Map.of(
                "outTradeNo", req.getOutTradeNo(),
                "amountFen", String.valueOf(req.getAmountFen()),
                "timestamp", String.valueOf(req.getTimestamp()),
                "channelTradeNo", req.getChannelTradeNo() == null ? "" : req.getChannelTradeNo()
        );
        String secret = props.getChannels().get("wechat").getSecret();
        boolean ok = SignatureUtil.verify(params, secret, req.getSign());
        if (!ok) {
            log.warn("[回调-微信] 验签失败,报文可能被篡改。订单={}", req.getOutTradeNo());
            throw new SecurityException("微信回调验签失败");
        }
        log.info("[回调-微信] 验签通过");
    }

    @Override
    protected PayOrder updateOrder(NotifyRequest req) {
        PayOrder order = requireOrder(req.getOutTradeNo());
        // 金额一致性校验:回调里的实付金额必须等于下单金额,防止被改成 1 分钱
        if (order.amountFen() != req.getAmountFen()) {
            log.error("[回调-微信] 金额不一致:下单={}分, 回调={}分。订单={}",
                    order.amountFen(), req.getAmountFen(), req.getOutTradeNo());
            throw new SecurityException("回调金额与下单金额不一致");
        }
        return order.markSuccess(req.getChannelTradeNo());
    }

    @Override
    protected String buildSuccessAck() {
        // 微信约定:处理完必须原样返回字符串 "success",否则它会一直重发
        return "success";
    }
}

差异全在这了:用微信的密钥验签、回调金额和下单金额比对、按微信约定返回 "success"。银联那边应答串是 "OK"------同一个骨架,不同子类填不同的坑。

3.4 验签工具:HMAC-SHA256,JDK 自带,不自研

java 复制代码
package com.dp.lesson10.sign;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.TreeMap;
import java.util.Map;

/**
 * 签名工具:HMAC-SHA256(JDK 自带 Mac,现成方案,不自研加密)。
 * 类比:银行汇款短信带一个"防伪码"------伪造短信的人不知道暗语,编出来的防伪码对不上,你就知道是骗子。
 */
public final class SignatureUtil {

    private SignatureUtil() { }

    /** 对业务参数按 key 字典序拼接后做 HMAC-SHA256,输出小写 hex 字符串。 */
    public static String sign(Map<String, String> params, String secret) {
        try {
            // TreeMap 按 key 字典序排:双方拼接顺序必须一致,否则同一个报文算出来签名不同
            TreeMap<String, String> sorted = new TreeMap<>(params);
            StringBuilder sb = new StringBuilder();
            for (Map.Entry<String, String> e : sorted.entrySet()) {
                if (e.getValue() == null || e.getValue().isEmpty()) continue;
                if (sb.length() > 0) sb.append('&');
                sb.append(e.getKey()).append('=').append(e.getValue());
            }
            String raw = sb.toString();

            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] digest = mac.doFinal(raw.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (Exception ex) {
            // HmacSHA256 是 JDK 保证存在的算法,走到这里属于不可能事件
            throw new IllegalStateException("签名计算失败", ex);
        }
    }

    /**
     * 验签:用相同规则重算一次,和传过来的 sign 比对。
     * 用 MessageDigest.isEqual 而不是 String.equals:后者逐字符短路比较,有被耗时侧信道攻击的微小风险。
     */
    public static boolean verify(Map<String, String> params, String secret, String sign) {
        if (sign == null || sign.isEmpty()) return false;
        String expected = sign(params, secret);
        return MessageDigest.isEqual(
                expected.getBytes(StandardCharsets.UTF_8),
                sign.getBytes(StandardCharsets.UTF_8));
    }
}

两个细节面试常问。为什么用 TreeMap 排序?你和渠道必须按同样顺序拼字符串,顺序不同算出来的签名就不同,验签永远失败。为什么比对用 MessageDigest.isEqual?String.equals 逐字符短路比较,攻击者能靠"比较耗时"反推签名前缀,isEqual 是恒定时间比较。加密这种东西自己写等于裸奔,JDK 的 Mac 直接用。

3.5 幂等:Redis 一条原子命令,别自己造锁

java 复制代码
package com.dp.lesson10.idempotent;

import com.dp.lesson10.config.ChannelProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 幂等服务。方案:Redis SET key value NX EX 24h(setIfAbsent)------
 * key 不存在才设值成功,天然去重。Redis 单线程执行命令,这个操作原子,不需要自己加分布式锁。
 */
@Service
public class IdempotentService {

    private static final Logger log = LoggerFactory.getLogger(IdempotentService.class);

    /** 幂等 key 存活 24 小时:订单生命周期内挡住重复回调,过期自动清。 */
    private static final Duration TTL = Duration.ofHours(24);

    private final StringRedisTemplate redis;
    private final ChannelProperties props;

    /** 内存兜底:Redis 不可用时顶上(多实例部署时内存方案不共享,README 已说明)。 */
    private final ConcurrentHashMap<String, Boolean> memoryFallback = new ConcurrentHashMap<>();

    public IdempotentService(StringRedisTemplate redis, ChannelProperties props) {
        this.redis = redis;
        this.props = props;
    }

    /** @return true = 第一次来,可以继续处理;false = 重复请求,直接回成功 ack 不重复处理 */
    public boolean tryAcquire(String outTradeNo) {
        String key = props.getRedis().getKeyPrefix() + "idem:" + outTradeNo;
        try {
            // setIfAbsent = SETNX:key 不存在才写入,整个操作原子
            Boolean first = redis.opsForValue().setIfAbsent(key, "1", TTL);
            boolean acquired = Boolean.TRUE.equals(first);
            log.info("[幂等] Redis SETNX key={} -> {}", key, acquired ? "首次处理" : "重复请求");
            return acquired;
        } catch (Exception e) {
            // Redis 连不上就降级内存,不能因为缓存挂了主流程就 500(异常兜底)
            log.warn("[幂等] Redis 不可用,降级到内存 Map。原因: {}", e.getMessage());
            return memoryFallback.putIfAbsent(key, Boolean.TRUE) == null;
        }
    }
}

setIfAbsent(key, "1", 24h) 就是 SET key value NX EX 24h:key 不存在才写得进去。首次回调写成功返回 true,重发的回调写不进去返回 false,骨架拿到 false 直接回成功应答------不重复落库、不重复发短信。try-catch 里还有兜底:Redis 挂了降级内存 Map,主流程不挂。教学上单实例够用;生产多实例内存方案不共享,升级路径在 README 里。

3.6 观察者:主流程一行 publishEvent,监听器各自接单

java 复制代码
package com.dp.lesson10.event;

import com.dp.lesson10.model.PayOrder;
import org.springframework.context.ApplicationEvent;

/**
 * 观察者模式------支付成功事件。
 * 主流程只负责把事件发布出去,谁关心谁自己来监听;发事件的人完全不知道订阅者是谁。
 * 类比 B 站点关注:UP 主发新视频,平台自动通知所有粉丝,UP 主不需要记谁关注了他。
 */
public class PaymentSuccessEvent extends ApplicationEvent {

    private final PayOrder order;

    public PaymentSuccessEvent(Object source, PayOrder order) {
        super(source);
        this.order = order;
    }

    public PayOrder getOrder() {
        return order;
    }
}
java 复制代码
package com.dp.lesson10.event;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
 * 观察者①:短信通知监听。
 * 只关心 PaymentSuccessEvent------收到就发短信。和 PayService 零耦合。
 */
@Component
public class SmsNotifyListener {

    private static final Logger log = LoggerFactory.getLogger(SmsNotifyListener.class);

    @EventListener
    public void onPaymentSuccess(PaymentSuccessEvent event) {
        // 教学里只打日志;真实工程这里调短信网关 SDK
        log.info("[观察者-短信] 已给用户发送支付成功短信,订单号={}, 金额={}分",
                event.getOrder().outTradeNo(), event.getOrder().amountFen());
    }
}

主流程(骨架)里就一行 publisher.publishEvent(new PaymentSuccessEvent(this, updated))。加一种新通知------比如"发票申请"------就加一个带 @EventListener 的 @Component 方法,主流程零改动。第 1 篇那个 if (needSms) ... if (needEmail) ... 的通知连锁,到这连根拔掉了。

四、实测输出:启动就把全链路演一遍

启动方式(PowerShell,JDK 17):

powershell 复制代码
cd <你的clone路径>\design-patterns-journey\lesson-10
$env:JAVA_HOME="C:\jdk\jdk-17.0.12"
mvn spring-boot:run

启动后 DemoRunner 自动跑五个场景:正常下单 → 微信回调 → 同一个回调重发 → 伪造签名 → 下单到被关闭的银联渠道。以下是实测日志(与课程 README「六、运行验证」节一致):

复制代码
[回调分发] 已加载处理器: [alipay, wechat, unionpay]
[策略路由] 已加载渠道: [alipay, wechat, unionpay]
Tomcat started on port 10060 (http) with context path '/'
========== 第 10 课启动演示:收单平台全链路 ==========
[下单] 收到下单请求 outTradeNo=P-DEMO-1001, channel=wechat, amount=9900分
[策略路由] 订单 P-DEMO-1001 路由到 微信支付
[回调骨架] 开始处理 渠道=wechat, 订单=P-DEMO-1001
[回调-微信] 验签通过
[幂等] Redis SETNX key=dp-l10:idem:P-DEMO-1001 -> 首次处理
[回调骨架] 订单已落库为 SUCCESS: P-DEMO-1001
[观察者-邮件] 已发送支付成功通知邮件,订单号=P-DEMO-1001, 渠道=wechat
[观察者-对账] 写对账流水: 订单=P-DEMO-1001, 渠道流水号=CH-1791604464693, 金额=9900分
[观察者-短信] 已给用户发送支付成功短信,订单号=P-DEMO-1001, 金额=9900分
[回调骨架] 已发布 PaymentSuccessEvent,观察者们开始干活
[幂等] Redis SETNX key=dp-l10:idem:P-DEMO-1001 -> 重复请求
[回调骨架] 重复回调,直接返回成功 ack,不重复处理。订单=P-DEMO-1001
[回调-微信] 验签失败,报文可能被篡改。订单=P-DEMO-1001
[启动演示] 攻击回调被拦截: 微信回调验签失败(符合预期)
[策略路由] 渠道 unionpay 已被开关关闭,拒绝路由

逐行解读:

  • 已加载处理器/已加载渠道 [alipay, wechat, unionpay]:两个 Map 都是 Spring 启动时自动收集建好的,没有一行 if-else。
  • SETNX ... -> 首次处理:幂等真的打在本机 6379 Redis 上(key 前缀 dp-l10:,不碰你其他数据)。
  • 三个观察者按 邮件→对账→短信 打出:Spring 默认同步、顺序不保证;生产要固定顺序加 @Order,要并行加 @Async。
  • 紧接着 -> 重复请求:同一个回调重发,被幂等挡在落库之前------短信只发一遍。
  • 验签失败 + 攻击回调被拦截:伪造签名被 SecurityException 拦下,全局异常处理器转成 401。
  • 渠道 unionpay 已被开关关闭,拒绝路由:yml 里 enabled: false 的渠道,路由层兜底拒绝。

再用 curl 走一遍 HTTP 接口,给支付宝渠道下一笔单(PowerShell 直发中文 JSON 会双编码乱码,规范写法是写进 UTF-8 文件再发):

powershell 复制代码
$body = '{"outTradeNo":"P-CURL-2001","channel":"alipay","amountFen":5200,"subject":"课程套餐A"}'
[System.IO.File]::WriteAllText("body.json", $body, (New-Object System.Text.UTF8Encoding($false)))
curl.exe -s -X POST http://localhost:10060/pay/create -H "Content-Type: application/json; charset=utf-8" --data-binary "@body.json"

实测返回(INIT 状态 + 支付凭据):

json 复制代码
{"outTradeNo":"P-CURL-2001","channel":"alipay","status":"INIT","payCredential":"alipay://trade?order=P-CURL-2001&totalAmount=5200"}

教学端点 /pay/mock-callback 会用真签名工具替你签一个合法回调,但验签→幂等→落库→通知全是真走的,不是跳过:

powershell 复制代码
curl.exe -s "http://localhost:10060/pay/mock-callback?outTradeNo=P-CURL-2001&channel=alipay"

实测返回:

json 复制代码
{"code":"OK","ack":"success","outTradeNo":"P-CURL-2001"}

同一个回调再发一次,返回一模一样------但服务端日志打印 重复请求 / 重复回调,直接返回成功 ack,没有第二次发通知。查订单终态:

json 复制代码
{"outTradeNo":"P-CURL-2001","channel":"alipay","amountFen":5200,"status":"SUCCESS","channelTradeNo":"CH-1791604514693"}

最后试一把攻击:body.json 里签名故意写 wrongsign,POST 到 /pay/notify/alipay,实测被 401 拦下:

json 复制代码
{"code":401,"message":"支付宝回调验签失败"}

剩下三条验收就不贴命令了:不支持的渠道返回 不支持的支付渠道: goldpay、被开关关掉的银联返回 渠道维护中、金额传 0 被 @Min 注解拦下。10 项验收全过。

五、挑战题(改参数会怎样?答案都在源码和 yml 里)

  1. ⭐ 把 application.yml 里 timestamp-window-seconds 从 300 改成 1,重启后调一次 mock-callback,等 2 秒再调第二次。第二次被骨架的第几步拦下?日志打印什么?它和"验签失败""重复请求"是三种完全不同的拒绝理由------这就是为什么顺序不能乱。

  2. ⭐⭐ 把本机 6379 的 Redis 停掉(或机器上根本没起),再触发一次回调。日志多打印哪一行?订单还能不能正常落库成 SUCCESS?顺带想:为什么生产不能照抄这个内存兜底?

  3. ⭐⭐ 把 yml 里 wechat 的 secret 故意改一个字符,重启后走一次 mock-callback。返回码是多少?顺藤摸瓜:这个 secret 在验签链路里管什么?有人偷了它,能造出"支付成功"的假回调吗?

三题答案都在 lesson-10 源码里,改一行配置重启就能验证。现象和你想的不一样?评论区贴出来一起查。

六、生产环境进阶:demo 能跑,生产还要补这几刀

真要上生产,四步补刀:

  1. @TransactionalEventListener(AFTER_COMMIT) :本课内存仓储没事务,监听器用普通 @EventListener 同步执行。生产里订单更新通常在 @Transactional 事务中:监听器若在提交前就发短信、结果事务回滚------钱没扣成短信已发出。这个注解 = "等事务真正提交成功了才触发监听器"。面试被问"发短信怎么保证订单真落库了",标准答案就是它。

  2. 通知改异步 :监听器默认同步,发短信慢了会拖垮回调应答------渠道还等你回 "success" 呢。生产加 @Async(记得配线程池),或把事件转成 RocketMQ 消息,天然带重试和堆积。

  3. 幂等双保险 :Redis SETNX 之外,数据库 out_trade_no 加唯一索引------极端情况下 Redis 和服务同时重启、幂等 key 丢了,唯一索引挡住重复插入。

  4. 回调安全 checklist :签名串让所有业务参数参与;时间戳窗口 5 分钟;金额二次比对;验签失败打 WARN 并计数(大量失败 = 有人在扫你回调地址);应答串和渠道约定一字不差,微信要 "success",多一个空格它都当失败继续重发。

七、面试回答模板

Q1:支付网关要接微信、支付宝、银联多个渠道,新增渠道怎么做到不改老代码?

一句话结论:策略模式 + Spring 自动收集 Bean,加渠道 = 加一个实现类,路由代码一行不改 。展开:定义 PaymentChannel 接口(渠道码/发起支付/中文名),每渠道一个 @Component 实现;路由器构造时收 List<PaymentChannel> 建 Map,下单按渠道码查表分发,查不到抛"不支持的渠道",再查 yml 开关决定是否下线。指向本文 3.1 节。

Q2:渠道回调会重复发,怎么保证不重复处理、不被伪造报文攻击?

一句话结论:模板方法焊死"时间戳窗口→验签→Redis SETNX 幂等→落库→发事件"骨架,重复回调直接回成功应答 。展开:验签用 HMAC-SHA256(JDK 的 Mac),带密钥的摘要别人伪造不了;幂等用 setIfAbsent(key,"1",24h) 原子命令,首次写成功、重发写不进;时间戳超 5 分钟窗口按重放拒。指向本文 3.2 / 3.4 / 3.5 节。

Q3:@EventListener 和 @TransactionalEventListener 有什么区别?

一句话结论:后者只在数据库事务提交成功后才触发,防止"事务回滚了、短信已发出"的资损事故 。展开:本课内存仓储没事务,用普通监听器同步执行;生产里订单更新在事务中,监听器必须等提交,fallbackExecution=true 保证没事务时也不静默丢事件。指向本文第六节。

八、总结表:坏味道 → 对应模式 → 企业级落点

第 1 篇的坏味道 用什么模式收拾 本课的企业级落点
if-else 按订单类型/渠道分发 策略模式 PaymentChannel + ChannelRouter,yml 开关随时下线渠道
通知连锁 if (needSms)...if (needEmail)... 观察者模式 Spring 事件总线,加通知方式不加主流程 if
各渠道各写一套处理、顺序没人管 模板方法 final handle() 焊死 验签→幂等→落库→发事件
字段 public 裸奔、内部结构被随便摸 面向接口 + 仓储抽象 OrderRepository 接口化,换 MySQL 业务代码不动
else 只打印不抛异常(静默失败) 全局异常兜底 GlobalExceptionHandler 统一转 JSON,验签失败 401
金额用 double、配置写死在代码里 long 存分 + 配置外置 PayOrder 用 long amountFen,密钥/超时/端口全走 yml

九、关于这个系列(完结篇)

走到这儿,「Java 后端实战精通营」设计模式系列 10 篇就齐了:

  1. 坏代码体检(六大原则)→ 2. 单例 → 3. 工厂 → 4. 建造者/原型 → 5. 策略/模板方法 → 6. 观察者 → 7. 责任链 → 8. 代理/装饰器 → 9. 模式组合重构 → 10. 支付网关企业级收官。

回头看第 1 篇那份外卖烂代码:魔法数字被工厂消灭,if-else 分发被策略消灭,通知连锁被观察者消灭,一个方法干 5 件事被责任链拆开------最后今天,它以"企业级支付网关"的模样站在你面前。模式不是用来背 23 个名字的,它是给"这段烂代码该怎么治"开的药方。

这份工程现在是内存仓储 + mock 渠道。想变成简历上能讲 20 分钟的项目,按三步走:内存仓储换 MySQL、Redis 幂等接真实例、同步事件接 RocketMQ。走完你就不是"学过设计模式",而是"做过支付网关"。

最后留个互动:翻回第 1 篇,对照那份 200 行的 processOrder,你能数出今天这个网关替它"治"好了几处坏味道?你自己项目里最该先治的是哪一处?评论区聊聊。10 篇系列到此收官,咱们下个系列见。

本文部分内容由 AI 辅助生成

相关推荐
考研保研资料分享1 小时前
国际商务保研复盘:贸大自贸港题、央财投资研究追问与西财华为案例
面试
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 04 章 · `SpringApplication` 全景:`run()` 的十二步状态机
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 01 章 · Spring Boot 4 的坐标系:版本矩阵、模块拓扑与源码阅读方法论
java·后端·开源
云和数据.ChenGuang1 小时前
Golang AI 工程师路线图:从入门到实战
开发语言·人工智能·后端·golang
救救孩子把1 小时前
AI面试八股文-12_11-多模态大模型面试题
人工智能·面试·职场和发展·多模态·clip·rag·llave
高频因子挖掘机1 小时前
分钟线跨午休后时间轴不连续,是数据错了还是 A 股交易时段本来如此?
后端·github·api
雪宫街道2 小时前
SpringBoot - POI实现Excel 导入与导出
spring boot·后端·excel
打工仔折腾 AI2 小时前
让时序预测真正参与决策:TimechoAI 工程化落地与管线设计实践
人工智能·后端·python·算法·机器学习·性能优化
CarIise2 小时前
Spring MVC入门与执行流程
后端