作者:鱼宵 | 「Java 后端实战精通营」实战驱动系列・第 10 篇(系列收官)
完整可运行工程已开源:
一、真实场景: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 企业级三件套(模式之外的资损红线)
模式解决"怎么扩展",这三件事解决"怎么不出事"(细节后面代码节逐个展开):
- 验签(HMAC-SHA256):拿你和渠道共享的密钥对报文算摘要,没密钥算不出同样摘要------对不上 = 报文被改过。和 MD5 的区别:MD5 谁都能算,HMAC 带密钥,只有持证的人能算对。
- 幂等(Idempotent):同一请求处理一次和处理 N 次结果一样。渠道回调是"至少一次"投递,怕你没收到会反复重发,你必须扛住。
- 防重放:攻击者把合法回调原样隔几天再发一遍骗你重复发货。防御 = 报文带时间戳,和服务器时间差超 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 里)
-
⭐ 把
application.yml里timestamp-window-seconds从 300 改成 1,重启后调一次 mock-callback,等 2 秒再调第二次。第二次被骨架的第几步拦下?日志打印什么?它和"验签失败""重复请求"是三种完全不同的拒绝理由------这就是为什么顺序不能乱。 -
⭐⭐ 把本机 6379 的 Redis 停掉(或机器上根本没起),再触发一次回调。日志多打印哪一行?订单还能不能正常落库成 SUCCESS?顺带想:为什么生产不能照抄这个内存兜底?
-
⭐⭐ 把 yml 里 wechat 的
secret故意改一个字符,重启后走一次 mock-callback。返回码是多少?顺藤摸瓜:这个 secret 在验签链路里管什么?有人偷了它,能造出"支付成功"的假回调吗?
三题答案都在 lesson-10 源码里,改一行配置重启就能验证。现象和你想的不一样?评论区贴出来一起查。
六、生产环境进阶:demo 能跑,生产还要补这几刀
真要上生产,四步补刀:
-
@TransactionalEventListener(AFTER_COMMIT):本课内存仓储没事务,监听器用普通@EventListener同步执行。生产里订单更新通常在@Transactional事务中:监听器若在提交前就发短信、结果事务回滚------钱没扣成短信已发出。这个注解 = "等事务真正提交成功了才触发监听器"。面试被问"发短信怎么保证订单真落库了",标准答案就是它。 -
通知改异步 :监听器默认同步,发短信慢了会拖垮回调应答------渠道还等你回
"success"呢。生产加@Async(记得配线程池),或把事件转成 RocketMQ 消息,天然带重试和堆积。 -
幂等双保险 :Redis SETNX 之外,数据库
out_trade_no加唯一索引------极端情况下 Redis 和服务同时重启、幂等 key 丢了,唯一索引挡住重复插入。 -
回调安全 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 篇就齐了:
- 坏代码体检(六大原则)→ 2. 单例 → 3. 工厂 → 4. 建造者/原型 → 5. 策略/模板方法 → 6. 观察者 → 7. 责任链 → 8. 代理/装饰器 → 9. 模式组合重构 → 10. 支付网关企业级收官。
回头看第 1 篇那份外卖烂代码:魔法数字被工厂消灭,if-else 分发被策略消灭,通知连锁被观察者消灭,一个方法干 5 件事被责任链拆开------最后今天,它以"企业级支付网关"的模样站在你面前。模式不是用来背 23 个名字的,它是给"这段烂代码该怎么治"开的药方。
这份工程现在是内存仓储 + mock 渠道。想变成简历上能讲 20 分钟的项目,按三步走:内存仓储换 MySQL、Redis 幂等接真实例、同步事件接 RocketMQ。走完你就不是"学过设计模式",而是"做过支付网关"。
- 完整可运行工程:https://gitee.com/j67mk2/design-patterns(本课源码在
lesson-10/,启动类com.dp.lesson10.PayGatewayApplication,端口 10060)
最后留个互动:翻回第 1 篇,对照那份 200 行的 processOrder,你能数出今天这个网关替它"治"好了几处坏味道?你自己项目里最该先治的是哪一处?评论区聊聊。10 篇系列到此收官,咱们下个系列见。
本文部分内容由 AI 辅助生成