前情提要

这次给一个 Vue + Spring Boot 的会员系统接入微信支付,最开始以为只要做一个二维码就够了,后来才发现这件事在网页端有三个完全不同的场景:

  • 电脑浏览器打开:适合 Native 扫码支付。
  • 手机微信内打开:适合 JSAPI 支付。
  • 手机普通浏览器打开:适合 H5 支付。

一开始我们只做了 Native 支付:后端调用微信支付的 Native 下单接口拿到 code_url,前端把它转成二维码展示。这在 PC 上没问题,但手机上如果用户长按识别二维码,或者截图后再扫码,会被微信支付拦截。原因是 Native 的官方场景本来就是“商户系统展示二维码,用户用微信扫一扫”,不是手机网页内长按识别。

最终方案是:桌面端继续 Native;微信内网页走 JSAPI;微信外移动端走 H5。

最终架构

支付入口仍然只有一个“创建订单”接口,但前端会根据环境传入不同的 payType

1
2
3
4
5
6
7
8
function preferredPayType(): WechatPayType {
if (isWechatBrowser()) return 'jsapi'
return isMobile.value || isMobileUA() ? 'h5' : 'native'
}

function isWechatBrowser() {
return typeof navigator !== 'undefined' && /MicroMessenger/i.test(navigator.userAgent)
}

后端根据 payType 调用不同的微信支付服务:

  • native:返回 codeUrl,前端生成二维码。
  • jsapi:返回 appId/timeStamp/nonceStr/package/signType/paySign,前端调用 WeixinJSBridge.invoke
  • h5:返回 h5Url,前端跳转微信收银台。

后端配置

后端用的是微信支付 Java SDK:

1
2
3
4
5
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.17</version>
</dependency>

配置项要同时覆盖商户号、证书、公钥、公众号 AppID/AppSecret、回调地址、H5 场景信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
web:
wechat-pay:
enabled: ${WECHAT_PAY_ENABLED:true}
app-id: ${WECHAT_PAY_APP_ID}
app-secret: ${WECHAT_PAY_APP_SECRET:}
mch-id: ${WECHAT_PAY_MCH_ID}
api-v3-key: ${WECHAT_PAY_API_V3_KEY}
merchant-serial-number: ${WECHAT_PAY_MERCHANT_SERIAL}
private-key-path: ${WECHAT_PAY_PRIVATE_KEY_PATH}
notify-url: ${WECHAT_PAY_NOTIFY_URL}
public-key-path: ${WECHAT_PAY_PUBLIC_KEY_PATH}
public-key-id: ${WECHAT_PAY_PUBLIC_KEY_ID:}
h5-app-name: ${WECHAT_PAY_H5_APP_NAME:内推码Pro}
h5-app-url: ${WECHAT_PAY_H5_APP_URL:https://vip.offerjob.cn}

实际生产环境建议通过 production.env 注入,不要把 AppSecretAPIv3 Key、私钥写进仓库。私有仓库也不建议放密钥,因为之后很容易复制、迁移、开源或泄露。

SDK 初始化

一开始只初始化了 NativePayService,后来扩展为三个服务:

1
2
3
4
this.nativePayService = new NativePayService.Builder().config(config).build();
this.jsapiService = new JsapiServiceExtension.Builder().config(config).build();
this.h5Service = new H5Service.Builder().config(config).build();
this.notificationParser = new NotificationParser((NotificationConfig) config);

这里还有一个细节:我们使用的是“微信支付公钥模式”,需要配置:

  • 商户号 mchId
  • 商户私钥 apiclient_key.pem
  • 商户证书序列号
  • 微信支付公钥文件 pub_key.pem
  • 微信支付公钥 ID,形如 PUB_KEY_ID_xxx
  • APIv3 Key

新商户很多时候已经不是老的“平台证书自动下载”模式了,优先确认商户平台 API 安全里实际给你的是什么。

订单创建流程

后端创建订单时先落库,再调用微信预下单:

1
2
3
4
5
6
7
8
9
10
11
12
MemberOrder order = MemberOrder.builder()
.outTradeNo(outTradeNo)
.userId(userId)
.planId(String.valueOf(plan.getId()))
.channel(ch)
.description(plan.getDescription())
.amount(payableAmount)
.durationDays(plan.getDurationDays())
.status("PENDING")
.createTime(LocalDateTime.now())
.build();
memberOrderMapper.insert(order);

为什么先落库?因为微信回调、主动查单、前端轮询都需要通过 outTradeNo 找到本地订单。如果先调用微信再落库,极端情况下回调可能先到,反而查不到订单。

下单返回值统一带上:

1
2
3
4
data.put("payType", type);
data.put("outTradeNo", outTradeNo);
data.put("amount", payableAmount);
data.put("planName", plan.getDescription());

再根据支付方式补充具体字段:

1
2
3
4
5
6
7
8
if ("jsapi".equals(type)) {
data.put("jsapi", createJsapiPrepay(props, plan, outTradeNo, payableAmount, openid.trim()));
} else if ("h5".equals(type)) {
data.put("h5Url", createH5Prepay(props, plan, outTradeNo, payableAmount, clientIp));
} else {
PrepayResponse response = wechatPayConfig.nativePay().prepay(request);
data.put("codeUrl", response.getCodeUrl());
}

JSAPI 支付的关键:openid

微信内网页 JSAPI 支付必须要 openid,而这个 openid 必须来自同一个公众号 AppID。

前端发起支付时,如果当前在微信里打开,就先走网页授权:

1
2
3
4
5
6
7
8
9
function redirectWechatOauth() {
const url = new URL(WECHAT_PAY_REDIRECT_ORIGIN)
const redirectUri = encodeURIComponent(url.toString())
const appId = encodeURIComponent(WECHAT_PAY_APP_ID)
window.location.href =
`https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code&scope=snsapi_base&state=${WX_AUTH_STATE}#wechat_redirect`
}

这里用的是 snsapi_base,它是静默授权,只用于拿 openid,不会弹出用户信息授权页。

后端用微信回传的 codeopenid

1
2
3
4
5
6
7
8
9
URI uri = UriComponentsBuilder
.fromHttpUrl("https://api.weixin.qq.com/sns/oauth2/access_token")
.queryParam("appid", props.getAppId())
.queryParam("secret", props.getAppSecret())
.queryParam("code", code)
.queryParam("grant_type", "authorization_code")
.build()
.encode()
.toUri();

拿到 openid 后再调用 JSAPI 下单:

1
2
3
4
5
Payer payer = new Payer();
payer.setOpenid(openid);
request.setPayer(payer);
request.setAppid(props.getAppId());
request.setMchid(props.getMchId());

前端拿到后调起微信支付:

1
2
3
4
5
6
WeixinJSBridge.invoke('getBrandWCPayRequest', params, (res) => {
const msg = String(res?.err_msg || '')
if (msg.includes(':ok')) {
// 支付成功后继续轮询本地订单
}
})

H5 支付的处理

微信外的手机浏览器不在微信容器里,不能调用 WeixinJSBridge,这时走 H5 支付。

后端 H5 下单需要传 scene_info

1
2
3
4
5
6
7
8
SceneInfo sceneInfo = new SceneInfo();
sceneInfo.setPayerClientIp(clientIp);

H5Info h5Info = new H5Info();
h5Info.setType("Wap");
h5Info.setAppName(props.getH5AppName());
h5Info.setAppUrl(props.getH5AppUrl());
sceneInfo.setH5Info(h5Info);

前端拿到 h5Url 后跳转:

1
2
3
4
function withH5Redirect(h5Url: string) {
const separator = h5Url.includes('?') ? '&' : '?'
return `${h5Url}${separator}redirect_url=${encodeURIComponent(window.location.href)}`
}

跳走前要把订单号存在 sessionStorage,用户从收银台回跳后继续轮询:

1
2
3
4
5
6
sessionStorage.setItem(H5_PENDING_KEY, JSON.stringify({
outTradeNo: res.outTradeNo,
amount: res.amount,
planName: res.planName,
createdAt: Date.now()
}))

回调和轮询

支付成功最终以微信异步回调为准:

1
2
3
4
Transaction t = wechatPayConfig.notificationParser().parse(requestParam, Transaction.class);
if (t.getTradeState() == Transaction.TradeStateEnum.SUCCESS) {
self.grant(t.getOutTradeNo(), t.getTransactionId());
}

同时保留前端轮询和主动查单。这样即使微信回调延迟或丢失,用户前端也有机会触发后端主动向微信查单并补发会员:

1
2
3
4
Transaction t = wechatPayConfig.nativePay().queryOrderByOutTradeNo(q);
if (t.getTradeState() == Transaction.TradeStateEnum.SUCCESS) {
self.grant(outTradeNo, t.getTransactionId());
}

注意:查询订单接口在 SDK 里 Native/JSAPI/H5 都能查同一个商户订单,实际项目里继续复用 Native 的查询服务即可。

公众号后台和商户平台配置

这是最容易踩坑的部分。代码写完不代表能支付,后台配置必须全部对上。

公众号后台需要配置:

  • 公众号必须是已认证服务号。
  • 网页授权域名:填写 vip.offerjob.cn
  • JS接口安全域名:也建议填写 vip.offerjob.cn
  • 校验文件要能从根目录访问,例如 https://vip.offerjob.cn/MP_verify_xxx.txt

商户平台需要配置:

  • 当前公众号 AppID 必须和商户号绑定。
  • JSAPI 支付产品必须开通。
  • H5 支付产品必须开通。
  • H5 支付域名必须配置为实际站点域名。
  • 商户证书、公钥、APIv3 Key 要和同一个商户号对应。

这次最终卡住的一次错误是:

1
2
APPID_MCHID_NOT_MATCH
appid和mch_id不匹配,请检查后再试

含义非常明确:公众号 AppID 没有绑定当前商户号,或者用错了商户号/证书。解决方法是到商户平台把公众号 AppID 关联到商户号,或者换成该公众号实际绑定的那套商户号和证书。

常见错误记录

长按二维码或截图扫码被拦截

这是因为手机端误用了 Native 支付。Native 二维码适合 PC 展示给微信“扫一扫”,不适合手机网页长按识别。解决:微信内走 JSAPI,微信外手机浏览器走 H5。

此公众号没有这些 scope 的权限

通常是 AppID 用错了,或者公众号不支持网页授权。确认:

  • 用的是公众号/服务号 AppID,不是小程序 AppID。
  • 公众号是已认证服务号。
  • 使用 snsapi_base 获取 openid。

redirect_uri 域名与后台配置不一致,错误码 10003

说明公众号后台的“网页授权域名”和实际 redirect_uri 域名不匹配。注意不是 JS 接口安全域名。

正确配置类似:

1
网页授权域名 = vip.offerjob.cn

代码里最好把授权回调固定成稳定域名:

1
VITE_WECHAT_PAY_REDIRECT_ORIGIN=https://vip.offerjob.cn/

不要让它跟随当前页面地址,否则用户从别名域名、测试域名、带奇怪参数的页面进入时,很容易触发 10003。

发起支付失败,请重试

这个是我们后端统一包了一层友好错误。真正原因要看服务端日志,重点搜:

1
journalctl -u pushcode --since "30 minutes ago" --no-pager | grep -E "微信预下单失败|APPID|MCH|INVALID|支付" -C 4

如果看到 APPID_MCHID_NOT_MATCH,就去商户平台检查 AppID 和商户号绑定关系。

部署 Checklist

下次接入微信支付,可以按这个顺序来:

  1. 确认产品场景:PC Native、微信内 JSAPI、微信外 H5。
  2. 准备商户号、APIv3 Key、商户私钥、证书序列号、微信支付公钥/平台证书。
  3. 确认公众号 AppID 和商户号已绑定。
  4. 公众号后台配置网页授权域名和 JS 接口安全域名。
  5. 商户平台开通 JSAPI 支付、H5 支付,并配置 H5 支付域名。
  6. 把微信校验文件放到前端 public,部署后确认根路径可访问。
  7. 后端实现统一下单接口,按 payType 分流 Native/JSAPI/H5。
  8. 前端根据 UA 判断支付方式。
  9. JSAPI 前先走 OAuth snsapi_base 获取 openid
  10. H5 跳转前保存订单号,回跳后继续轮询。
  11. 回调验签解密后幂等发放权益。
  12. 前端轮询时保留主动查单兜底。

最终心得

微信支付真正麻烦的地方不在代码,而在“身份关系”:

  • AppID 属于公众号或小程序。
  • openid 只在对应 AppID 下有效。
  • mch_id 属于商户平台。
  • AppID 必须绑定到 mch_id 才能支付。
  • 商户证书、公钥、APIv3 Key 必须属于同一个商户号。
  • 网页授权域名、JS 安全域名、H5 支付域名是三套不同配置。

只要把这些关系理顺,代码反而比较直接。PC 用 Native,微信内用 JSAPI,微信外用 H5,这就是网页端微信支付最稳的接入方式。