☰
yansongda/pay 微信 Openapi 插件域与 OAuth 用户身份能力(v3.8)设计详解
2026/10/15 3:17:42 网站建设 项目流程
  • 后端
  • 金融科技

【免费下载链接】pay

可能是我用过的最优雅的 Alipay/WeChat/Unipay/江苏银行 的支付 SDK 扩展包了

项目地址:https://gitcode.com/yansongda/pay
点击查看免费下载

本篇基于仓库内设计文档 wechat-oauth-openapi.md 展开,讲解 pay v3.8 中新增的Plugin/Wechat/Openapi/插件域:如何在不影响纯支付用户的前提下,让Pay::wechat()->oauth()一次性覆盖公众号网页授权(web_token/refresh/userinfo)与小程序 code2session 登录。读完你会掌握:OAuth 四个_action的配置方式与调用链、GET 管道“业务插件自拼 URL query + 空 body”的关键契约、/sns/前缀路由到api.weixin.qq.com的实现,以及合并版Openapi\ResponsePlugin的 HTTP 状态码 + errcode 双层校验顺序论证。

一、背景与问题:为什么微信侧需要一个独立的 Openapi 插件域

v3.8(当前最新 tag v3.8.0-beta.5)之前,SDK 支付宝侧已具备用户身份能力(Plugin/Alipay/V2/Member/Authorization/*、DetailPlugin),而微信侧仅有支付能力。要补齐“获取微信用户身份”(公众号网页授权、小程序 code2session),设计文档指出了三个具体困境:

  1. 必须另装 easywechat:用户此前若想拿到微信用户身份,只能在 pay 之外再引入 easywechat——即 issue #788 的诉求,两年未闭环;
  2. Wechat::URL_VIRTUAL命名名不副实:微信虚拟支付模块已打通api.weixin.qq.com通路(getWechatUrl()对/xpay/、/cgi-bin/前缀返回虚拟支付基地址),但 OAuth 的/sns/*接口同样走api.weixin.qq.com,而现有路由不认/sns/前缀(已验证,见 WechatTrait::getWechatUrl());
  3. 管道对 GET 无 query 支持:AddPayloadBodyPlugin会无条件打包 body,而 OAuth 要求参数放在 URL query 中。

由此确立的设计目标:

  • 独立的 Openapi 插件域:承接api.weixin.qq.com上与具体支付业务解耦的通用能力;
  • 纯支付用户零影响:新增配置字段全部可选、不改既有管道行为;
  • 命名消除 token 歧义:按接口场景统一命名(web_token用户级、session会话级);
  • BC 变更明示且可控:按已批准决策执行,在 UPGRADE 文档记录。

二、整体方案:OAuth 走“自拼 URL query + 空 body”的 GET 管道

核心思路是新增Plugin/Wechat/Openapi/插件域;OAuth 走「业务插件自拼 URL query + 空 body」的 GET 管道,统一响应校验合并为一个ResponsePlugin。完整调用链如下:

Pay::wechat()->oauth(['_action' => 'session', 'js_code' => '...']) │ ▼ OauthShortcut(_action → 业务插件) ┌─────────────────────────────────────────────────────────────┐ │ StartPlugin → Oauth业务插件 → Wechat AddRadarPlugin → │ │ Openapi\ResponsePlugin → ParserPlugin │ └─────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ _url 自拼 /sns/**?query(含secret) getWechatUrl(): /sns/* → api.weixin.qq.com _body = ''(GET 无请求体)

这条管道与文档中 OauthShortcut 的实现逐位对应:四个_action分别装配StartPlugin → 业务插件 → AddRadarPlugin → ResponsePlugin → ParserPlugin五个插件,注意刻意不装AddPayloadBodyPlugin(原因见第四节)。

对应的文件结构(+新增~修改»迁移引用,均已在仓库中落地):

src/ ├── Provider/Wechat.php ~ URL_VIRTUAL → URL_OPENAPI ├── Traits/WechatTrait.php ~ /sns/ 分支 + 引用 GetStableTokenPlugin ├── Config/WechatConfig.php + mpAppSecret / miniAppSecret(可选) ├── Plugin/Wechat/Openapi/ + 新插件域 │ ├── GetStableTokenPlugin.php » Virtual/GetAccessTokenPlugin 更名迁入(/cgi-bin/stable_token) │ ├── ResponsePlugin.php » Virtual/CheckResponsePlugin 迁入 + 合并 HTTP 状态检查 │ └── Oauth/ │ ├── WebAccessTokenPlugin.php + /sns/oauth2/access_token(网页授权,用户级) │ ├── UserInfoPlugin.php + /sns/userinfo │ ├── RefreshTokenPlugin.php + /sns/oauth2/refresh_token(用户级) │ └── Code2SessionPlugin.php + /sns/jscode2session(会话级) ├── Shortcut/Wechat/OauthShortcut.php + 4 个 _action ├── Shortcut/Wechat/VirtualShortcut.php ~ 响应插件位合并 tests/ ~ 改 4 + 增 7 / 删 2(迁自 Virtual 域) web/docs/v3/upgrade/v3.8.md ~ BREAKING CHANGES 3 条 bullet web/docs/v3/wechat/oauth.md + 使用文档(独立页面,对齐 virtual.md 先例)

三、配置设计:两个可选 secret 字段,不入validateRequired()

3.1 配置格式

{ "wechat": { "default": { "mp_app_id": "公众号appid", "mp_app_secret": "公众号appsecret", "mini_app_id": "小程序appid", "mini_app_secret": "小程序appsecret" } } }
字段类型必填说明
mp_app_secret?string否仅web_token/refresh需要
mini_app_secret?string否仅session需要
  • 不加入validateRequired();secret 优先级为params 显式传入 → config(仿虚拟支付惯例)。

3.2 源码印证

WechatConfig 中新增了两个可空字段与对应 getter/setter:

private ?string $mpAppSecret = null; private ?string $miniAppSecret = null;

其 validateRequired() 只校验mchId、mchSecretKey、mchSecretCert、mchPublicCertPath四项(以及mch_secret_key必须 32 字节、服务商模式下sub_mch_id必填),两个 OAuth secret 完全不在其中——这正是“纯支付用户零影响”的落地方式:不配 secret,支付管道照常工作;配了 secret,OAuth 能力才可用。缺凭据的错误推迟到具体业务插件中抛出(见下一节的异常示例)。

四、GET 管道契约:为什么是“自拼 query +_body => ''+ 不装 AddPayloadBodyPlugin”

设计文档(3.2 节)给出两条已验证的关键契约:

  1. getWechatBody()要求_body必存(缺失抛PARAMS_WECHAT_BODY_MISSING),故业务插件自设_body => '',且不装AddPayloadBodyPlugin(否则会覆盖_body)——GET 请求体为空;
  2. _url以/sns/开头即可被新路由分支补全域名,业务插件拼 query 对齐Virtual/AddPayloadSignaturePlugin::appendQueryParams先例。

对照 WechatTrait 的实现:

public static function getWechatBody(?Collection $payload): mixed { $body = get_radar_body($payload); if (is_null($body)) { throw new InvalidParamsException(Exception::PARAMS_WECHAT_BODY_MISSING, '参数异常: 微信 `_body` 参数缺失:...'); } return $body; }

注意判断的是is_null($body)而非空串——业务插件写入_body => ''恰好绕过缺失检查,同时保证 GET 请求不携带请求体。以 Code2SessionPlugin 为例,其装配逻辑与设计文档伪代码一致:

$jsCode = $params['js_code'] ?? ''; if (empty($jsCode)) { throw new InvalidParamsException(Exception::PARAMS_NECESSARY_PARAMS_MISSING, '参数异常: 缺少微信小程序登录参数 -- [js_code]'); } $appid = $params['appid'] ?? $config->getMiniAppId(); $secret = $params['secret'] ?? $config->getMiniAppSecret(); if (empty($appid) || empty($secret)) { throw new InvalidConfigException(Exception::CONFIG_WECHAT_INVALID, '配置异常: 缺少微信配置 -- [mini_app_secret] 或 [mini_app_id]'); } $rocket->mergePayload([ '_method' => 'GET', '_url' => '/sns/jscode2session?'.http_build_query(['appid' => $appid, 'secret' => $secret, 'js_code' => $jsCode, 'grant_type' => 'authorization_code']), '_body' => '', ]);

可以看出参数解析顺序就是文档声明的「params 显式传入 → config」;js_code缺失抛InvalidParamsException,appid/secret 缺失抛InvalidConfigException,与文档 3.2 节伪代码中的异常契约一一对应。OauthShortcutTest 则从测试侧验证了四个_action各自的管道插件序列及非法_action抛PARAMS_SHORTCUT_ACTION_INVALID。

五、接口规格与关键契约

五个 Openapi 插件的接口规格(已验证:微信官方文档 + 存档快照;错误响应经真实 HTTP 实测):

插件请求query 参数
WebAccessTokenPluginGET/sns/oauth2/access_tokenappid、secret、code、grant_type=authorization_code
UserInfoPluginGET/sns/userinfoaccess_token、openid、lang(可选默认zh_CN)
RefreshTokenPluginGET/sns/oauth2/refresh_tokenappid、grant_type=refresh_token、refresh_token
Code2SessionPluginGET/sns/jscode2sessionappid、secret、js_code、grant_type=authorization_code
GetStableTokenPluginPOST/cgi-bin/stable_token(body JSON)grant_type=client_credential、appid、secret、force_refresh可选

以 code2session 为例的等价 curl 与成功响应:

curl 'https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code'
{ "openid": "xxx", "session_key": "xxx", "unionid": "xxx", "errcode": 0, "errmsg": "xxx" }

关键契约(这些细节直接决定上层代码怎么写):

  • 参数全小写(appid/js_code/grant_type);grant_type=client_credential为单数(官方原文);
  • 成功/失败不依赖 HTTP 状态码(实测错误也返回 HTTP 200),错误统一{"errcode": int, "errmsg": "..."},errmsg常带rid:<uuid>后缀——代码不得按 errmsg 全文匹配;
  • code2session成功响应自带errcode:0,与null !== errcode && 0 !== errcode判断兼容(已验证);
  • 公众号/小程序 appid+secret 是独立凭据对,不互通;unionid需开放平台绑定,不硬编码假设。

对照源码:UserInfoPlugin 中'lang' => $params['lang'] ?? 'zh_CN'正是“可选默认 zh_CN”的落地;RefreshTokenPlugin 中grant_type固定为refresh_token,且只需mp_app_id不需要 secret(与规格表一致);GetStableTokenPlugin 只负责声明_method => 'POST'与_url => '/cgi-bin/stable_token',body 由管道中的AddPayloadBodyPlugin打包(getWechatVirtualAccessToken()内部管道即此用法,见 WechatTrait)。

六、Openapi/ResponsePlugin:合并两层校验,行为顺序等价性论证

微信开放接口响应不在 HTTP Header 中携带签名,因此统一校验插件只检查两层:HTTP 状态码 + 业务 errcode。ResponsePlugin 的实现与设计文档伪代码一致:

if (!should_do_http_request($rocket->getDirection()) || is_null($rocket->getDestinationOrigin())) { return $rocket; // 原 CheckResponse 短路 } if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { throw new InvalidResponseException(Exception::RESPONSE_CODE_WRONG, '微信开放接口返回状态码异常,请检查参数是否错误', ...); // 原 Wechat\ResponsePlugin 职责 } if (null !== $errcode && 0 !== $errcode) { throw new InvalidResponseException(Exception::RESPONSE_BUSINESS_CODE_WRONG, '微信开放接口返回业务异常: '.$destination->get('errmsg'), ...); // 原 CheckResponse 职责 }

行为顺序论证(设计文档 3.4 节,已验证,读了Artful::artful()/Artful::ignite()、supportsPipeline、artfulParserPlugin与双侧插件源码):

  1. HTTP 请求由Artful::ignite()在管道最内层发出(Artful.php:264的->then(fn ($rocket) => self::ignite($rocket)),ignite内$http->sendRequest(...)并setDestination(clone $response)->setDestinationOrigin(...)——任何状态码(含 500)都会设置 destination);ParserPlugin只负责$direction->guide()解包响应体,不发请求。
  2. supportsPipeline为array_reduce(array_reverse($pipes), ...)洋葱模型(Pipeline.php:47-49):原管道数组[..., AddRadar, CheckResponse, Response, Parser]的弹栈顺序为 ignite(发 HTTP)→ Parser 后置 →Wechat\ResponsePlugin后置(HTTP 检查,先抛)→CheckResponsePlugin后置(errcode 检查,后抛)。故HTTP 状态检查先于 errcode 检查。
  3. 合并插件按「HTTP 先、errcode 后」顺序,与原管道一致;HTTP 检查抛异常时 errcode 检查不会执行(而非「HTTP 错误时无 destination」)。
  4. 已知理论边界(不可达,仅记录):direction 为NoHttpRequestDirection且 destinationOrigin 为非 2xxResponse时,原Wechat\ResponsePlugin(无should_do_http_request守卫)会抛RESPONSE_CODE_WRONG,合并版(守卫短路)不抛;SDK 内无产生该状态的代码路径。

连锁影响方面:VirtualShortcut::serverSidePlugins() 原来的CheckResponsePlugin + ResponsePlugin两个位置已合为Openapi\ResponsePlugin一个(管道插件数 8→7);存量测试testHttpStatusCodeErrorStillChecksErrcode的断言由RESPONSE_BUSINESS_CODE_WRONG改为RESPONSE_CODE_WRONG(单插件直测视角下 HTTP 检查先执行),并新增管道级用例(mock HTTP 500 + errcode body →RESPONSE_CODE_WRONG)验证真实弹栈中 HTTP 检查先抛;errcode 业务异常路径由单插件用例(HTTP 200 + errcode 43001 →RESPONSE_BUSINESS_CODE_WRONG)覆盖。getWechatVirtualAccessToken()内部管道仅换类引用(GetStableTokenPlugin),其Wechat\ResponsePlugin不动。

异常文案变化('微信虚拟支付返回业务异常'/'微信返回状态码异常'→'微信开放接口返回业务异常'/'微信开放接口返回状态码异常')作为 BC 申报进 UPGRADE bullet;异常码(RESPONSE_CODE_WRONG/RESPONSE_BUSINESS_CODE_WRONG)不变。

七、兼容性设计(BC 清单)与getWechatUrl()新路由

变更类型影响面(已验证)
Wechat::URL_VIRTUAL删除 →URL_OPENAPIchange定义 1 处 + 引用 1 处
Virtual\GetAccessTokenPlugin→Openapi\GetStableTokenPluginchangeWechatTrait 2 处 + 1 测试
Virtual\CheckResponsePlugin→Openapi\ResponsePlugin(合并)changeVirtualShortcut 1 处 +tests/Provider/WechatTest.php10 处(4 用例)+tests/Stubs/Plugin/VirtualCheckResponsePluginStub.php(extends)+ 1 测试
其余 Virtual 业务插件(AddPayloadSignaturePlugin、Order/Currency/Goods/Subscribe/Withdraw)不动语义与虚拟支付业务绑定
docs/fix-1186.md与docs/learning/fix-1186.md旧类名字符串不动历史设计记录

路由改名后,Wechat::URL_OPENAPI 定义为'https://api.weixin.qq.com',getWechatUrl() 的新路由规则为:

http 开头 → 原样 | requestVirtualPayment → 原样 /xpay/ | /cgi-bin/ | /sns/ → URL_OPENAPI + url ← 新增 /sns/ 其余 → URL[mode] + url

源码中该分支已按设计落地(WechatTrait.php#L65-L68):

// 微信开放接口(虚拟支付 /xpay/*、接口凭据 /cgi-bin/*、OAuth /sns/*)使用 api.weixin.qq.com,非 api.mch.weixin.qq.com if (str_starts_with($url, '/xpay/') || str_starts_with($url, '/cgi-bin/') || str_starts_with($url, '/sns/')) { return Wechat::URL_OPENAPI.$url; }

/sns/前缀由此被纳入开放接口路由,消除了URL_VIRTUAL名不副实的问题;而/xpay/、/cgi-bin/的既有行为保持不变。

八、使用示例:四个_action的调用与返回

_action常量定义在 WechatAction(web_token/refresh/userinfo/session),入口为 Wechat 提供者 上声明的oauth(array $order)快捷方法(通过__call路由到OauthShortcut)。配置与调用示例:

// 仅小程序登录(session):需 mini_app_id + mini_app_secret Pay::config(['wechat' => ['default' => [ 'mch_id' => ..., 'mch_secret_key' => ..., 'mch_secret_cert' => ..., 'mch_public_cert_path' => ..., 'mini_app_id' => 'wx...', 'mini_app_secret' => '...', ]]]); // 网页授权(web_token/refresh):需另配 mp_app_id + mp_app_secret;按 3.1 字段表,缺对应凭据会抛 InvalidConfigException Pay::wechat()->oauth(['_action' => 'session', 'js_code' => '...']); Pay::wechat()->oauth(['_action' => 'web_token', 'code' => '...']); Pay::wechat()->oauth(['_action' => 'userinfo', 'access_token' => '...', 'openid' => '...']); Pay::wechat()->oauth(['_action' => 'refresh', 'refresh_token' => '...']);

各 action 的返回字段(详见 oauth.md):

  • session:openid(用户唯一标识)、session_key(会话密钥,用于数据签名校验与用户敏感数据解密,请勿下发给前端之外的任何端)、unionid(需绑定微信开放平台,未绑定时无该字段);
  • web_token:access_token(网页授权接口调用凭证,用户级,与基础access_token即stable_token应用级凭证互相隔离)、expires_in(有效期,秒)、refresh_token、openid、unionid(scope 为snsapi_userinfo时返回)、is_snapshotuser(快照页模式虚拟账号时返回,值为 1,此时 openid 为虚拟账号);
  • userinfo:openid、nickname、sex(1 男性,2 女性,0 未知)、province、city、country(如中国为 CN)、headimgurl、privilege(JSON 数组)、unionid(需绑定开放平台);
  • refresh:新的access_token、expires_in、refresh_token、openid、scope(逗号分隔的授权作用域)。

九、风险与对策、监控可观测性

风险严重度对策
secret 进 URL query +AddRadarPlugin日志打印 rocket(_url含 secret)中仓库无脱敏先例(已验证),沿用 virtual.md 惯例:oauth.md 醒目风险提示;_body=''已消除 body 侧暴露
BC 变更影响自维护插件的用户中UPGRADE/v3.8.md 三条change:bullet + issue #788 回复
快照用户is_snapshotuser=1时 openid 为虚拟账号低oauth.md 明确提示
code一次性且 5 分钟过期(官方原文)低文档注明,业务侧责任
errmsg带rid:后缀低代码不匹配全文,仅透传
userinfo需snsapi_userinfo授权否则 48001低文档标注授权要求
session_key敏感性低文档警示

此外注意:开启 Artful 日志(logger.enable = true)时,日志会包含带 secret 的 URL,生产环境需注意日志安全。SDK 库无独立监控通道,沿用 Artful Logger 现有 debug/info 通道(每个 Openapi 插件都有成对的Logger::debug/Logger::info装配日志,如 Code2SessionPlugin);secret 落盘风险由文档提示覆盖。

十、推进策略与仓库内验证路径

设计文档规划的单 PR 三阶段(逐阶段可验证、整体可 revert):

  1. 迁移与路由:URL_OPENAPI+/sns/分支 +GetStableTokenPlugin/ResponsePlugin迁入更名 + UPGRADE bullet → 验证:存量测试全绿;
  2. 新增能力:WechatConfig 两字段 + Oauth 4 插件 + OauthShortcut + 单测 → 验证:composer test绿;
  3. 文档:oauth.md + sidebar 注册 → 验证:docs build。

回滚方式为git revert整 PR。仓库中可对照验证的路径:

  • 插件实现:src/Plugin/Wechat/Openapi/(含Oauth/四个业务插件);
  • 入口与路由:OauthShortcut、WechatTrait::getWechatUrl()、Wechat::URL_OPENAPI;
  • 配置:WechatConfig(mpAppSecret/miniAppSecret字段与可选性);
  • 测试:OauthShortcutTest、tests/Plugin/Wechat/Openapi/(GetStableTokenPluginTest、ResponsePluginTest、Oauth/四个业务插件用例);
  • 用户文档:web/docs/v3/wechat/oauth.md。

小结:这套设计用“新增插件域 + 可选配置 + 精确路由分支”的三板斧,把一个拖了两年、诉求明确的功能(issue #788)以最小 BC 代价落进了既有管道;其最有复用价值的两处经验是——GET 场景下“业务插件自拼 query 并显式置空_body、绕开AddPayloadBodyPlugin”的契约写法,以及“合并响应校验插件时必须以管道洋葱弹栈顺序论证行为等价性”的方法论。

  • 后端
  • 金融科技

【免费下载链接】pay

可能是我用过的最优雅的 Alipay/WeChat/Unipay/江苏银行 的支付 SDK 扩展包了

项目地址:https://gitcode.com/yansongda/pay
点击查看免费下载

相关推荐

上一篇:阿里云盘批量重命名终极指南:告别手动操作,效率提升500%
下一篇:百度网盘下载神器揭秘:5个技巧让你的下载速度飞起来!

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询