微信支付服务商模式:只改签名密钥就报 PARAM_ERROR,sp_appid 和 sub_appid 下的 openid 怎么配对
一个直连商户支付模块切到服务商模式(普通服务商 + 特约商户)后,只改了签名密钥,下单全部返回 PARAM_ERROR。根因不在签名:服务商模式多了一套平行的 ID,商户号分服务商和子商户,AppID 也分服务商和子商户,openid 跟着 AppID 走,两个 AppID 下的 openid 不是一回事。
接入背景
直连模式只有一组:appid + mchid + openid。服务商模式的参数名带 sp_/sub_ 前缀:
sp_appid:服务商在开放平台/公众平台申请的 AppIDsp_mchid:服务商商户号sp_openid:用户在该sp_appid下的标识sub_appid:子商户的 AppID(选填)sub_mchid:子商户号(必填)sub_openid:用户在该sub_appid下的标识
下单接口:POST /v3/pay/partner/transactions/jsapi(小程序同一个地址)。文档:微信支付服务商 JSAPI 下单。
核心规则:appid 和 openid 必须成对
官方文档的原话:
- 使用
sp_appid时传payer.sp_openid,openid 必须由sp_appid换取 - 使用
sub_appid时传payer.sub_openid,openid 必须由sub_appid换取 payer.sp_openid和payer.sub_openid二选一必填- 若传
sub_openid,则sub_appid必填
工程上常见三种错:
- 小程序
wx.login拿 code,用子商户小程序换 openid,却填进payer.sp_openid→ 「appid与openid不匹配」 - 传了
sub_appid但填了sp_appid换来的 openid → 「sub_appid和sub_openid不匹配」 - 只传
payer.openid(照抄直连模式字段名)→ 参数校验失败或 openid 无效
取舍:sub_appid 到底传不传
场景 A:收款方是子商户,但用户在你自己的小程序/公众号里下单(平台型 SaaS 常见)。不传 sub_appid,传 sp_appid + payer.sp_openid,sub_mchid 照样必填。钱进子商户账户,用户身份挂在你自己的 AppID 下。好处是只要维护一套登录/openid;代价是子商户看不到自己品牌 AppID 下的用户,营销触达能力归你。
场景 B:用户从子商户自己的小程序进来。sub_appid 必传,payer.sub_openid 必须是从这个 sub_appid 换来的。这要求 sub_appid 已在服务商平台与 sub_mchid 绑定,否则报「sub_mch_id与sub_appid不匹配」。代价是每个子商户要单独绑 AppID、单独做网页授权域名配置,运营成本随商户数线性增长。
能用自己的 AppID 收就用 sp_appid + sp_openid,把 sub_appid 当作「必须支持子商户自有小程序」时才付的成本。别为了对齐字段两边都传——两者都传时以哪个为准,官方没有承诺,也别赌。
下单请求示例
curl -s -X POST https://api.mch.weixin.qq.com/v3/pay/partner/transactions/jsapi \
-H "Authorization: WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\",nonce_str=\"...\",signature=\"...\",timestamp=\"...\",serial_no=\"...\"" \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{
"sp_appid": "wx服务商appid",
"sp_mchid": "1900000001",
"sub_mchid": "1900000109",
"description": "年度会员",
"out_trade_no": "T20261024001",
"notify_url": "https://example.com/pay/notify/wx",
"amount": {"total": 100, "currency": "CNY"},
"payer": {"sp_openid": "oXXXX"}
}'
注意点:
- 签名用的是服务商商户号的 API 证书私钥与序列号,不是子商户的。回调验签/解密用的也是服务商维度配置的 APIv3 密钥。
amount.total单位是分,整数。prepay_id有效期 2 小时;过期要用原out_trade_no和原参数重新下单,不是换个单号。- 不传
time_expire默认 7 天有效,超期订单需关单。
调起支付:appId 必须和下单时一致
前端 wx.requestPayment 的 appId 要填下单时实际用的那个 appid(sp_appid 或 sub_appid),而且当前实际调起支付的公众号/小程序必须就是它。填错报「商户传入的APPID参数不正确」。package 的值是 "prepay_id=" + 下单返回的 prepay_id,前缀不能省。
支付授权目录在服务商平台配置(可以给全体子商户配,也可以给单个子商户配),网页授权域名在公众平台配——这两个是两个地方,只配一个就会出现「能调起来但拿不到 openid」或者「拿到 openid 但调不起来」。paySign 的构造与排查站内 #8142 已单独写过,这里不重复。
报错对照表
| 报错原文 | 出现阶段 | 实际原因 |
|---|---|---|
| PARAM_ERROR 受理机构必须传入sub_mch_id | 下单 | 用的是服务商商户号却调了普通商户模式接口,路径少了 /partner |
| appid和mch_id不匹配,请检查后再试 | 下单 | sp_appid 未与服务商商户号绑定 |
| sub_mch_id与sub_appid不匹配 | 下单 | sub_appid 未在服务商平台与 sub_mchid 绑定 |
| appid与openid不匹配 / sub_appid和sub_openid不匹配 | 下单 | openid 与本次使用的 appid 不配对 |
| 无效的openid | 下单 | openid 不存在(常见于串了测试号或存储被截断) |
| 下单账号与支付账号不一致,请核实后再支付 | 调起 | 下单的 openid 属于 A 用户,实际支付的是 B 用户(同一浏览器切换账号、或前端缓存了旧 openid) |
最后一条最容易被误判成签名问题。它是用户维度的校验,跟证书没关系。
回调里怎么取用户标识
支付成功通知是 TRANSACTION.SUCCESS,resource.ciphertext 用 APIv3 密钥 AES-256-GCM 解密后,字段是 sp_appid / sp_mchid / sub_appid / sub_mchid / out_trade_no / transaction_id / trade_state / payer 等。关键:payer 下面有两个 key,sp_openid 和 sub_openid。文档把 sp_openid 标为必填、sub_openid 标为选填,且写明「若下单未传 sub_appid 参数,则不会返回」。
所以不要在回调里写死取 payer.sub_openid,也不要默认 payer.openid 存在——服务商模式回调日志里没有 payer.openid 这个 key(那是直连模式的字段)。稳妥做法:用 out_trade_no 回本地订单表取本单实际使用的 appid 和 openid,回调里的 openid 只用来做一致性校验,不一致就告警不要直接改库。
适用范围
以上规则适用于普通服务商 + JSAPI/小程序支付的 v3 接口。电商收付通(平台商户)、境内服务商跨境、以及 v2 的统一下单在字段命名上有差异,不要直接套。文中字段与报错文案摘自微信支付合作伙伴文档(下单接口 4012738519、支付结果通知 4012090195、常见问题 4013334850/4013352071),未在真实商户号上逐条复现;报错文案以你实际收到的响应为准,微信会改字。
数据模型:openid 不是用户主键
踩过的坑基本都是因为表结构假设了「一个用户一个 openid」。openid 是 AppID 维度的,同一个微信用户在不同 AppID 下是不同的字符串。服务商模式下这个维度至少有三个:你的 sp_appid、子商户的 sub_appid、以及未来可能接入的其它小程序。
最小可用结构:
CREATE TABLE user_wechat (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '本地用户',
appid VARCHAR(32) NOT NULL COMMENT 'openid 所属 AppID',
openid VARCHAR(128) NOT NULL,
unionid VARCHAR(128) NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_appid_openid (appid, openid),
KEY idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单表另存 pay_appid 和 pay_openid(下单时使用的,不是查询时再算),以及 sp_mchid/sub_mchid 两个商户号。这样对账、退款、分账时不用再猜这单是哪个 AppID 下的用户,也不会出现「退款退给错误的 openid」这种不可逆事故。