当玩家点击"充值"时发生了什么?
一枚按钮背后,是一场跨越前后端、穿梭多个系统的精密协作。这不是简单的"转账",而是一场跨越半个互联网的接力赛。
引言:看似简单的一"点"
玩家在游戏里看到心仪的道具,点击"充值"按钮,几秒钟后支付成功——整个过程行云流水。但你有没有想过,这短短几秒内,数据在系统间经历了怎样的旅程?
今天我们拆解这个流程,看看一枚充值按钮背后的技术世界。
这就像你在餐厅点菜:看似只是"我要一份宫保鸡丁",但背后涉及点菜系统、厨房调度、食材库存、结账系统……每一个环节都有它的学问。支付系统比这更复杂——它涉及真金白银,容不得半点差错。
[配图建议:玩家点击充值按钮的场景截图,配合数据流动的虚线箭头]
一、前端:从点击到发起
1.1 按钮不是"裸奔"的
当玩家点击充值按钮时,前端做的第一件事不是直接调用支付,而是收集上下文信息:
// 点击充值按钮时,前端收集的核心信息
const paymentContext = {
// 玩家是谁?
userId: 'player_12345',
roleId: 'role_67890',
serverId: 'server_001',
// 要买什么?
productId: 'diamond_pack_100',
productName: '100钻石礼包',
quantity: 1,
// 在哪里买?
platform: 'android',
appVersion: '2.3.1',
channelId: 'huawei',
// 用什么设备?
deviceId: 'device_uuid_xxx',
osVersion: 'Android 12',
networkType: 'wifi'
};
这些信息就像订单的"身份证",后续每一步都要用它来验明正身。
想象一下,如果只传"玩家要买100钻石",问题来了:
- 哪个服的玩家?(跨服数据隔离)
- 哪个角色买的?(一个账号可能有多个角色)
- 从哪个渠道来的?(不同渠道可能有不同的优惠活动)
- 设备信息呢?(风控需要)
[配图建议:前端收集信息的示意图,类似填表的过程]
1.2 SDK 的角色:翻译官与保镖
游戏通常不会直接对接支付渠道,而是通过一个中间层——支付SDK。
你可以把 SDK 想象成一个"翻译官 + 保镖"的组合:
- 翻译官:游戏只管说"我要收钱",SDK 负责把它翻译成微信支付、支付宝、苹果内购等不同渠道能听懂的语言
- 保镖:SDK 内置了安全机制、异常处理、状态管理,游戏不用重复造轮子
class PaymentSDK {
// 1. 统一接口:游戏只需调用一个方法
async pay(options) {
// 2. 渠道选择:根据设备、地区智能选择
const channel = this.selectChannel(options);
// 3. 调用对应渠道的支付
const result = await channel.pay(options);
// 4. 状态管理:处理各种异常
return this.handleResult(result);
}
selectChannel(options) {
// iOS 强制走苹果内购
if (options.platform === 'ios') {
return new ApplePayChannel();
}
// Android 根据用户偏好和渠道状态选择
if (this.isUserPreferWechat()) {
return new WechatPayChannel();
}
return new AlipayChannel();
}
}
| 场景 | 选择渠道 | 原因 |
|---|---|---|
| iOS 用户 | 苹果内购 | App Store 强制要求 |
| 华为应用市场下载 | 华为支付 | 渠道包要求 |
| 微信小游戏 | 微信支付 | 原生支持,体验最好 |
| Android 官网包 | 用户自选 | 给用户选择权 |
| 某渠道故障 | 自动切换 | 降级保障 |
1.3 先问后付:获取订单信息
在真正发起支付前,前端会先向后端"请示":
"玩家A想买6元礼包,请给我一个订单号。"
这个请求看起来简单,但暗藏玄机:
// 前端请求订单
const orderRequest = {
product_id: 'diamond_pack_100',
user_id: 'player_12345',
// 注意:这里没有传金额!
};
// 后端返回
const orderResponse = {
order_id: 'ORD20260301235959001',
amount: 600, // 6.00元,以分为单位
currency: 'CNY',
product_name: '100钻石礼包',
pay_params: {
// 调起支付所需的签名参数
app_id: 'wx1234567890',
partner_id: '1234567890',
prepay_id: 'wx20260301...',
sign: 'A1B2C3D4E5F6...',
timestamp: 1709311199,
nonce_str: 'abc123def456'
},
expire_time: '2026-03-01T00:15:00Z', // 15分钟后过期
channel: 'wechat_pay'
};
为什么?假设前端传金额,黑客可以抓包把"600分"改成"1分",那岂不是亏大了?
[配图建议:前端向后端请求订单的时序图]
1.4 前端的安全防线
前端虽然是"最不安全"的地方(用户可见、可修改),但也要有自己的防线:
// 混淆前
function getProductId() {
return 'diamond_pack_100';
}
// 混淆后(实际会更复杂)
function _0x3f2a() {
return _0x1a2b[0x5];
}
// 敏感参数要加密
const encryptedData = AES.encrypt(JSON.stringify({
user_id: userId,
timestamp: Date.now()
}), SECRET_KEY);
// 检测是否在模拟器、Root设备等风险环境
if (isEmulator() || isRooted()) {
// 可以选择拒绝支付或加强验证
showWarning('检测到异常环境,请使用官方渠道');
}
当然,前端的任何防护都可以被破解,所以真正的安全必须依赖后端。前端防护的意义在于"提高攻击成本"——让黑客觉得不值得花这个时间。
二、后端:订单的诞生与生命周期
2.1 创建订单:不只是写数据库
后端收到"我要下单"的请求后,会进行一系列操作。这不是简单的"INSERT INTO orders",而是一个精密编排的流程:
@Transactional
public CreateOrderResult createOrder(CreateOrderRequest request) {
// 第一步:验明正身
UserInfo user = authService.verifyToken(request.getToken());
if (user == null) {
throw new UnauthorizedException("请先登录");
}
// 第二步:业务校验
Product product = productService.getProduct(request.getProductId());
if (product == null) {
throw new BusinessException("商品不存在");
}
if (!product.isOnSale()) {
throw new BusinessException("商品已下架");
}
if (!product.canBuy(user.getLevel())) {
throw new BusinessException("等级不足,需要Lv." + product.getMinLevel());
}
// 检查限购
int boughtCount = orderService.countUserOrders(user.getId(), product.getId());
if (boughtCount >= product.getBuyLimit()) {
throw new BusinessException("已达购买上限");
}
// 第三步:计算价格(注意:这里可能有折扣逻辑)
int finalAmount = calculateFinalAmount(product, user);
// 第四步:生成订单
String orderId = generateOrderId(); // ORD + 时间戳 + 随机数
Order order = Order.builder()
.orderId(orderId)
.userId(user.getId())
.productId(product.getId())
.originalAmount(product.getPrice())
.discountAmount(product.getPrice() - finalAmount)
.finalAmount(finalAmount)
.status(OrderStatus.PENDING)
.expireTime(Instant.now().plus(15, ChronoUnit.MINUTES))
.build();
orderRepository.save(order);
// 第五步:准备支付参数
PayParams payParams = payChannelService.preparePayment(order, request.getChannel());
return CreateOrderResult.builder()
.orderId(orderId)
.amount(finalAmount)
.payParams(payParams)
.expireTime(order.getExpireTime())
.build();
}
┌─────────────────────────────────────┐
│ │
▼ │
┌─────────┐ ┌──────┴─────┐
│ PENDING │ ─────支付成功─────► │ PAID │
│ (待支付) │ │ (已支付) │
└────┬────┘ └──────┬─────┘
│ │
│ 超时/取消 发货成功 │
│ ▼
▼ ┌───────────┐
┌─────────┐ │ DELIVERED │
│ EXPIRED │ │ (已发货) │
│ (已过期) │ └───────────┘
└─────────┘
2.2 订单号的学问
订单号看似简单,实则大有讲究:
// 一个好的订单号设计
public String generateOrderId() {
// ORD + 日期(8位) + 时分秒(6位) + 服务器ID(2位) + 序列号(6位) = 25位
// 示例:ORD20260301235959010000001
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String time = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"));
String serverId = String.format("%02d", serverConfig.getServerId());
String sequence = String.format("%06d", sequenceGenerator.next());
return "ORD" + date + time + serverId + sequence;
}
- 全局唯一:分布式环境下也要保证唯一
- 有序性:便于按时间查询和排序
- 可追溯:从订单号能看出一些信息(日期、服务器等)
- 防猜测:不能让黑客猜出下一个订单号
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 简单、唯一 | 太长、无序、不可读 |
| 数据库自增 | 简单、有序 | 分布式困难、暴露业务量 |
| 雪花算法 | 有序、分布式友好 | 需要维护机器ID |
| 时间戳+序列 | 可读、有序 | 高并发需要分布式序列 |
2.3 渠道选择:条条大路通罗马
支付渠道的选择通常考虑以下因素:
public PayChannel selectChannel(SelectChannelRequest request) {
List<PayChannel> availableChannels = new ArrayList<>();
// 1. 根据平台筛选
if ("ios".equals(request.getPlatform())) {
// iOS 强制苹果内购
return applePayChannel;
}
// 2. 根据地区筛选
if (isInRestrictedRegion(request.getRegion())) {
availableChannels = getChannelsForRegion(request.getRegion());
} else {
availableChannels = allChannels;
}
// 3. 检查渠道健康状态
availableChannels = availableChannels.stream()
.filter(ch -> healthChecker.isHealthy(ch))
.collect(Collectors.toList());
// 4. 根据用户偏好排序
String preferredChannel = userPreferenceService.getPreferredChannel(request.getUserId());
availableChannels.sort((a, b) -> {
if (a.getName().equals(preferredChannel)) return -1;
if (b.getName().equals(preferredChannel)) return 1;
return 0;
});
// 5. 考虑成本(手续费)
// ...
return availableChannels.get(0);
}
开始
│
▼
┌───────────────┐
│ 是 iOS 设备? │
└───────┬───────┘
是 / \ 否
/ \
▼ ▼
┌──────────┐ ┌────────────────┐
│ 苹果内购 │ │ 检查渠道状态 │
└──────────┘ └───────┬────────┘
/ │ \
/ │ \
▼ ▼ ▼
微信 支付宝 其他...
2.4 签名:数字世界的"防伪印章"
当后端要和支付渠道通信时,必须带上一个签名(Signature)。
签名的原理类似于古代的印章:
// 签名生成示例(以微信支付为例)
public String generateSign(Map<String, String> params, String apiKey) {
// 1. 把所有参数按 key 排序
List<String> sortedKeys = new ArrayList<>(params.keySet());
Collections.sort(sortedKeys);
// 2. 拼接成 key=value&key=value 的格式
StringBuilder sb = new StringBuilder();
for (String key : sortedKeys) {
if (params.get(key) != null && !params.get(key).isEmpty()) {
if (sb.length() > 0) sb.append("&");
sb.append(key).append("=").append(params.get(key));
}
}
// 3. 加上密钥
sb.append("&key=").append(apiKey);
// 4. 计算哈希
return DigestUtils.md5Hex(sb.toString()).toUpperCase();
}
// 验签示例
public boolean verifySign(Map<String, String> params, String expectedSign) {
String calculatedSign = generateSign(params, apiKey);
return calculatedSign.equals(expectedSign);
}
- 防止参数被篡改:改了任何一个字,签名就对不上
- 防止伪造请求:没有密钥,算不出正确的签名
- 确认请求来源:只有合法商户才有密钥
// 某支付渠道回调验签
public void handleCallback(HttpServletRequest request) {
Map<String, String> params = extractParams(request);
String sign = params.remove("sign");
if (!verifySign(params, sign)) {
log.error("签名校验失败!params={}, sign={}", params, sign);
// 返回失败,但不抛异常(防止渠道重试)
response.getWriter().write("FAIL");
return;
}
// 签名正确,继续处理
processPayment(params);
response.getWriter().write("SUCCESS");
}
三、安全:守护每一分钱
3.1 Token 验证:你是谁?
Token 是用户登录后系统颁发的一个"临时通行证":
// Token 结构(JWT 为例)
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"user_id": "player_12345",
"role_id": "role_67890",
"exp": 1709314800, // 过期时间
"iat": 1709311200 // 签发时间
},
"signature": "..." // 用服务端密钥签名
}
public UserInfo verifyToken(String token) {
try {
// 1. 解析 Token
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
// 2. 检查是否过期
if (claims.getExpiration().before(new Date())) {
throw new TokenExpiredException("Token已过期");
}
// 3. 检查用户状态(是否被封禁)
UserInfo user = userService.getUser(claims.get("user_id", String.class));
if (user == null || user.isBanned()) {
throw new UnauthorizedException("用户不存在或已被封禁");
}
return user;
} catch (Exception e) {
throw new UnauthorizedException("Token验证失败");
}
}
3.2 多层签名校验:数据被篡改了吗?
支付系统中的签名校验是多层次的:
┌─────────────────────────────────────────────────────────────┐
│ 前端 → 后端 │
│ Token 签名:验证用户身份 │
│ 请求签名:防止参数篡改(可选,取决于安全要求) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端 → 支付渠道 │
│ 商户签名:验证商户身份,防止伪造请求 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 支付渠道 → 后端(回调) │
│ 渠道签名:验证回调来自真正的支付渠道 │
└─────────────────────────────────────────────────────────────┘
每一层签名都是一道防线,确保数据在传输过程中不被篡改。
[配图建议:多层签名校验的示意图,像多道安检门]
3.3 防重放攻击:同一个请求不能来两次
假设黑客截获了一个合法的支付请求,然后反复发送会怎样?
如果没有防重放机制,系统可能会重复处理同一笔订单。常见的防御手段:
public void checkTimestamp(long requestTime) {
long now = System.currentTimeMillis();
long diff = Math.abs(now - requestTime);
// 允许5分钟的时钟偏差
if (diff > 5 * 60 * 1000) {
throw new RequestExpiredException("请求已过期");
}
}
// Redis 实现
public boolean checkAndSetNonce(String nonce) {
String key = "nonce:" + nonce;
// SETNX:只有 key 不存在时才设置
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
return Boolean.TRUE.equals(success);
}
// 使用
if (!checkAndSetNonce(request.getNonce())) {
throw new ReplayAttackException("重复请求");
}
// 处理支付回调时
@Transactional
public void processPaymentCallback(String orderId, PaymentResult result) {
Order order = orderRepository.findByOrderId(orderId);
// 幂等检查:如果已经处理过,直接返回
if (order.getStatus() == OrderStatus.PAID ||
order.getStatus() == OrderStatus.DELIVERED) {
log.info("订单已处理,orderId={}", orderId);
return; // 不抛异常,让渠道认为处理成功
}
// 正常处理...
order.setStatus(OrderStatus.PAID);
order.setPayTime(Instant.now());
orderRepository.save(order);
}
public void validateRequest(PaymentRequest request) {
// 1. 时间戳校验
checkTimestamp(request.getTimestamp());
// 2. Nonce 校验
if (!checkAndSetNonce(request.getNonce())) {
throw new ReplayAttackException("重复请求");
}
// 3. 签名校验
if (!verifySign(request.getParams(), request.getSign())) {
throw new InvalidSignException("签名无效");
}
// 4. 业务幂等(在处理订单时)
// ...
}
3.4 风控系统:识别异常交易
除了基础的安全校验,成熟的支付系统还有风控系统:
public RiskResult evaluateRisk(PaymentRequest request) {
int riskScore = 0;
List<String> riskFactors = new ArrayList<>();
// 1. 设备风险
if (deviceRiskService.isRooted(request.getDeviceId())) {
riskScore += 30;
riskFactors.add("设备已Root");
}
// 2. 行为风险
int orderCountToday = orderService.countTodayOrders(request.getUserId());
if (orderCountToday > 10) {
riskScore += 20;
riskFactors.add("今日下单次数异常");
}
// 3. 金额风险
if (request.getAmount() > 10000) {
riskScore += 15;
riskFactors.add("大额交易");
}
// 4. 地理风险
if (!geoService.isInUsualLocation(request.getUserId(), request.getIp())) {
riskScore += 25;
riskFactors.add("异地登录");
}
// 5. 时间风险
if (isUnusualTime()) {
riskScore += 10;
riskFactors.add("异常时间段");
}
return RiskResult.builder()
.score(riskScore)
.factors(riskFactors)
.action(riskScore >= 50 ? RiskAction.BLOCK :
riskScore >= 30 ? RiskAction.VERIFY :
RiskAction.PASS)
.build();
}
| 风险分数 | 处理方式 | 说明 |
|---|---|---|
| 0-29 | 直接通过 | 正常交易 |
| 30-49 | 增强验证 | 需要短信/邮箱验证 |
| 50-79 | 人工审核 | 转入人工审核队列 |
| 80+ | 直接拒绝 | 高风险交易 |
四、支付页面:最后的临门一脚
4.1 H5 跳转支付
这是最常见的支付方式:
// 前端获取支付链接后跳转
function goToPayment(payUrl) {
// 方式1:直接跳转
window.location.href = payUrl;
// 方式2:新窗口打开(适合PC端)
window.open(payUrl, '_blank');
// 方式3:iframe 内嵌(不推荐,很多渠道禁止)
// ...
}
┌─────────┐ 1.请求支付 ┌─────────┐
│ 游戏 │ ──────────────► │ 后端 │
└─────────┘ └────┬────┘
│
2.返回支付URL
│
▼
┌─────────┐ 3.跳转支付页 ┌─────────┐
│ 游戏 │ ◄────────────── │ 支付渠道 │
└─────────┘ └────┬────┘
▲ │
│ 4.用户完成支付
│ │
│ 5.回调通知后端
│ │
│ ▼
│ ┌─────────┐
└──────────────────────│ 后端 │
6.查询支付结果 └─────────┘
这种方式兼容性好,但体验略差——用户要离开游戏界面。
4.2 SDK 调起支付
通过支付 SDK 直接调起已安装的支付 App:
// 微信支付 SDK 调起
function callWechatPay(payParams) {
WeixinJSBridge.invoke('getBrandWCPayRequest', {
appId: payParams.app_id,
timeStamp: payParams.timestamp,
nonceStr: payParams.nonce_str,
package: 'prepay_id=' + payParams.prepay_id,
signType: 'MD5',
paySign: payParams.sign
}, function(res) {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
// 支付成功,去后端确认
checkPaymentResult();
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
// 用户取消
showCancelMessage();
} else {
// 支付失败
showErrorMessage(res.err_msg);
}
});
}
// 支付宝 SDK 调起
function callAlipay(payParams) {
AlipayJSBridge.call('tradePay', {
tradeNO: payParams.trade_no
}, function(result) {
if (result.resultCode === '9000') {
checkPaymentResult();
} else if (result.resultCode === '6001') {
showCancelMessage();
} else {
showErrorMessage(result.resultCode);
}
});
}
这种方式用户体验更好,支付完成后可以直接返回游戏,但需要用户安装对应的 App。
4.3 原生支付:苹果内购
iOS 的苹果内购(IAP)是一种特殊的原生支付:
// iOS 内购流程
func purchase(product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
// 处理支付结果
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// 向苹果服务器验证收据
verifyReceipt(transaction)
case .failed:
handleFailure(transaction.error)
case .restored:
handleRestore(transaction)
default:
break
}
}
}
// 向后端验证收据
func verifyReceipt(_ transaction: SKPaymentTransaction) {
let receipt = getReceiptData()
api.verifyAppleReceipt(receipt: receipt) { result in
if result.success {
// 发放商品
SKPaymentQueue.default().finishTransaction(transaction)
}
}
}
- 必须使用:App Store 政策强制要求
- 审核严格:虚拟商品必须走内购,实物可以用其他支付
- 手续费高:30%(年收100万美元以下15%)
- 验证复杂:需要向苹果服务器验证收据
4.4 三种支付方式对比
| 特性 | H5 跳转 | SDK 调起 | 苹果内购 |
|---|---|---|---|
| 用户体验 | 一般 | 好 | 最好 |
| 开发成本 | 低 | 中 | 高 |
| 兼容性 | 最好 | 需安装App | 仅iOS |
| 手续费 | 正常 | 正常 | 15-30% |
| 审核要求 | 无 | 无 | 严格 |
[配图建议:三种支付方式的对比图,展示用户看到的界面差异]
五、异步通知与订单确认
5.1 为什么需要异步通知?
支付完成后,支付渠道会主动通知游戏服务器,这就是异步通知(也叫回调、Webhook)。
为什么需要它?
- 支付可能耗时较长:用户可能在支付页面停留很久
- 前端不可靠:用户可能关闭页面、网络断开
- 确保不漏:任何一笔支付都必须被处理
同步方式(前端返回结果):
用户 → 支付页面 → 支付成功 → 返回游戏 → 前端通知后端
↑
如果这步失败?订单丢失!
异步方式(渠道主动通知):
用户 → 支付页面 → 支付成功 ────────────────────► 后端收到通知
│
└─► 返回游戏(可失败,不影响)
5.2 处理异步通知的原则
@PostMapping("/payment/callback")
public String handleCallback(HttpServletRequest request) {
// 原则1:快速响应(先记录,再处理)
String rawBody = readRawBody(request);
log.info("收到支付回调: {}", rawBody);
try {
// 原则2:先验签,再处理
Map<String, String> params = parseParams(rawBody);
String sign = params.remove("sign");
if (!verifySign(params, sign)) {
log.error("签名校验失败");
return "FAIL"; // 让渠道重试
}
// 原则3:幂等处理
String orderId = params.get("out_trade_no");
Order order = orderService.getByOrderId(orderId);
if (order.getStatus() != OrderStatus.PENDING) {
log.info("订单已处理: {}", orderId);
return "SUCCESS"; // 已处理,告诉渠道不用重试
}
// 原则4:业务逻辑异步处理
paymentService.processPaymentAsync(orderId, params);
return "SUCCESS";
} catch (Exception e) {
log.error("处理支付回调异常", e);
return "FAIL"; // 让渠道重试
}
}
5.3 订单状态同步机制
异步通知可能延迟或丢失,所以需要有主动查询机制:
// 定时任务:检查未确认的订单
@Scheduled(fixedDelay = 60000) // 每分钟执行
public void checkPendingOrders() {
// 查找超过5分钟还在"待支付"的订单
List<Order> pendingOrders = orderRepository.findPendingOrders(
Instant.now().minus(5, ChronoUnit.MINUTES)
);
for (Order order : pendingOrders) {
try {
// 主动查询支付渠道
PaymentStatus status = payChannelService.queryOrder(order);
if (status == PaymentStatus.SUCCESS) {
// 支付成功,处理订单
paymentService.processPayment(order.getOrderId());
} else if (status == PaymentStatus.FAILED) {
// 支付失败,关闭订单
order.setStatus(OrderStatus.FAILED);
orderRepository.save(order);
}
// 其他状态继续等待
} catch (Exception e) {
log.error("查询订单状态失败: {}", order.getOrderId(), e);
}
}
}
5.4 用户主动查询
除了后端定时任务,用户也可以主动触发查询:
// 前端轮询检查支付结果
async function checkPaymentResult(orderId) {
const maxRetry = 10;
const interval = 3000; // 3秒
for (let i = 0; i < maxRetry; i++) {
const result = await api.checkOrder(orderId);
if (result.status === 'PAID') {
showSuccess();
refreshBalance();
return;
} else if (result.status === 'FAILED' || result.status === 'EXPIRED') {
showFailed();
return;
}
// 等待后重试
await sleep(interval);
}
// 超时,提示用户稍后查看
showPending();
}
六、异常处理与重试机制
6.1 异常分类
支付系统的异常可以分为几类:
| 异常类型 | 示例 | 处理方式 |
|---|---|---|
| 网络异常 | 连接超时、DNS解析失败 | 重试 |
| 业务异常 | 商品下架、库存不足 | 返回错误,不重试 |
| 渠道异常 | 支付渠道维护、限流 | 切换渠道或稍后重试 |
| 数据异常 | 订单不存在、状态冲突 | 记录日志,人工介入 |
| 安全异常 | 签名校验失败、重放攻击 | 拒绝并告警 |
6.2 重试策略
// 指数退避重试
public <T> T retryWithBackoff(Callable<T> task, int maxRetries) {
int retry = 0;
long delay = 1000; // 初始延迟1秒
while (true) {
try {
return task.call();
} catch (RetryableException e) {
retry++;
if (retry >= maxRetries) {
throw new MaxRetriesExceededException("重试次数超限");
}
log.warn("重试 {}/{}, 等待 {}ms", retry, maxRetries, delay);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("重试被中断");
}
// 指数退避:1s → 2s → 4s → 8s...
delay = Math.min(delay * 2, 60000); // 最大60秒
}
}
}
| 策略 | 描述 | 适用场景 |
|---|---|---|
| 固定间隔 | 每次等待相同时间 | 简单场景 |
| 线性退避 | 每次增加固定时间 | 一般场景 |
| 指数退避 | 每次翻倍 | 网络抖动 |
| 随机抖动 | 加入随机因素 | 防止惊群效应 |
6.3 幂等设计
幂等性是指:同一个操作执行多次,结果与执行一次相同。
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processPayment(String orderId) {
Order order = orderRepository.findByOrderIdForUpdate(orderId);
// 幂等检查
if (order.getStatus() == OrderStatus.DELIVERED) {
log.info("订单已发货,幂等返回: {}", orderId);
return;
}
if (order.getStatus() == OrderStatus.PAID) {
// 已支付但未发货,继续发货
deliverGoods(order);
order.setStatus(OrderStatus.DELIVERED);
orderRepository.save(order);
return;
}
if (order.getStatus() == OrderStatus.PENDING) {
// 待支付状态,先更新为已支付
order.setStatus(OrderStatus.PAID);
order.setPayTime(Instant.now());
orderRepository.save(order);
// 然后发货
deliverGoods(order);
order.setStatus(OrderStatus.DELIVERED);
orderRepository.save(order);
return;
}
// 其他状态(已过期、已取消等)
throw new OrderStateException("订单状态异常: " + order.getStatus());
}
- 使用数据库行锁(
FOR UPDATE)防止并发 - 状态检查要完整
- 已处理的不抛异常(幂等返回)
6.4 分布式事务处理
支付涉及多个系统,如何保证一致性?
// 创建订单时,同时写入消息表
@Transactional
public void createOrder(Order order) {
// 1. 保存订单
orderRepository.save(order);
// 2. 写入消息表
OutboxMessage message = OutboxMessage.builder()
.type("ORDER_CREATED")
.payload(toJson(order))
.status(MessageStatus.PENDING)
.build();
outboxRepository.save(message);
}
// 定时任务:扫描消息表,发送到消息队列
@Scheduled(fixedDelay = 1000)
public void processOutbox() {
List<OutboxMessage> messages = outboxRepository.findPending();
for (OutboxMessage msg : messages) {
try {
messageQueue.send(msg);
msg.setStatus(MessageStatus.SENT);
outboxRepository.save(msg);
} catch (Exception e) {
log.error("发送消息失败", e);
}
}
}
// Try:预留资源
public void tryDeduct(String orderId) {
// 冻结用户余额,不真正扣减
accountService.freeze(userId, amount);
}
// Confirm:确认扣减
public void confirmDeduct(String orderId) {
// 真正扣减冻结的余额
accountService.deductFrozen(userId, amount);
}
// Cancel:取消预留
public void cancelDeduct(String orderId) {
// 解冻余额
accountService.unfreeze(userId, amount);
}
支付成功 → 写入消息队列 → 消费者处理 → 失败重试 → 成功确认
↓
失败超过阈值 → 人工处理
七、性能优化
7.1 高并发场景的挑战
游戏支付有几个典型的流量特征:
| 场景 | 特点 | 峰值估算 |
|---|---|---|
| 新版本上线 | 短时间大量充值 | 平时10-50倍 |
| 限时活动 | 集中在活动开始时 | 平时5-10倍 |
| 节日促销 | 持续高峰 | 平时3-5倍 |
| 正常运营 | 相对平稳 | 基准 |
7.2 数据库优化
// 订单查询走从库
@ReadOnly
public Order getOrderByOrderId(String orderId) {
return orderRepository.findByOrderId(orderId);
}
// 订单写入走主库
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
}
// 按用户ID取模分表
public String getTableName(String userId) {
int hash = Math.abs(userId.hashCode()) % 16;
return "order_" + hash;
}
// 按时间分表(历史数据归档)
// order_202601, order_202602, ...
-- 订单表核心索引
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_status_create_time ON orders(status, create_time);
CREATE INDEX idx_order_id ON orders(order_id);
-- 联合索引顺序:等值查询在前,范围查询在后
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
7.3 缓存策略
请求 → 本地缓存 → Redis → 数据库
(秒级) (分钟级) (持久化)
// 订单查询的多级缓存
public Order getOrderByOrderId(String orderId) {
// 1. 本地缓存(适合热点订单)
Order order = localCache.get(orderId);
if (order != null) return order;
// 2. Redis缓存
String cacheKey = "order:" + orderId;
String cached = redis.get(cacheKey);
if (cached != null) {
order = parseOrder(cached);
localCache.put(orderId, order);
return order;
}
// 3. 数据库
order = orderRepository.findByOrderId(orderId);
if (order != null) {
// 写入缓存,设置过期时间
redis.setex(cacheKey, 300, toJson(order)); // 5分钟
localCache.put(orderId, order);
}
return order;
}
| 策略 | 描述 | 适用场景 |
|---|---|---|
| Cache Aside | 先更新DB,再删缓存 | 通用方案 |
| Write Through | 同时写缓存和DB | 读多写少 |
| Write Behind | 先写缓存,异步写DB | 高吞吐场景 |
7.4 异步化处理
支付回调处理:
├── 关键路径(同步)
│ ├── 验签
│ ├── 更新订单状态
│ └── 返回成功响应
│
└── 非关键路径(异步)
├── 发放商品
├── 发送通知
├── 统计上报
└── 风控分析
// 异步发放商品
@Async
public void deliverGoodsAsync(Order order) {
try {
// 发放商品可能需要调用游戏服务器
gameService.deliver(order.getUserId(), order.getProductId());
// 发送通知
notificationService.send(order.getUserId(), "购买成功");
// 统计
metricsService.record(order);
} catch (Exception e) {
log.error("发货失败,进入重试队列", e);
retryQueue.add(new RetryTask(order));
}
}
7.5 限流与降级
// 基于令牌桶的限流
public class RateLimiter {
private final TokenBucket bucket;
public boolean tryAcquire() {
return bucket.tryConsume(1);
}
}
// 用户级限流
if (!userRateLimiter.tryAcquire(userId)) {
throw new RateLimitException("操作太频繁,请稍后再试");
}
// 全局限流
if (!globalRateLimiter.tryAcquire()) {
throw new ServiceBusyException("系统繁忙,请稍后再试");
}
// 非核心功能降级
public void processPayment(PaymentRequest request) {
// 核心功能:必须执行
updateOrderStatus(request);
// 非核心功能:可以降级
if (notificationService.isAvailable()) {
notificationService.send(userId, "支付成功");
} else {
// 降级:记录日志,稍后补发
log.info("通知服务不可用,降级处理");
pendingNotifications.add(userId);
}
}
八、游戏行业的支付场景
8.1 道具直购
最简单的场景:玩家看中一个道具,直接购买。
- 商品信息实时同步(价格、库存)
- 购买后立即发放道具
- 处理并发购买(多人同时买最后一件)
// 并发购买处理
@Transactional
public void buyProduct(String userId, String productId) {
Product product = productRepository.findByIdForUpdate(productId);
// 库存检查(加锁后)
if (product.getStock() <= 0) {
throw new OutOfStockException("商品已售罄");
}
// 扣减库存
product.setStock(product.getStock() - 1);
productRepository.save(product);
// 发放道具
gameService.deliver(userId, productId);
}
8.2 礼包购买
礼包是多个商品的组合,通常有折扣。
- 礼包内容原子性发放(要么全发,要么全不发)
- 购买次数限制(每人限购 X 次)
- 与其他优惠的互斥逻辑
// 礼包原子性发放
@Transactional
public void deliverPackage(String userId, String packageId) {
Package pkg = packageRepository.findById(packageId);
List<String> items = pkg.getItems();
// 记录已发放的物品(用于回滚)
List<String> delivered = new ArrayList<>();
try {
for (String itemId : items) {
gameService.deliverItem(userId, itemId);
delivered.add(itemId);
}
} catch (Exception e) {
// 发放失败,回滚已发放的物品
for (String itemId : delivered) {
gameService.revokeItem(userId, itemId);
}
throw e;
}
}
8.3 订阅续费
玩家定期付费,享受持续权益(如月卡、通行证)。
- 自动续费的触发和通知
- 续费失败的处理和重试
- 权益的生效和失效时间计算
- 取消订阅后的宽限期处理
// 订阅状态机
public enum SubscriptionStatus {
ACTIVE, // 活跃
PAST_DUE, // 逾期(扣费失败)
CANCELLED, // 已取消
EXPIRED // 已过期
}
// 续费处理
public void handleRenewal(String subscriptionId) {
Subscription sub = subscriptionRepository.findById(subscriptionId);
try {
// 尝试扣费
paymentService.charge(sub.getUserId(), sub.getAmount());
// 续费成功,延长有效期
sub.setExpireTime(sub.getExpireTime().plus(sub.getPeriod()));
sub.setStatus(SubscriptionStatus.ACTIVE);
} catch (PaymentException e) {
// 续费失败
sub.setStatus(SubscriptionStatus.PAST_DUE);
sub.setRetryCount(sub.getRetryCount() + 1);
if (sub.getRetryCount() >= 3) {
// 重试次数超限,进入宽限期
sub.setGracePeriodEnd(Instant.now().plus(7, ChronoUnit.DAYS));
}
}
subscriptionRepository.save(sub);
}
订阅续费可以类比为"会员制健身房"——你按月付费,每月自动续费,取消后当月权益继续有效。
九、监控与告警
9.1 核心监控指标
| 指标 | 计算方式 | 告警阈值 |
|---|---|---|
| 支付成功率 | 成功订单数 / 总订单数 | < 95% |
| 平均支付时长 | 订单完成时间 - 订单创建时间 | > 30秒 |
| 订单丢失率 | 渠道成功数 - 系统成功数 | > 0.1% |
| 退款率 | 退款订单数 / 总订单数 | > 1% |
| 指标 | 告警阈值 |
|---|---|
| 接口响应时间 P99 | > 500ms |
| 错误率 | > 1% |
| 数据库连接池使用率 | > 80% |
| Redis 内存使用率 | > 80% |
9.2 日志规范
// 支付日志示例
log.info("支付请求 orderId={}, userId={}, amount={}, channel={}",
orderId, userId, amount, channel);
log.info("支付回调 orderId={}, status={}, payTime={}",
orderId, status, payTime);
log.error("支付失败 orderId={}, reason={}",
orderId, reason);
// 结构化日志(便于ELK分析)
log.info(JSON.stringify(Map.of(
"event", "payment_success",
"orderId", orderId,
"userId", userId,
"amount", amount,
"channel", channel,
"duration", duration
)));
9.3 告警策略
// 告警规则配置
AlertRule paymentSuccessRate = AlertRule.builder()
.name("支付成功率下降")
.metric("payment.success_rate")
.condition("< 0.95")
.duration(5, TimeUnit.MINUTES)
.severity(AlertSeverity.HIGH)
.channels(Arrays.asList("feishu", "sms"))
.build();
AlertRule paymentLatency = AlertRule.builder()
.name("支付延迟过高")
.metric("payment.latency_p99")
.condition("> 30000")
.duration(3, TimeUnit.MINUTES)
.severity(AlertSeverity.MEDIUM)
.channels(Arrays.asList("feishu"))
.build();
十、实战案例:一个完整的支付流程
10.1 正常流程时序图
玩家 前端 游戏后端 支付服务 支付渠道
│ │ │ │ │
│ 点击充值 │ │ │ │
├────────────►│ │ │ │
│ │ 创建订单请求 │ │ │
│ ├──────────────►│ │ │
│ │ │ 生成订单 │ │
│ │ ├─────────────►│ │
│ │ │ │ 预支付请求 │
│ │ │ ├─────────────►│
│ │ │ │ 返回预支付ID │
│ │ │ │◄─────────────┤
│ │ │ 返回支付参数 │ │
│ │ │◄─────────────┤ │
│ │ 返回订单信息 │ │ │
│ │◄──────────────┤ │ │
│ │ 调起支付 │ │ │
│ ├──────────────────────────────────────────────►
│ │ │ │ │
│ 确认支付 │ │ │ │
├─────────────────────────────────────────────────────────────►
│ │ │ │ │
│ │ │ 支付回调 │ │
│ │ │◄─────────────┤◄─────────────┤
│ │ │ 验签+处理 │ │
│ │ ├─────────────►│ │
│ │ │ │ 返回成功 │
│ │ │◄─────────────┤ │
│ │ │ 发放商品 │ │
│ │ ├─────────────────────────────►
│ │ │ │ │
│ 支付成功 │ │ │ │
│◄────────────┤ │ │ │
│ │ │ │ │
10.2 异常流程处理
1. 用户完成支付
2. 渠道回调失败(网络问题)
3. 后端未收到通知
4. 用户投诉
解决方案:
- 定时任务主动查询未确认订单
- 用户触发主动查询
- 客服后台手动查询
1. 渠道发送回调
2. 后端处理成功,但响应超时
3. 渠道重发回调
4. 后端再次处理
解决方案:
- 幂等设计:同一订单只处理一次
- 状态机:已处理的订单直接返回成功
1. 用户在两个设备同时点击支付
2. 创建两个订单
3. 都支付成功
4. 发放两份商品?
解决方案:
- 商品库存校验
- 购买次数限制
- 或者:允许购买,但只算一份(看业务需求)
总结:一枚按钮的旅程
当玩家点击"充值"按钮后,数据经历了这样的旅程:
| 阶段 | 关键动作 | 耗时(典型) |
|---|---|---|
| 前端 | 收集信息 → 调用SDK → 请求订单 | 100-300ms |
| 后端 | 验证身份 → 创建订单 → 生成签名 | 50-200ms |
| 支付 | 跳转/调起 → 用户确认 → 完成支付 | 3-30s |
| 回调 | 接收通知 → 验签处理 → 发放商品 | 100-500ms |
| 确认 | 前端轮询 → 显示结果 | 1-5s |
整个过程涉及前端、后端、支付渠道三方协作,每一个环节都有安全机制保驾护航。
要点总结
技术要点
- 金额后端定:前端只负责展示和发起,金额由后端决定,防止篡改
- 多层签名校验:从前端到后端、后端到渠道,每一步都有签名保护
- 防重放三件套:时间戳 + Nonce + 幂等设计,确保请求唯一性
- 异步通知保底:支付结果以渠道的异步通知为准,不依赖前端返回
- 幂等是核心:所有操作都要能重复执行而不产生副作用
架构要点
- 读写分离:查询和写入分离,提升性能
- 异步处理:非关键路径异步化,提升响应速度
- 缓存分层:本地缓存 + Redis + 数据库,减轻数据库压力
- 限流降级:保护系统不被流量冲垮
- 监控告警:及时发现问题,快速响应
业务要点
- 场景决定设计:道具直购、礼包、订阅各有各的技术挑战
- 用户体验优先:支付流程要流畅,错误提示要友好
- 对账很重要:定期与支付渠道对账,发现差异及时处理
- 客服工具:提供订单查询、手动补发等工具
附录:常见问题 FAQ
A:按以下步骤排查:
- 让用户提供订单号或支付截图
- 在后台查询订单状态
- 如果渠道显示成功但订单未处理,检查回调日志
- 确认后手动触发发货或退款
A:
- 风控系统识别异常行为
- 对于虚拟商品,大部分平台不支持退款
- 记录用户行为日志,申诉时作为证据
- 设置黑名单机制
A:
- 健康检查:实时监控各渠道状态
- 自动切换:故障时切换到备用渠道
- 降级提示:所有渠道都不可用时,显示友好提示
- 排队机制:高峰期可以让用户排队等待
A:
- 后端统一使用 UTC 时间
- 数据库存储 UTC 时间
- 前端根据用户时区显示本地时间
- 订单过期时间使用服务端时间计算
十一、渠道对接实战
11.1 微信支付对接详解
微信支付是国内最常用的支付渠道之一,我们来详细看看对接流程。
- 登录微信支付商户平台
- 提交资质审核(营业执照、法人身份证等)
- 绑定结算账户
- 配置API密钥和证书
// 微信支付核心API
public interface WechatPayApi {
// 1. 统一下单(Native/JSAPI/H5)
PrepayResult unifiedOrder(UnifiedOrderRequest request);
// 2. 查询订单
OrderQueryResult queryOrder(String orderId);
// 3. 关闭订单
void closeOrder(String orderId);
// 4. 申请退款
RefundResult refund(RefundRequest request);
// 5. 查询退款
RefundQueryResult queryRefund(String refundId);
}
// JSAPI支付(微信内H5)
public class WechatJsapiPayService {
public PrepayResult createJsapiOrder(Order order) {
// 构建请求参数
Map<String, String> params = new TreeMap<>();
params.put("appid", appId);
params.put("mch_id", mchId);
params.put("nonce_str", UUID.randomUUID().toString().replace("-", ""));
params.put("body", order.getProductName());
params.put("out_trade_no", order.getOrderId());
params.put("total_fee", String.valueOf(order.getAmount()));
params.put("spbill_create_ip", getClientIp());
params.put("notify_url", notifyUrl);
params.put("trade_type", "JSAPI");
params.put("openid", order.getOpenid()); // JSAPI必须传openid
// 签名
params.put("sign", generateSign(params));
// 调用微信API
String response = httpClient.post(
"https://api.mch.weixin.qq.com/pay/unifiedorder",
mapToXml(params)
);
// 解析响应
PrepayResult result = parseResponse(response);
// 生成前端调起支付的参数
return buildJsapiParams(result.getPrepay_id());
}
// 生成前端需要的支付参数
private JsapiParams buildJsapiParams(String prepayId) {
Map<String, String> params = new TreeMap<>();
params.put("appId", appId);
params.put("timeStamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("nonceStr", UUID.randomUUID().toString());
params.put("package", "prepay_id=" + prepayId);
params.put("signType", "MD5");
params.put("paySign", generateSign(params));
return JsapiParams.fromMap(params);
}
}
@PostMapping("/wechat/pay/callback")
public String handleWechatCallback(HttpServletRequest request) {
String xmlBody = readXmlBody(request);
// 解析XML
Map<String, String> params = parseXml(xmlBody);
// 验签
String sign = params.remove("sign");
if (!verifySign(params, sign)) {
log.error("微信支付回调验签失败");
return buildFailResponse("签名错误");
}
// 检查支付结果
String resultCode = params.get("result_code");
if (!"SUCCESS".equals(resultCode)) {
log.warn("支付失败: {}", params.get("err_code_des"));
return buildSuccessResponse();
}
// 处理支付成功
String orderId = params.get("out_trade_no");
String transactionId = params.get("transaction_id");
try {
paymentService.processPayment(orderId, transactionId);
return buildSuccessResponse();
} catch (Exception e) {
log.error("处理支付失败", e);
return buildFailResponse("处理失败");
}
}
private String buildSuccessResponse() {
return "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
}
private String buildFailResponse(String msg) {
return "<xml><return_code><![CDATA[FAIL]]></return_code>" +
"<return_msg><![CDATA[" + msg + "]]></return_msg></xml>";
}
11.2 苹果内购对接详解
苹果内购(IAP)的对接与第三方支付完全不同,有它独特的流程。
- 强制使用:iOS App 必须使用苹果内购销售虚拟商品
- 审核严格:审核时会检查是否有绕过内购的代码
- 收据验证:必须向苹果服务器验证收据的真实性
- 订阅复杂:自动续期订阅的处理非常复杂
public class AppleIapService {
// 沙盒环境和生产环境地址
private static final String SANDBOX_URL =
"https://sandbox.itunes.apple.com/verifyReceipt";
private static final String PRODUCTION_URL =
"https://buy.itunes.apple.com/verifyReceipt";
public VerifyResult verifyReceipt(String receiptData) {
// 1. 先尝试生产环境
VerifyResult result = verifyWithUrl(PRODUCTION_URL, receiptData);
// 2. 如果是沙盒收据,切换到沙盒环境
if (result.getStatus() == 21007) {
result = verifyWithUrl(SANDBOX_URL, receiptData);
}
return result;
}
private VerifyResult verifyWithUrl(String url, String receiptData) {
Map<String, Object> request = new HashMap<>();
request.put("receipt-data", receiptData);
request.put("password", sharedSecret); // 用于订阅
String response = httpClient.postJson(url, request);
return parseVerifyResponse(response);
}
}
// 苹果会通过服务器通知告知订阅状态变化
@PostMapping("/apple/notifications")
public void handleAppleNotification(AppleNotification notification) {
switch (notification.getNotificationType()) {
case "INITIAL_BUY":
// 首次购买
handleInitialBuy(notification);
break;
case "DID_RENEW":
// 续费成功
handleRenewal(notification);
break;
case "DID_FAIL_TO_RENEW":
// 续费失败
handleRenewalFailure(notification);
break;
case "DID_CHANGE_RENEWAL_STATUS":
// 用户取消/恢复续费
handleRenewalStatusChange(notification);
break;
case "CANCEL":
// 退款
handleRefund(notification);
break;
}
}
11.3 支付宝对接详解
public class AlipayService {
// 支付宝当面付(扫码支付)
public AlipayTradePrecreateResult createQrCodePay(Order order) {
AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
request.setNotifyUrl(notifyUrl);
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", order.getOrderId());
bizContent.put("total_amount", formatAmount(order.getAmount()));
bizContent.put("subject", order.getProductName());
bizContent.put("timeout_express", "15m");
request.setBizContent(bizContent.toString());
return alipayClient.execute(request);
}
// 手机网站支付
public String createWapPayUrl(Order order) {
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setNotifyUrl(notifyUrl);
request.setReturnUrl(returnUrl);
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", order.getOrderId());
bizContent.put("total_amount", formatAmount(order.getAmount()));
bizContent.put("subject", order.getProductName());
bizContent.put("product_code", "QUICK_WAP_WAY");
request.setBizContent(bizContent.toString());
return alipayClient.pageExecute(request).getBody();
}
}
十二、对账系统
12.1 为什么需要对账?
对账是确保资金安全的最后一道防线:
- 发现漏单:回调失败导致的未处理订单
- 发现差异:系统记录与渠道记录不一致
- 发现问题:潜在的Bug或欺诈行为
- 合规要求:财务审计需要
12.2 对账流程
┌─────────────────────────────────────────────────────────────┐
│ 每日对账流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ T+1 凌晨 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 下载渠道账单 │ ← 微信/支付宝/苹果等 │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 解析账单数据 │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 与本地订单比对 │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ │ │
│ ▼ ▼ │
│ 一致 不一致 │
│ │ │ │
│ ▼ ▼ │
│ 完成 生成差异报告 │
│ │ │
│ ▼ │
│ 人工处理 │
│ │
└─────────────────────────────────────────────────────────────┘
12.3 对账实现
@Service
public class ReconciliationService {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void dailyReconciliation() {
LocalDate yesterday = LocalDate.now().minusDays(1);
// 1. 下载各渠道账单
for (PayChannel channel : payChannels) {
try {
BillFile bill = channel.downloadBill(yesterday);
processBill(channel, bill, yesterday);
} catch (Exception e) {
log.error("下载账单失败: channel={}", channel.getName(), e);
alertService.sendAlert("账单下载失败: " + channel.getName());
}
}
}
private void processBill(PayChannel channel, BillFile bill, LocalDate date) {
// 2. 解析账单
List<BillRecord> records = billParser.parse(bill);
// 3. 逐条比对
List<ReconciliationDiff> diffs = new ArrayList<>();
for (BillRecord record : records) {
Order order = orderRepository.findByOrderId(record.getOrderId());
if (order == null) {
// 渠道有,系统无 → 漏单
diffs.add(ReconciliationDiff.missing(record));
continue;
}
if (order.getAmount() != record.getAmount()) {
// 金额不一致
diffs.add(ReconciliationDiff.amountMismatch(order, record));
continue;
}
if (order.getStatus() != record.getStatus()) {
// 状态不一致
diffs.add(ReconciliationDiff.statusMismatch(order, record));
}
}
// 4. 处理差异
if (!diffs.isEmpty()) {
handleDiffs(channel, diffs, date);
}
// 5. 保存对账记录
saveReconciliationResult(channel, date, records.size(), diffs.size());
}
private void handleDiffs(PayChannel channel, List<ReconciliationDiff> diffs, LocalDate date) {
// 生成差异报告
ReconciliationReport report = ReconciliationReport.builder()
.channel(channel.getName())
.date(date)
.diffs(diffs)
.build();
// 发送通知
alertService.sendReconciliationAlert(report);
// 自动处理部分差异
for (ReconciliationDiff diff : diffs) {
if (diff.getType() == DiffType.MISSING_ORDER) {
// 漏单:尝试自动补单
try {
paymentService.processPayment(diff.getRecord().getOrderId());
log.info("自动补单成功: {}", diff.getRecord().getOrderId());
} catch (Exception e) {
log.error("自动补单失败", e);
}
}
}
// 保存报告
reportRepository.save(report);
}
}
十三、退款处理
13.1 退款场景
游戏中的退款场景相对复杂:
| 场景 | 触发方 | 处理方式 |
|---|---|---|
| 用户申请退款 | 用户 | 客服审核后处理 |
| 渠道强制退款 | 渠道(苹果/Google) | 收到通知后扣回商品 |
| 系统异常退款 | 系统 | 自动处理 |
| 活动错误退款 | 运营 | 批量处理 |
13.2 退款流程
public class RefundService {
@Transactional
public RefundResult refund(RefundRequest request) {
// 1. 查询原订单
Order order = orderRepository.findByOrderId(request.getOrderId());
if (order == null) {
throw new OrderNotFoundException();
}
// 2. 校验退款条件
if (order.getStatus() != OrderStatus.DELIVERED) {
throw new RefundNotAllowedException("订单状态不允许退款");
}
if (order.getRefundStatus() == RefundStatus.REFUNDED) {
throw new AlreadyRefundedException("订单已退款");
}
// 3. 计算退款金额
int refundAmount = calculateRefundAmount(order, request);
// 4. 扣回商品
try {
gameService.revokeGoods(order.getUserId(), order.getProductId());
} catch (Exception e) {
log.error("扣回商品失败", e);
// 记录,但不阻止退款(可能用户已经消费了)
refundLogService.logRevokeFailed(order, e);
}
// 5. 调用渠道退款API
RefundResult result = payChannelService.refund(
order.getChannel(),
order.getChannelOrderId(),
refundAmount,
request.getReason()
);
// 6. 更新订单状态
order.setRefundStatus(RefundStatus.REFUNDED);
order.setRefundAmount(refundAmount);
order.setRefundTime(Instant.now());
orderRepository.save(order);
// 7. 记录退款流水
refundLogService.log(order, result);
return result;
}
// 苹果的特殊处理:被动接收退款通知
public void handleAppleRefundNotification(AppleRefundNotification notification) {
String orderId = notification.getOrderId();
Order order = orderRepository.findByOrderId(orderId);
if (order == null) {
log.warn("收到未知订单的退款通知: {}", orderId);
return;
}
// 苹果退款是强制的,不需要我们同意
// 只需要扣回商品
try {
gameService.revokeGoods(order.getUserId(), order.getProductId());
} catch (Exception e) {
log.error("扣回商品失败", e);
// 记录待处理
pendingRevokeService.add(order);
}
// 更新订单状态
order.setRefundStatus(RefundStatus.CHARGEBACK);
order.setRefundTime(Instant.now());
orderRepository.save(order);
// 可能需要封号(频繁退款的玩家)
if (userService.getRefundCount(order.getUserId()) > 3) {
userService.flagUser(order.getUserId(), "频繁退款");
}
}
}
十四、国际化支付
14.1 多币种支持
public class CurrencyService {
// 支持的货币
private static final Set<String> SUPPORTED_CURRENCIES = Set.of(
"CNY", "USD", "EUR", "JPY", "KRW", "TWD", "HKD", "SGD"
);
// 汇率缓存
private final LoadingCache<String, BigDecimal> exchangeRates;
public CurrencyService() {
this.exchangeRates = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build(this::fetchExchangeRate);
}
// 货币转换
public int convert(int amount, String fromCurrency, String toCurrency) {
if (fromCurrency.equals(toCurrency)) {
return amount;
}
BigDecimal rate = exchangeRates.get(fromCurrency + "_" + toCurrency);
return rate.multiply(BigDecimal.valueOf(amount))
.setScale(0, RoundingMode.HALF_UP)
.intValue();
}
// 从汇率API获取
private BigDecimal fetchExchangeRate(String key) {
String[] parts = key.split("_");
String from = parts[0];
String to = parts[1];
// 调用汇率API
ExchangeRateResponse response = exchangeRateApi.getRate(from, to);
return response.getRate();
}
}
14.2 多地区支付渠道
public class RegionalPayChannelService {
private final Map<String, PayChannel> channelMap;
public RegionalPayChannelService() {
channelMap = new HashMap<>();
// 中国大陆
channelMap.put("CN", new CombinedChannel(
new WechatPayChannel(),
new AlipayChannel()
));
// 美国
channelMap.put("US", new CombinedChannel(
new StripeChannel(),
new PayPalChannel()
));
// 日本
channelMap.put("JP", new CombinedChannel(
new LinePayChannel(),
new SoftbankChannel()
));
// 韩国
channelMap.put("KR", new CombinedChannel(
new KakaoPayChannel(),
new TossChannel()
));
// 东南亚
channelMap.put("SEA", new CombinedChannel(
new GrabPayChannel(),
new GCashChannel()
));
}
public PayChannel getChannel(String region) {
return channelMap.getOrDefault(region, defaultChannel);
}
}
十五、最佳实践总结
15.1 设计原则
- 安全第一:金额永远由后端决定,前端不可信
- 幂等设计:所有操作都要能安全重试
- 异步优先:非关键路径异步化,提升响应速度
- 数据为王:日志要详细,便于排查问题
- 防御编程:假设一切都会失败,准备好兜底方案
15.2 技术选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 订单存储 | MySQL + 分库分表 | 事务支持、成熟稳定 |
| 缓存 | Redis Cluster | 高性能、支持分布式 |
| 消息队列 | RocketMQ/Kafka | 事务消息、可靠性高 |
| 日志 | ELK | 全文检索、可视化 |
| 监控 | Prometheus + Grafana | 指标全面、告警灵活 |
15.3 避坑指南
- 不要相信前端传的金额 - 这是新手最容易犯的错误
- 不要忽略回调重试 - 渠道会重试,必须幂等处理
- 不要忘记超时处理 - 订单要设置过期时间
- 不要省略日志 - 出问题时你会感谢详细的日志
- 不要忽视对账 - 这是发现问题的最后一道防线
- 不要硬编码配置 - 渠道参数要可配置,便于切换
- 不要单点部署 - 支付服务要高可用
附录:常见错误码
| 错误码 | 含义 | 处理建议 |
|---|---|---|
| 10001 | 订单不存在 | 检查订单号是否正确 |
| 10002 | 订单已支付 | 幂等返回,不重复处理 |
| 10003 | 订单已过期 | 引导用户重新下单 |
| 10004 | 签名验证失败 | 检查密钥配置 |
| 10005 | 金额不匹配 | 检查订单金额 |
| 20001 | 渠道连接失败 | 稍后重试或切换渠道 |
| 20002 | 渠道返回错误 | 查看渠道错误码 |
| 20003 | 渠道维护中 | 切换渠道或等待 |
| 30001 | 用户余额不足 | 引导用户充值 |
| 30002 | 超过支付限额 | 引导用户分笔支付 |
| 40001 | 风控拦截 | 人工审核或拒绝 |
💬 评论 (0)