当玩家点击"充值"时发生了什么?

一枚按钮背后,是一场跨越前后端、穿梭多个系统的精密协作。这不是简单的"转账",而是一场跨越半个互联网的接力赛。


引言:看似简单的一"点"

玩家在游戏里看到心仪的道具,点击"充值"按钮,几秒钟后支付成功——整个过程行云流水。但你有没有想过,这短短几秒内,数据在系统间经历了怎样的旅程?

今天我们拆解这个流程,看看一枚充值按钮背后的技术世界。

这就像你在餐厅点菜:看似只是"我要一份宫保鸡丁",但背后涉及点菜系统、厨房调度、食材库存、结账系统……每一个环节都有它的学问。支付系统比这更复杂——它涉及真金白银,容不得半点差错。

[配图建议:玩家点击充值按钮的场景截图,配合数据流动的虚线箭头]


一、前端:从点击到发起

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 想象成一个"翻译官 + 保镖"的组合:

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;
}
  1. 全局唯一:分布式环境下也要保证唯一
  2. 有序性:便于按时间查询和排序
  3. 可追溯:从订单号能看出一些信息(日期、服务器等)
  4. 防猜测:不能让黑客猜出下一个订单号
方案 优点 缺点
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)
    }
  }
}
  1. 必须使用:App Store 政策强制要求
  2. 审核严格:虚拟商品必须走内购,实物可以用其他支付
  3. 手续费高:30%(年收100万美元以下15%)
  4. 验证复杂:需要向苹果服务器验证收据

4.4 三种支付方式对比

特性 H5 跳转 SDK 调起 苹果内购
用户体验 一般 最好
开发成本
兼容性 最好 需安装App 仅iOS
手续费 正常 正常 15-30%
审核要求 严格

[配图建议:三种支付方式的对比图,展示用户看到的界面差异]


五、异步通知与订单确认

5.1 为什么需要异步通知?

支付完成后,支付渠道会主动通知游戏服务器,这就是异步通知(也叫回调、Webhook)。

为什么需要它?

  1. 支付可能耗时较长:用户可能在支付页面停留很久
  2. 前端不可靠:用户可能关闭页面、网络断开
  3. 确保不漏:任何一笔支付都必须被处理
同步方式(前端返回结果):
用户 → 支付页面 → 支付成功 → 返回游戏 → 前端通知后端
                                          ↑
                                   如果这步失败?订单丢失!

异步方式(渠道主动通知):
用户 → 支付页面 → 支付成功 ────────────────────► 后端收到通知
                        │
                        └─► 返回游戏(可失败,不影响)

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());
}
  1. 使用数据库行锁(FOR UPDATE)防止并发
  2. 状态检查要完整
  3. 已处理的不抛异常(幂等返回)

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 礼包购买

礼包是多个商品的组合,通常有折扣。

// 礼包原子性发放
@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

整个过程涉及前端、后端、支付渠道三方协作,每一个环节都有安全机制保驾护航。


要点总结

技术要点

  1. 金额后端定:前端只负责展示和发起,金额由后端决定,防止篡改
  2. 多层签名校验:从前端到后端、后端到渠道,每一步都有签名保护
  3. 防重放三件套:时间戳 + Nonce + 幂等设计,确保请求唯一性
  4. 异步通知保底:支付结果以渠道的异步通知为准,不依赖前端返回
  5. 幂等是核心:所有操作都要能重复执行而不产生副作用

架构要点

  1. 读写分离:查询和写入分离,提升性能
  2. 异步处理:非关键路径异步化,提升响应速度
  3. 缓存分层:本地缓存 + Redis + 数据库,减轻数据库压力
  4. 限流降级:保护系统不被流量冲垮
  5. 监控告警:及时发现问题,快速响应

业务要点

  1. 场景决定设计:道具直购、礼包、订阅各有各的技术挑战
  2. 用户体验优先:支付流程要流畅,错误提示要友好
  3. 对账很重要:定期与支付渠道对账,发现差异及时处理
  4. 客服工具:提供订单查询、手动补发等工具

附录:常见问题 FAQ

A:按以下步骤排查:

  1. 让用户提供订单号或支付截图
  2. 在后台查询订单状态
  3. 如果渠道显示成功但订单未处理,检查回调日志
  4. 确认后手动触发发货或退款

A:

  1. 风控系统识别异常行为
  2. 对于虚拟商品,大部分平台不支持退款
  3. 记录用户行为日志,申诉时作为证据
  4. 设置黑名单机制

A:

  1. 健康检查:实时监控各渠道状态
  2. 自动切换:故障时切换到备用渠道
  3. 降级提示:所有渠道都不可用时,显示友好提示
  4. 排队机制:高峰期可以让用户排队等待

A:

  1. 后端统一使用 UTC 时间
  2. 数据库存储 UTC 时间
  3. 前端根据用户时区显示本地时间
  4. 订单过期时间使用服务端时间计算

十一、渠道对接实战

11.1 微信支付对接详解

微信支付是国内最常用的支付渠道之一,我们来详细看看对接流程。

  1. 登录微信支付商户平台
  2. 提交资质审核(营业执照、法人身份证等)
  3. 绑定结算账户
  4. 配置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)的对接与第三方支付完全不同,有它独特的流程。

  1. 强制使用:iOS App 必须使用苹果内购销售虚拟商品
  2. 审核严格:审核时会检查是否有绕过内购的代码
  3. 收据验证:必须向苹果服务器验证收据的真实性
  4. 订阅复杂:自动续期订阅的处理非常复杂
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 为什么需要对账?

对账是确保资金安全的最后一道防线:

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 设计原则

  1. 安全第一:金额永远由后端决定,前端不可信
  2. 幂等设计:所有操作都要能安全重试
  3. 异步优先:非关键路径异步化,提升响应速度
  4. 数据为王:日志要详细,便于排查问题
  5. 防御编程:假设一切都会失败,准备好兜底方案

15.2 技术选型建议

场景 推荐方案 原因
订单存储 MySQL + 分库分表 事务支持、成熟稳定
缓存 Redis Cluster 高性能、支持分布式
消息队列 RocketMQ/Kafka 事务消息、可靠性高
日志 ELK 全文检索、可视化
监控 Prometheus + Grafana 指标全面、告警灵活

15.3 避坑指南

  1. 不要相信前端传的金额 - 这是新手最容易犯的错误
  2. 不要忽略回调重试 - 渠道会重试,必须幂等处理
  3. 不要忘记超时处理 - 订单要设置过期时间
  4. 不要省略日志 - 出问题时你会感谢详细的日志
  5. 不要忽视对账 - 这是发现问题的最后一道防线
  6. 不要硬编码配置 - 渠道参数要可配置,便于切换
  7. 不要单点部署 - 支付服务要高可用

附录:常见错误码

错误码 含义 处理建议
10001 订单不存在 检查订单号是否正确
10002 订单已支付 幂等返回,不重复处理
10003 订单已过期 引导用户重新下单
10004 签名验证失败 检查密钥配置
10005 金额不匹配 检查订单金额
20001 渠道连接失败 稍后重试或切换渠道
20002 渠道返回错误 查看渠道错误码
20003 渠道维护中 切换渠道或等待
30001 用户余额不足 引导用户充值
30002 超过支付限额 引导用户分笔支付
40001 风控拦截 人工审核或拒绝

💬 评论 (0)

0/500
排序: