说实话,第一次接手支付网关这种“吞金兽”项目时,我整个人都是懵的。不是技术难点吓退了我,而是那种如履薄冰的感觉——每一行代码背后都是真金白银,一个精度丢失的Bug就能让公司赔得底裤都不剩。今天我把压箱底的经验都掏出来,咱们不聊虚的,直接聊怎么从零搭建一套能扛住高并发、账目分毫不差的支付核心系统。
一、 别急着写代码,先搞懂钱是怎么“动”的
很多开发新手一上来就定义 Order 表和 Payment 表,然后想着怎么对接微信、支付宝接口。停!如果连账务模型都没想清楚,后面全是坑。
在支付系统里,核心概念不是“订单”,而是“账户”。
1.1 双轨制账户模型
我推荐采用“虚拟账户 + 流水”的双轨模型。
- 虚拟账户(Virtual Account):这是一个内存或数据库中的逻辑余额,代表用户或商户“理论上”拥有的钱。它不直接用于对外支付,而是内部清算的依据。
- 资金流水(Transaction Log):这是真实的、不可篡改的记账凭证。每一笔钱的变动,都必须有一条对应的流水记录。
为什么要这么设计?
想象一下,如果用户A给用户B转账100元。
- 方案A:直接改A账户余额为-100,改B账户余额为+100。
- 方案B:生成一条A的支出流水,生成一条B的收入流水,然后异步更新账户余额。
选方案A,如果中间断电,数据不一致,钱就没了。选方案B,即使系统挂了,流水还在,重启后对账就能恢复余额。
1.2 代码层面的实现思路
在数据库设计中,account 表和 transaction_log 表是核心。
-- 虚拟账户表
CREATE TABLE virtual_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
account_no VARCHAR(64) NOT NULL UNIQUE COMMENT '账户编号',
account_type TINYINT NOT NULL COMMENT '1:用户 2:商户 3:平台',
balance DECIMAL(18,2) DEFAULT 0.00 COMMENT '当前可用余额',
frozen_amount DECIMAL(18,2) DEFAULT 0.00 COMMENT '冻结金额',
version INT DEFAULT 0 COMMENT '乐观锁版本号',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 交易流水表(核心中的核心)
CREATE TABLE transaction_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
log_no VARCHAR(64) NOT NULL UNIQUE COMMENT '流水号',
account_no VARCHAR(64) NOT NULL COMMENT '关联账户',
trans_type TINYINT NOT NULL COMMENT '1:充值 2:支付 3:退款 4:分账',
amount DECIMAL(18,2) NOT NULL COMMENT '交易金额',
direction TINYINT NOT NULL COMMENT '1:收入 2:支出',
status TINYINT DEFAULT 0 COMMENT '0:处理中 1:成功 2:失败 3:已撤销',
biz_id VARCHAR(64) COMMENT '业务ID(如订单号)',
gateway_order_no VARCHAR(64) COMMENT '通道订单号',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
注意 balance 和 frozen_amount 是DECIMAL类型,绝对不能用 DOUBLE 或 FLOAT,那是金融事故的万恶之源。
二、 分账结算:怎么把一笔钱精准地切给多人
这是支付网关最复杂的场景之一。比如用户买了100元的商品,平台要抽成10元,商家拿85元,推广员拿5元。这个过程叫“分账”。
2.1 分账的核心原则
- 原子性:分账必须要么全部成功,要么全部失败,不能出现平台收了钱却没分给商家。
- 可追溯:每一分钱的去向,都要有对应的流水。
- 实时性: ideally,分账应该在支付成功后立即执行,或者进入实时队列。
2.2 分账流程设计
我以“支付后自动分账”为例,设计一个状态机流程:
- 支付成功:用户支付100元,
virtual_account中商户账户入账100元(暂为待结算状态)。 - 分账触发:支付回调成功,触发分账任务。
- 分账执行:
- 平台账户:+10元
- 商户账户:+85元
- 推广员账户:+5元
- 结算:商户和推广员的账户资金从“待结算”变为“可提现”。
2.3 Java 代码示例:分账服务
@Service
public class SplitPaymentService {
@Autowired
private TransactionLogRepository logRepository;
@Autowired
private VirtualAccountRepository accountRepository;
/**
* 执行分账
* @param orderId 订单号
* @param totalAmount 总金额
* @param splitRules 分账规则列表
*/
@Transactional(rollbackFor = Exception.class)
public void executeSplit(String orderId, BigDecimal totalAmount, List<SplitRule> splitRules) {
// 1. 校验总金额是否等于各分账金额之和
BigDecimal splitTotal = splitRules.stream()
.map(SplitRule::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalAmount.compareTo(splitTotal) != 0) {
throw new BusinessException("分账金额总和与订单金额不一致");
}
// 2. 开始分账
for (SplitRule rule : splitRules) {
// 2.1 生成支出流水(从原支付账户扣除,这里假设是待结算资金)
createLog(orderId, rule.getSourceAccount(), rule.getAmount(), Direction.OUT, SplitType.SPLIT);
// 2.2 生成收入流水(到目标账户)
createLog(orderId, rule.getTargetAccount(), rule.getAmount(), Direction.IN, SplitType.SPLIT);
// 2.3 更新账户余额(使用乐观锁防止超扣)
accountRepository.incrementBalance(rule.getTargetAccount(), rule.getAmount());
accountRepository.decrementBalance(rule.getSourceAccount(), rule.getAmount());
}
}
private void createLog(String orderId, String accountNo, BigDecimal amount, Direction direction, SplitType type) {
TransactionLog log = new TransactionLog();
log.setLogNo(generateLogNo());
log.setAccountNo(accountNo);
log.setTransType(type.getCode());
log.setAmount(amount);
log.setDirection(direction.getCode());
log.setStatus(TransactionStatus.SUCCESS.getCode());
log.setBizId(orderId);
logRepository.save(log);
}
}
关键点:@Transactional 保证分账操作的原子性。如果中途任何一步失败,整个分账回滚,资金不会丢失。
三、 多通道路由:如何让支付成功率飙升
接入微信支付、支付宝、银联云闪付只是基础。真正的技术含量在于路由策略——怎么把用户的支付请求,引导到最合适、最便宜、最稳定的通道。
3.1 为什么需要多通道路由?
- 成功率优化:不同银行、不同用户群体,对通道的偏好不同。比如某用户微信余额不足,但支付宝有钱,路由系统可以自动切换。
- 成本降低:不同通道的费率不同,智能路由可以选择费率最低的通道。
- 高可用:单一通道故障时,能快速切换到备用通道,保障业务连续性。
3.2 路由架构设计
一个典型的多通道路由系统包含以下几个组件:
- 路由决策引擎:根据配置的策略,选择最优通道。
- 通道适配器(Adapter):将统一的内部请求转换为各支付通道的特定格式。
- 通道健康监控:实时监测各通道的成功率、响应时间,动态调整路由权重。
- 重试机制:失败时自动重试其他通道。
3.3 路由策略示例
我们可以设计一个基于权重和黑名单的路由策略:
@Component
public class RouteStrategy {
@Autowired
private ChannelRepository channelRepository;
@Autowired
private ChannelHealthMonitor healthMonitor;
/**
* 选择最优支付通道
* @param payType 支付方式(微信/支付宝/银联)
* @param amount 金额
* @param merchantId 商户ID
* @return 选中的通道
*/
public Channel selectChannel(String payType, BigDecimal amount, String merchantId) {
// 1. 获取该商户配置的该支付方式下的所有可用通道
List<Channel> candidates = channelRepository.findByPayTypeAndMerchant(payType, merchantId);
// 2. 过滤掉黑名单通道和健康状态异常的通道
candidates = candidates.stream()
.filter(c -> !healthMonitor.isBlacklisted(c.getChannelCode()))
.filter(c -> healthMonitor.isHealthy(c.getChannelCode()))
.collect(Collectors.toList());
if (candidates.isEmpty()) {
throw new BusinessException("暂无可用支付通道");
}
// 3. 根据权重排序,选择权重最高的通道
// 权重 = 基础权重 * 健康度系数 * 成本系数
candidates.sort(Comparator.comparingDouble(this::calculateWeight).reversed());
return candidates.get(0);
}
private double calculateWeight(Channel channel) {
double baseWeight = channel.getBaseWeight();
double healthScore = healthMonitor.getHealthScore(channel.getChannelCode());
double costFactor = 1.0 / channel.getFeeRate(); // 费率越低,权重越高
return baseWeight * healthScore * costFactor;
}
}
3.4 通道适配器模式
为了屏蔽不同通道的差异,我们采用适配器模式:
public interface PaymentGateway {
PaymentResult pay(PaymentRequest request);
RefundResult refund(RefundRequest request);
QueryResult query(QueryRequest request);
}
// 微信支付适配器
@Component
public class WechatPayGateway implements PaymentGateway {
@Override
public PaymentResult pay(PaymentRequest request) {
// 调用微信统一下单API
// 转换返回结果为统一内部格式
return convertToInternalResult(wechatClient.unifiedOrder(request));
}
}
// 支付宝适配器
@Component
public class AlipayGateway implements PaymentGateway {
@Override
public PaymentResult pay(PaymentRequest request) {
// 调用支付宝统一下单API
return convertToInternalResult(alipayClient.create(request));
}
}
这样,上层业务代码只需要调用 PaymentGateway.pay(),无需关心底层是微信还是支付宝。
四、 对账与差错处理:最后的防线
无论系统设计得再好,出错是难免的。这时候,对账就是最后的防线。
4.1 对账流程
- 下载对账单:每天凌晨,从各支付通道下载前一天的对账单。
- 明细比对:将内部流水与通道对账单逐条比对。
- 差异处理:
- 长款:通道有记录,内部无记录。可能是内部系统漏记,需补记。
- 短款:内部有记录,通道无记录。可能是支付失败但内部误记成功,需冲正。
- 金额不符:需人工介入核查。
4.2 自动化对账代码示例
@Service
public class ReconciliationService {
public void dailyReconciliation(LocalDate date) {
// 1. 下载各通道对账单
List<ChannelStatement> statements = downloadStatements(date);
// 2. 获取内部当日所有成功流水
List<TransactionLog> internalLogs = transactionLogRepository
.findByDateAndStatus(date, TransactionStatus.SUCCESS);
// 3. 构建内部流水Map,key为通道订单号
Map<String, TransactionLog> logMap = internalLogs.stream()
.collect(Collectors.toMap(TransactionLog::getGatewayOrderNo, Function.identity(), (v1, v2) -> v1));
// 4. 遍历对账单,进行比对
for (ChannelStatement stmt : statements) {
TransactionLog internalLog = logMap.get(stmt.getChannelOrderNo());
if (internalLog == null) {
// 长款:通道有,内部无
handleLongItem(stmt);
} else {
// 比对金额
if (internalLog.getAmount().compareTo(stmt.getAmount()) != 0) {
// 金额不符
handleAmountMismatch(internalLog, stmt);
} else {
// 匹配成功,标记对账状态
internalLog.setReconciled(true);
transactionLogRepository.save(internalLog);
}
}
}
// 5. 检查内部流水中未匹配的记录(短款)
logMap.values().stream()
.filter(log -> !log.isReconciled())
.forEach(this::handleShortItem);
}
}
五、 一些踩坑后的真心话
- 幂等性:支付请求必须幂等。用户网络抖动,重复提交订单,系统不能重复扣款。解决方案:用
biz_no(业务唯一号)作为幂等键,第一次处理成功后,后续相同biz_no的请求直接返回第一次的结果。 - 精度问题:所有金额计算,一律使用
BigDecimal,并且指定精度和舍入模式(通常是RoundingMode.HALF_UP)。 - 分布式锁:在并发场景下,更新账户余额时,必须加分布式锁,防止超扣。可以用 Redis 的
SETNX或者数据库的行锁。 - 日志记录:支付相关的每一笔操作,都要记录详细日志,包括请求参数、响应结果、耗时、异常信息等。这是日后排查问题的唯一依据。
结语
从0到1搭建支付网关,是一场对技术深度和严谨性的双重考验。账户模型是基石,分账结算是核心能力,多通道路由是效率保障,而对账是最后的安全网。这四者环环相扣,缺一不可。
希望这篇文章能帮你理清思路。记住,在支付领域,“稳”永远比“快”重要。每一个小数点,都承载着用户的信任。
