微信支付服务商模式 V3 API 实战:Spring Boot 集成 1 个 APP 对接 N 个子商户收款
微信支付服务商模式 V3 API 实战:Spring Boot 集成 1 个 APP 对接 N 个子商户收款
在当今数字化支付浪潮中,聚合支付平台和 SaaS 服务商面临的核心挑战之一是如何高效管理多个子商户的收款流程。微信支付服务商模式 V3 API 为这一场景提供了优雅的解决方案,本文将深入探讨如何基于 Spring Boot 实现一个 APP 动态路由多个子商户的完整技术方案。
1. 服务商模式架构解析
服务商模式与普通商户模式的核心差异在于资金流和信息流的处理方式。在服务商架构中:
- 资金流向:用户支付款项直接进入子商户账户,服务商不触碰资金
- 数据流向:服务商系统作为中间层,统一处理与微信支付的 API 交互
- 权限控制:服务商持有主证书,子商户只需提供基础身份信息
关键参数对比如下:
| 参数类型 | 服务商模式 | 普通商户模式 |
|---|---|---|
| 商户号 | sp_mchid + sub_mchid | mchid |
| 应用ID | sp_appid + sub_appid(可选) | appid |
| 用户标识 | sp_openid/sub_openid | openid |
| API证书 | 仅服务商持有 | 商户自行管理 |
签名机制是服务商模式的安全核心。V3 API 采用基于 SHA256-RSA 的签名算法,相比 V2 的 MD5 具有更强的安全性:
public class WxPayV3Signer { private static final String ALGORITHM = "SHA256withRSA"; public static String sign(String message, PrivateKey privateKey) throws Exception { Signature sign = Signature.getInstance(ALGORITHM); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); } }2. 环境准备与配置管理
2.1 证书与密钥管理
服务商需要从微信支付平台获取以下安全文件:
- 商户API证书(
apiclient_cert.p12) - 商户API私钥(
apiclient_key.pem) - 微信支付平台证书
推荐采用数据库存储多商户配置:
CREATE TABLE `wx_sub_merchant` ( `id` bigint NOT NULL AUTO_INCREMENT, `sub_mch_id` varchar(32) NOT NULL COMMENT '子商户号', `sub_appid` varchar(32) DEFAULT NULL COMMENT '子商户应用ID', `cert_path` varchar(255) NOT NULL COMMENT '证书存储路径', `api_key` varchar(64) NOT NULL COMMENT 'API密钥', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-禁用 1-启用', PRIMARY KEY (`id`), UNIQUE KEY `idx_sub_mch` (`sub_mch_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.2 Spring Boot 配置类
动态配置加载实现方案:
@Configuration public class WxPayConfig { @Bean @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public WxPayProperties wxPayProperties(SubMerchantService merchantService) { String subMchId = RequestContextHolder.getRequestAttributes() .getAttribute("subMchId", RequestAttributes.SCOPE_REQUEST); return merchantService.getConfig(subMchId); } @Bean public WxPayService wxPayService(WxPayProperties properties) throws Exception { WxPayConfig config = new WxPayConfig(); config.setMerchantId(properties.getSpMchId()); config.setPrivateKey(loadPrivateKey(properties.getKeyPath())); config.setMerchantSerialNumber(properties.getSerialNo()); return new WxPayServiceImpl(config); } private PrivateKey loadPrivateKey(String path) throws Exception { String content = new String(Files.readAllBytes(Paths.get(path))); String privateKey = content.replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replaceAll("\\s+", ""); KeyFactory kf = KeyFactory.getInstance("RSA"); return kf.generatePrivate( new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))); } }3. 动态商户路由实现
3.1 商户识别策略
常见的商户识别方式包括:
- URL路径参数:
/api/pay/{subMchId}/create - 请求头参数:
X-Sub-Mch-Id: 123456789 - JWT令牌:在认证令牌中嵌入商户信息
- 业务关联:通过订单ID反向查询所属商户
推荐使用 Spring 拦截器实现统一路由:
public class MerchantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String subMchId = extractSubMchId(request); SubMerchant merchant = merchantService.validate(subMchId); request.setAttribute("subMchId", merchant.getSubMchId()); return true; } private String extractSubMchId(HttpServletRequest request) { // 实现从不同位置提取商户号的逻辑 } }3.2 统一下单接口实现
动态商户统一下单核心代码:
@RestController @RequestMapping("/api/pay") public class PaymentController { @Autowired private WxPayService wxPayService; @PostMapping("/unified") public Map<String, String> unifiedOrder(@RequestBody OrderDTO dto, HttpServletRequest request) { String subMchId = (String) request.getAttribute("subMchId"); WxPayUnifiedOrderRequest wxRequest = new WxPayUnifiedOrderRequest(); wxRequest.setSpAppid(config.getSpAppid()); wxRequest.setSubMchid(subMchId); wxRequest.setDescription(dto.getGoodsDesc()); wxRequest.setOutTradeNo(generateOrderNo()); wxRequest.setNotifyUrl(config.getNotifyUrl()); wxRequest.setAmount(new Amount().setTotal(dto.getTotalFee())); try { WxPayUnifiedOrderResult result = wxPayService.unifiedOrder(wxRequest); return buildAppParams(result, subMchId); } catch (WxPayException e) { throw new BusinessException("支付下单失败: " + e.getReturnMsg()); } } private Map<String, String> buildAppParams(WxPayUnifiedOrderResult result, String subMchId) { Map<String, String> params = new HashMap<>(); params.put("appid", config.getSpAppid()); params.put("partnerid", subMchId); params.put("prepayid", result.getPrepayId()); params.put("package", "Sign=WXPay"); params.put("noncestr", WxPayUtil.generateNonceStr()); params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000)); params.put("sign", createAppSign(params)); return params; } }4. 安全与异常处理
4.1 回调验签机制
支付回调安全处理流程:
@PostMapping("/notify") public String payNotify(@RequestBody String xmlData, HttpServletRequest request) { try { // 1. 解析通知数据 WxPayOrderNotifyResult result = wxPayService.parseOrderNotifyResult(xmlData); // 2. 验证签名 if (!wxPayService.isPayResultNotifySignatureValid(result)) { return WxPayUtil.mapToXml(Collections.singletonMap( "return_code", "FAIL")); } // 3. 处理业务逻辑 orderService.processPayment(result.getOutTradeNo(), result.getTotalFee()); // 4. 返回成功响应 return WxPayUtil.mapToXml(Collections.singletonMap( "return_code", "SUCCESS")); } catch (Exception e) { log.error("支付通知处理失败", e); return WxPayUtil.mapToXml(Collections.singletonMap( "return_code", "FAIL")); } }4.2 常见问题排查
问题1:证书加载失败
解决方案:确保证书文件放置在类路径或绝对路径,JAR包运行时需特殊处理资源加载
问题2:签名验证失败
检查项:
- 参数名大小写严格匹配
- 签名前参数按ASCII码排序
- 时间戳单位为秒
问题3:子商户未授权
处理流程:
- 登录服务商平台检查子商户状态
- 确认子商户已绑定当前APPID
- 检查子商户费率是否配置
典型错误响应处理示例:
@ExceptionHandler(WxPayException.class) public ResponseEntity<ErrorResult> handleWxPayException(WxPayException ex) { ErrorResult result = new ErrorResult(); result.setCode(ex.getErrCode()); result.setMessage(convertErrorMsg(ex.getErrCode())); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); } private String convertErrorMsg(String errCode) { switch (errCode) { case "PARAM_ERROR": return "参数格式错误,请检查字段类型和必填项"; case "NO_AUTH": return "子商户未授权或已解除关系"; case "NOT_ENOUGH": return "子商户账户余额不足"; default: return "支付系统繁忙,请稍后重试"; } }5. 性能优化实践
5.1 证书缓存策略
微信支付平台证书需要定期更新(默认5年有效期),推荐实现自动更新机制:
@Scheduled(fixedRate = 86400000) // 每天检查一次 public void refreshCertificates() { try { List<WxPayCertificate> certs = wxPayService.downloadCertificates(); certs.forEach(cert -> { String serialNo = cert.getSerialNo(); X509Certificate x509Cert = parseCertificate(cert.getEncryptCertificate()); certificateCache.put(serialNo, x509Cert); }); } catch (Exception e) { log.error("证书更新失败", e); } }5.2 数据库连接优化
多商户场景下的数据库访问建议:
为
wx_sub_merchant表添加合适的索引使用HikariCP连接池配置:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000高频访问的商户信息使用Redis缓存:
@Cacheable(value = "merchant", key = "#subMchId") public SubMerchant getByMchId(String subMchId) { return merchantMapper.selectBySubMchId(subMchId); }6. 扩展功能实现
6.1 分账功能集成
服务商模式下的分账接口调用示例:
public void requestSplit(String orderId, String subMchId) { WxPayProfitSharingRequest request = new WxPayProfitSharingRequest(); request.setTransactionId(orderId); request.setSubMchid(subMchId); List<WxPayProfitSharingReceiver> receivers = new ArrayList<>(); receivers.add(new WxPayProfitSharingReceiver() .setType("MERCHANT_ID") .setAccount("分账方商户号") .setAmount(100) .setDescription("平台服务费")); try { wxPayService.profitSharing(request, receivers); } catch (WxPayException e) { if ("NOT_ENOUGH".equals(e.getErrCode())) { // 处理余额不足情况 } } }6.2 跨境支付支持
当需要处理跨境支付时,需额外配置:
- 在服务商平台开通跨境支付功能
- 子商户提交海关备案信息
- 支付接口添加海关参数:
wxRequest.setCustoms("GUANGZHOU"); wxRequest.setMerchantCustomsCode("商户海关备案号");7. 监控与运维
建议部署以下监控指标:
支付成功率监控:
sum(rate(wxpay_api_calls_total{method="unifiedorder",status="success"}[5m])) / sum(rate(wxpay_api_calls_total{method="unifiedorder"}[5m]))平均响应时间监控:
histogram_quantile(0.95, sum(rate(wxpay_api_duration_seconds_bucket[5m])) by (le))商户限额预警:
SELECT sub_mch_id, SUM(amount) FROM payment_transactions WHERE create_time >= CURRENT_DATE GROUP BY sub_mch_id HAVING SUM(amount) > 500000;
日志记录建议包含关键字段:
MDC.put("subMchId", subMchId); MDC.put("orderNo", orderNo); log.info("发起微信支付请求,金额:{}", amount);