- 后端
- 金融科技
【免费下载链接】pay
可能是我用过的最优雅的 Alipay/WeChat/Unipay/江苏银行 的支付 SDK 扩展包了
本篇基于仓库内设计文档 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),设计文档指出了三个具体困境:
- 必须另装 easywechat:用户此前若想拿到微信用户身份,只能在 pay 之外再引入 easywechat——即 issue #788 的诉求,两年未闭环;
Wechat::URL_VIRTUAL命名名不副实:微信虚拟支付模块已打通api.weixin.qq.com通路(getWechatUrl()对/xpay/、/cgi-bin/前缀返回虚拟支付基地址),但 OAuth 的/sns/*接口同样走api.weixin.qq.com,而现有路由不认/sns/前缀(已验证,见 WechatTrait::getWechatUrl());- 管道对 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 节)给出两条已验证的关键契约:
getWechatBody()要求_body必存(缺失抛PARAMS_WECHAT_BODY_MISSING),故业务插件自设_body => '',且不装AddPayloadBodyPlugin(否则会覆盖_body)——GET 请求体为空;_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 参数 |
|---|---|---|
WebAccessTokenPlugin | GET/sns/oauth2/access_token | appid、secret、code、grant_type=authorization_code |
UserInfoPlugin | GET/sns/userinfo | access_token、openid、lang(可选默认zh_CN) |
RefreshTokenPlugin | GET/sns/oauth2/refresh_token | appid、grant_type=refresh_token、refresh_token |
Code2SessionPlugin | GET/sns/jscode2session | appid、secret、js_code、grant_type=authorization_code |
GetStableTokenPlugin | POST/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与双侧插件源码):
- HTTP 请求由
Artful::ignite()在管道最内层发出(Artful.php:264的->then(fn ($rocket) => self::ignite($rocket)),ignite内$http->sendRequest(...)并setDestination(clone $response)->setDestinationOrigin(...)——任何状态码(含 500)都会设置 destination);ParserPlugin只负责$direction->guide()解包响应体,不发请求。 - supports
Pipeline为array_reduce(array_reverse($pipes), ...)洋葱模型(Pipeline.php:47-49):原管道数组[..., AddRadar, CheckResponse, Response, Parser]的弹栈顺序为 ignite(发 HTTP)→ Parser 后置 →Wechat\ResponsePlugin后置(HTTP 检查,先抛)→CheckResponsePlugin后置(errcode 检查,后抛)。故HTTP 状态检查先于 errcode 检查。 - 合并插件按「HTTP 先、errcode 后」顺序,与原管道一致;HTTP 检查抛异常时 errcode 检查不会执行(而非「HTTP 错误时无 destination」)。
- 已知理论边界(不可达,仅记录):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_OPENAPI | change | 定义 1 处 + 引用 1 处 |
Virtual\GetAccessTokenPlugin→Openapi\GetStableTokenPlugin | change | WechatTrait 2 处 + 1 测试 |
Virtual\CheckResponsePlugin→Openapi\ResponsePlugin(合并) | change | VirtualShortcut 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):
- 迁移与路由:
URL_OPENAPI+/sns/分支 +GetStableTokenPlugin/ResponsePlugin迁入更名 + UPGRADE bullet → 验证:存量测试全绿; - 新增能力:WechatConfig 两字段 + Oauth 4 插件 + OauthShortcut + 单测 → 验证:
composer test绿; - 文档: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 扩展包了
相关推荐
微信 Openapi 插件域与 OAuth 用户身份:yansongda/pay 公众号网页授权与小程序登录集成指南
微信 Openapi 插件域与 OAuth 用户身份:yansongda/pay 公众号网页授权与小程序登录集成指南 本文基于 yansongda/pay 的
金融科技后端Pay 微信 OAuth 用户身份能力实战指南:公众号网页授权与小程序登录
Pay 微信 OAuth 用户身份能力实战指南:公众号网页授权与小程序登录 本文以 yansongda/pay (本项目,仓库根目录为 gh_mirrors/p
金融科技后端yansongda/pay 微信插件全指南:从内置插件到自定义组合调用微信支付 API
yansongda/pay 微信插件全指南:从内置插件到自定义组合调用微信支付 API 本文以 yansongda/pay 开源支付 SDK 的插件机制为线索,
金融科技后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考