yansongda/pay 支付 Provider 代码评审实战指南:七阶段检查清单、安全审查要点与标准报告模板
yansongda/pay 支付 Provider 代码评审实战指南:七阶段检查清单、安全审查要点与标准报告模板
本指南源自 yansongda/pay 开源仓库中的
.agents/skills/pr-review-provider/SKILL.md,该文件沉淀自 Airwallex PR #1140 的真实 Review 经验。文章以该检查清单为骨架,结合仓库源码逐一印证每个检查项背后的实现事实,帮助你在为 yansongda/pay 新增或修改支付 Provider(如 Airwallex、Alipay、Wechat、Douyin、Unipay、Jsb、Paypal、Stripe)时,完成一次覆盖结构、规范、安全、架构、文档与测试的完整 Code Review。
在 yansongda/pay 这类以"插件管道"驱动的支付 SDK 中,新增一个支付 Provider 绝非只写几个支付方法那么简单:它牵涉插件按版本组织的目录结构、Provider 类与服务提供者的注册、快捷方式的命名、Trait 方法的下沉复用、Webhook 验签的安全性,以及文档和测试的同步更新。任何一环缺失,都可能在生产环境引入多租户配置错乱、签名伪造或回调解析失败等隐患。
读完本文,你将掌握一套可直接照做的七阶段评审流程(结构完整性 → 代码规范 → 安全性 → 架构一致性 → 官方文档对照 → 测试覆盖 → 文档完整性),识别 params 与 payload 混淆、null 空字段泄漏、Webhook 未验签等高风险问题,并能套用仓库内建的标准 Review 报告模板输出评审结论。
一、Review 规范(强制要求)
在执行任何评审动作之前,有两个强制规范必须遵守,它们直接关系到协作礼仪与报告的可追溯性。
1. 提交评论前必须征求用户许可
绝对禁止:未经用户明确允许,不得使用 gh pr comment 或其他方式向 PR 提交任何评论。
正确流程:
- 完成 review 分析;
- 向用户展示 review 报告内容;
- 询问用户:"是否需要我将此报告提交为 PR 评论?"
- 仅在用户明确确认后才执行提交。
2. 报告末尾必须注明使用的模型及审核状态
在 review 报告的结论部分之后,必须添加以下信息:
---
*Review by {模型名称} | {YYYY-MM-DD} | 经人工审核确认*
示例:
---
*Review by deepseek-v4-pro | 2026-05-07 | 经人工审核确认*
二、Review 流程总览
按以下阶段顺序审查,确保覆盖完整:
- Phase 1:Provider 结构完整性
- Phase 2:代码规范检查
- Phase 3:安全性检查(签名验证、加密解密)
- Phase 4:架构一致性(管道、Trait、Event)
- Phase 5:官方文档对照
- Phase 6:测试覆盖
- Phase 7:文档完整性
三、Phase 1:Provider 结构完整性
新增或修改 Provider 时,首先对照以下清单逐项检查,确认从插件到文档的完整链路都已就位。清单中的"位置"列给出了各组成部分在仓库中的标准落点:
| # | 检查项 | 位置 | 说明 |
|---|---|---|---|
| 1 | 插件 | src/Plugin/{Provider}/V{n}/ |
按版本组织 |
| 2 | Provider 类 | src/Provider/{Provider}.php |
实现 ProviderInterface |
| 3 | 服务提供者 | src/Service/{Provider}ServiceProvider.php |
服务注册 |
| 4 | 快捷方式 | src/Shortcut/{Provider}/ |
{Method}Shortcut.php |
| 5 | Trait 方法 | src/Traits/{Provider}Trait.php |
get{Provider}Url、verify{Provider}WebhookSign 等 |
| 6 | Provider 注册 | src/Pay.php |
添加 {Provider}::class 和入口方法 |
| 7 | 异常常量 | src/Exception/Exception.php |
PARAMS_{PROVIDER}_*、CONFIG_{PROVIDER}_* |
| 8 | 测试 | tests/ |
与源码结构对应 |
| 9 | 文档 | web/docs/v3/{provider}/ |
VitePress 文档 |
| 10 | 侧边栏/CHANGELOG | web/.vitepress/sidebar/v3.js、CHANGELOG.md |
更新配置 |
以 Stripe 为例,仓库中各个组成部分的落点与清单完全对应:
- 插件:
src/Plugin/Stripe/V1/下按Pay/子目录组织(PayPlugin、CallbackPlugin 等,另有 AddRadarPlugin、ResponsePlugin); - Provider 类:src/Provider/Stripe.php 实现
ProviderInterface; - 服务提供者:src/Service/StripeServiceProvider.php,其
makeService()返回new Stripe(); - 快捷方式:src/Shortcut/Stripe/ 下有
CancelShortcut、IntentShortcut、QueryShortcut、RefundShortcut、WebShortcut; - Trait:src/Traits/StripeTrait.php 提供
getStripeUrl()、verifyStripeWebhookSign(); - Provider 注册:src/Pay.php 的
$providers数组中登记了StripeServiceProvider::class,Pay::__callStatic()支持Pay::stripe()静态入口; - 异常常量:src/Exception/Exception.php 中的
PARAMS_STRIPE_*、CONFIG_STRIPE_*等; - 测试:tests/Plugin/Stripe/ 与源码结构一一对应;
- 文档:web/docs/v3/stripe/ 下的
pay.md、callback.md、query.md、refund.md等。
注意:src/Functions.php 已不存在,URL/签名等方法已下沉到 src/Traits/*Trait.php。评审时不要依据旧版目录结构找文件。
四、Phase 2:代码规范检查
基本规范
| 检查项 | 要求 |
|---|---|
declare(strict_types=1) |
每个文件必须有 |
use 导入 |
除动态类名字符串/反射场景外,禁止直接写完整命名空间(如 \Yansongda\Pay\...) |
| 多行条件 | && / ` |
以 src/Plugin/Stripe/V1/AddRadarPlugin.php 为例,文件首行即 declare(strict_types=1);,所有 Yansongda\Pay\... 均通过 use 导入;仅当构造动态类名时才允许字符串形式的完整命名空间,例如 src/Provider/Stripe.php 中的 '\Yansongda\Pay\Shortcut\Stripe\\'.Str::studly($shortcut).'Shortcut',这正是"动态类名字符串"场景下的合法例外。
命名规范
| 类型 | 格式 | 示例 |
|---|---|---|
| 插件 | {Action}Plugin.php |
PayPlugin、RefundPlugin |
| 快捷方式 | {Method}Shortcut.php |
WebShortcut、QueryShortcut |
| Provider | {ProviderName}.php |
Paypal.php、Stripe.php |
| ServiceProvider | {ProviderName}ServiceProvider.php |
PaypalServiceProvider.php |
| Trait | {Provider}Trait.php |
WechatTrait、StripeTrait |
| 命名空间 | Yansongda\Pay\Plugin\{Provider}\V{n}\Pay\{Plugin} |
版本号与 API 版本一致 |
日志与异常
- 日志格式:
<a href="https://link.gitcode.com/i/327a3619c1c917a754dcb7ed4dd69da1" target="_blank">Provider][V{n}][Category][Plugin],使用中文消息。例如 [CallbackPlugin 中的Logger::debug('[Stripe][V1][Pay][CallbackPlugin] 插件开始装载', ...); - 异常常量:
PARAMS_{PROVIDER}_*、CONFIG_{PROVIDER}_*; - 异常消息:中文,附带上下文参数。例如 StripeTrait 中配置缺失时抛出
InvalidConfigException(Exception::CONFIG_STRIPE_INVALID, '配置异常: 缺少 Stripe 配置 -- [webhook_secret]'),并把headers、body等上下文随异常携带。
五、Phase 3:安全性检查
安全性是支付 Provider 评审的重中之重,SKILL 将其细分为五个检查点,其中前两点直接决定多租户安全与报文质量。
1. params vs payload 混淆(高危)
高危:Artful::artful() 第二参数必须是 params(含 _config),不能是 payload。
// ❌ 错误 — payload 不含 _config,多租户必崩
$result = Artful::artful([...], $confirmPayload);
// ✅ 正确 — params 含 _config 租户标识
$result = Artful::artful([...], $confirmParams);
检查方法:搜索 Artful::artful( 调用,追踪第二参数来源。
这一检查项在仓库中有清晰的实现依据:src/Traits/ProviderConfigTrait.php 的 getTenant() 从 $params<a href="https://link.gitcode.com/i/3e24558333e3daabaebc04f7ef411290" target="_blank">'_config'] 读取租户标识(缺省为 'default'),getProviderConfig() 再用 "{$provider}.{$tenant}" 从容器配置中取出对应租户的配置对象并执行 validate()。如果第二参数误传成不含 _config 的 payload,租户解析将永远命中 default,多租户场景下必崩。这也是为什么 [StripeProvider 的 callback 方法 在调用 pay() 时显式传入 ['_request' => $request, '_params' => $params] 这一"params 形态"的数组。
2. 空值处理
检查最终发送的 body/query 是否包含不应出现的 null/空字段。
推荐方式:
- 优先使用
filter_params()函数(artful 库提供); - 嵌套数组场景补充
array_filter()。
// ✅ 优先方式 — filter_params
$body = http_build_query(filter_params($payload)->toArray());
// ✅ 嵌套场景 — array_filter
$rocket->mergePayload(array_filter([
'application_context' => $payload->get('application_context'),
], static fn ($value) => !is_null($value)));
检查位置:AddRadarPlugin::getBody()、getQueryString()、业务 Plugin 的 mergePayload()。
仓库实现印证:src/Plugin/Stripe/V1/AddRadarPlugin.php 的 getBody() 与 getQueryString() 均使用 http_build_query(filter_params($payload)->toArray()),GET 请求自动过滤空参数拼进 query string,非 GET 请求则将过滤后的参数编码为 application/x-www-form-urlencoded 的 body——这正是文档所推荐写法的标准落地。
3. Webhook 签名验证
安全强制:所有 CallbackPlugin 必须验签。
各 Provider 的验签方法及必需配置字段对照表:
| Provider | Trait 方法 | 必需配置字段 |
|---|---|---|
| Stripe | StripeTrait::verifyStripeWebhookSign() |
webhook_secret |
| PayPal | PaypalTrait::verifyPaypalWebhookSign() |
webhook_id + OAuth |
| 微信 | WechatTrait::verifyWechatSign() |
mch_secret_cert、wechat_public_cert_path(可预置或运行时拉取) |
| 抖音 | —(在 CallbackPlugin::verifySign() 中实现) |
mch_secret_token、mch_secret_salt |
| 银联 | UnipayTrait::verifyUnipaySign() |
unipay_public_cert_path |
| 支付宝 | AlipayTrait::verifyAlipaySign() |
alipay_public_cert_path |
检查点:
- CallbackPlugin 是否调用 Trait 提供或 Plugin 自身实现的签名验证方法;
- 签名算法是否与官方文档一致(对照
@see链接); - 配置缺失时抛异常;
- 签名为空时抛异常;
- 使用
hash_equals防时序攻击。
仓库源码中的 Trait 验签方法均可逐一对号入座:AirwallexTrait::verifyAirwallexWebhookSign()、AlipayTrait::verifyAlipaySign()、DouyinTrait::verifyDouyinTradeSign()、JsbTrait::verifyJsbSign()、PaypalTrait::verifyPaypalWebhookSign()、StripeTrait::verifyStripeWebhookSign()、UnipayTrait::verifyUnipaySign() 均定义于 src/Traits/ 下。
以 Stripe 为例,verifyStripeWebhookSign() 完整实现了上述检查点:读取 webhook_secret 缺失即抛 CONFIG_STRIPE_INVALID;Stripe-Signature 头为空抛 SIGN_EMPTY;解析 t= 时间戳与 v1= 签名列表,时间戳与当前时间差超过 300 秒判定超时;随后计算 hash_hmac('sha256', $timestamp.'.'.$body, $webhookSecret) 并与每个候选签名做 hash_equals 常量时间比较,全部不匹配才抛 SIGN_ERROR。对应的 CallbackPlugin 在验签后还会对 body 做 JSON 合法性校验(JSON_ERROR_NONE !== json_last_error() 时抛 PARAMS_STRIPE_BODY_INVALID)。
4. 数组回调的处理
仅适用于 Stripe/Wechat/Paypal(构造 ServerRequest 并验签):
getCallbackParams() 处理逻辑:
| 输入类型 | 行为 |
|---|---|
['body' => ..., 'headers' => ...] |
构造带 headers 的 ServerRequest,会验签 |
| 纯数组(无 headers) | 构造无 headers 的 ServerRequest,验签时会抛 SIGN_EMPTY |
ServerRequestInterface |
直接使用,验签 |
null |
从 ServerRequest::fromGlobals() 获取 |
结论:数组回调只有提供完整 headers + body 才能通过验签;否则验签阶段抛异常。
Alipay/Douyin/Unipay 不同:
getCallbackParams()返回Collection(从 query/parsedBody 取值);- 直接 merge 到 params,不构造
ServerRequest; - CallbackPlugin 从 params 中取值验签。
仓库实现印证:Stripe 的 getCallbackParams() 正是按此逻辑分支——带 body + headers 的数组构造 ServerRequest('POST', 'http://localhost', $headers, $body);纯数组构造无 headers 的请求(后续验签因 Stripe-Signature 头为空必然抛 SIGN_EMPTY);ServerRequestInterface 直接透传;null 则回退到 ServerRequest::fromGlobals()。
5. 加密资源解密(微信)
微信回调的 resource 字段需解密:
$body['resource'] = self::decryptWechatResource($body['resource'] ?? [], $config);
检查 CallbackPlugin 是否调用此方法(对应 WechatTrait::decryptWechatResource(),位于 src/Traits/WechatTrait.php)。
六、Phase 4:架构一致性
1. 插件管道骨架
通用骨架:
StartPlugin → [前置插件] → 业务插件 → [后置插件] → ParserPlugin
各 Provider 差异:
| Provider | 前置插件 | 后置插件 |
|---|---|---|
| Stripe | 无 | AddRadarPlugin → ResponsePlugin |
| PayPal | ObtainAccessTokenPlugin |
AddPayloadBodyPlugin → AddRadarPlugin → ResponsePlugin |
| 微信 | 无 | AddPayloadBodyPlugin → AddPayloadSignaturePlugin → AddRadarPlugin → VerifySignaturePlugin → ResponsePlugin |
| 支付宝 | 无 | FormatPayloadBizContentPlugin → AddPayloadSignaturePlugin → AddRadarPlugin → VerifySignaturePlugin → ResponsePlugin |
注意:不同 Provider 管道差异较大,不要按固定模板审查。
仓库中的 Stripe 快捷方式 WebShortcut 即为该骨架的实证:StartPlugin → WebPlugin → AddRadarPlugin → ResponsePlugin → ParserPlugin,与表格中"Stripe 无前置插件、后置为 AddRadarPlugin → ResponsePlugin"完全一致。
2. mergeCommonPlugins 实现
Provider 类必须实现此方法,返回完整管道:
public function mergeCommonPlugins(array $plugins): array
{
return array_merge(
[StartPlugin::class, /* 前置插件 */],
$plugins,
[/* 后置插件 */, ParserPlugin::class],
);
}
3. Trait 方法复用
新增 Provider 应复用 Trait 方法而非重新实现:
| 功能 | Trait 方法 |
|---|---|
| URL 构建 | get{Provider}Url() |
| 签名验证 | verify{Provider}WebhookSign() / verify{Provider}Sign() |
| 配置获取 | ProviderConfigTrait::getProviderConfig()、getTenant() |
| 加密解密 | WechatTrait::decryptWechatResource() |
例如 StripeTrait::getStripeUrl() 先调用 ProviderConfigTrait::getRadarUrl() 解析 _url/_sandbox_url/_service_url,缺失时抛 PARAMS_STRIPE_URL_MISSING(提示"你可能用错插件顺序,应该先使用业务插件"),再按模式拼上 Stripe::URL 中的 Base URL——这正是"URL 构建走 Trait"的落地样例。
4. Provider 常量定义
必须定义 URL 常量:
public const URL = [
Pay::MODE_NORMAL => 'https://api.xxx.com/',
Pay::MODE_SANDBOX => 'https://sandbox.api.xxx.com/',
Pay::MODE_SERVICE => 'https://api.xxx.com/',
];
特殊常量(如微信):
AUTH_TAG_LENGTH_BYTEMCH_SECRET_KEY_LENGTH_BYTE
仓库实证:src/Provider/Stripe.php 定义了 URL 常量,三种模式(Pay::MODE_NORMAL/MODE_SANDBOX/MODE_SERVICE,常量定义见 src/Pay.php)当前均指向 https://api.stripe.com。
5. Event 调用
callback 方法必须触发事件:
// Stripe/Wechat/Paypal(ServerRequestInterface)
Event::dispatch(new CallbackReceived('provider', clone $request, $params, null));
// Alipay/Douyin/Unipay/Jsb(Collection)
Event::dispatch(new CallbackReceived('provider', $request->all(), $params, null));
其他方法触发:
Event::dispatch(new MethodCalled('provider', __METHOD__, $order, null));
仓库实证:Stripe 的 callback() 在验签前先 Event::dispatch(new CallbackReceived(Pay::PROVIDER_STRIPE, clone $request, $params, null))(事件类见 src/Event/CallbackReceived.php),query()/cancel()/refund() 则触发 MethodCalled(src/Event/MethodCalled.php)。
6. PHPStan ignore 注释
Trait 静态方法调用需添加注释:
/* @phpstan-ignore-next-line */
self::verifyWechatSign(...);
这是 PHPStan 对 Trait 静态调用的已知限制,非代码质量问题。仓库中同样出现在 CallbackPlugin 的 self::verifyStripeWebhookSign(...) 调用之前。
七、Phase 5:官方文档对照
结合代码中的 @see 链接验证:
| # | 检查点 |
|---|---|
| 1 | API 端点 URL 是否与官方一致 |
| 2 | HTTP 方法(GET/POST)是否正确 |
| 3 | 认证 Header 名称、格式是否正确 |
| 4 | 请求/响应字段(必填/可选)是否正确 |
| 5 | 签名算法、拼接顺序是否与官方完全一致 |
| 6 | Base URL(production/sandbox)是否正确 |
例如 Stripe 的 AddRadarPlugin::getHeaders() 构造 Authorization: Bearer {secret_key}、Accept: application/json,非 GET 请求追加 Content-Type: application/x-www-form-urlencoded,并支持通过 _headers 注入 Idempotency-Key、Stripe-Version 等自定义请求头覆盖默认值——这些细节都需要在评审时与 Stripe 官方 API 文档逐一核对。
八、Phase 6:测试覆盖
| # | 检查项 |
|---|---|
| 1 | 每个 Plugin 有对应测试 |
| 2 | 必填参数缺失的异常测试 |
| 3 | 可选参数缺失的边界测试 |
| 4 | 多租户场景(_config 参数) |
| 5 | HTTP client mock,禁止真实 API 调用 |
| 6 | Callback 签名验证的正向/反向测试 |
仓库中 tests/Plugin/Stripe/V1/Pay/ 等测试目录与源码结构严格对应,CallbackPluginTest、AddRadarPluginTest 等即对应上述检查项 1;测试基类 tests/TestCase.php 与 phpunit.xml 提供了可离线运行的测试环境。
九、Phase 7:文档完整性
| # | 检查项 |
|---|---|
| 1 | 官方链接可访问且指向正确端点 |
| 2 | 示例代码使用原生 PHP,框架无关 |
| 3 | 侧边栏已更新 |
| 4 | CHANGELOG 已更新 |
仓库内对应文档位于 web/docs/v3/stripe/、web/docs/v3/wechat/ 等,每个 Provider 均包含 pay.md、callback.md、query.md、refund.md 等章节;变更记录见 CHANGELOG.md。
十、常见误报:null-safe 操作符
评审中容易把以下写法误判为 bug:
$payload?->get('a', $payload->get('b'))
PHP 8.0 的 null-safe 实现了 full short-circuiting:当 $payload 为 null 时,右侧参数不会被求值。这不是 bug。
(依据:PHP RFC nullsafe_operator,仓库要求 PHP >= 8.0。)
十一、避免重复造轮子
检查新增功能是否在 yansongda/supports 或 yansongda/artful 中已有实现:
| 功能 | 已有实现 |
|---|---|
| UUID 生成 | Str::uuidV4() |
| 字符串处理 | Str::* |
| 集合操作 | Collection::* |
| 空值过滤 | filter_params() (artful) |
| HTTP 方法获取 | get_radar_method() (artful) |
| Body 获取 | get_radar_body() (artful) |
仓库中 AddRadarPlugin 正是通过 use function Yansongda\Artful\filter_params; 等方式直接复用 artful 提供的工具函数,没有在业务代码里重复实现。
十二、Review 报告模板
以下为提交 review 时的标准报告格式,评审完成后按此结构整理输出:
# PR #{number} Code Review — {Provider} Provider
## 一、总览
| 维度 | 内容 |
|------|------|
| PR | #{number} — {title} |
| 涉及文件 | {n} 个 |
| Review 范围 | 全量代码 + 文档 + 测试 |
| 方法 | 逐文件审查 + 官方文档对照 + 跨 Provider 一致性比对 |
## 二、已确认 Bug(含证据链)
### BUG-{n}: {简要描述}
**严重级别**:{高/中高/中/低}
**位置**:`{file_path}` L{line}
**问题**:
{详细描述问题本质}
**证据链**:
1. 代码现状:`{相关代码片段}`
2. 预期行为:{应该怎样}
3. 实际行为:{实际会怎样}
4. 影响范围:{哪些场景会触发}
**修复建议**:
```php
// 修复方案
(重复以上格式,每个 Bug 单独一节)
三、已排除的误报
{误报描述}
结论:非 Bug
原因:{详细解释,附 RFC/文档链接}
四、风险项
| # | 风险 | 严重级别 | 文件 | 说明 |
|---|---|---|---|---|
| RISK-{n} | {风险名} | {级别} | {file} |
{说明} |
五、改进建议
| # | 建议 | 优先级 | 文件 | 说明 |
|---|---|---|---|---|
| SUG-{n} | {建议名} | {级别} | {file} |
{说明} |
六、官方文档对照验证
| 功能 | 代码中的 URL/算法 | 官方文档 | 是否一致 |
|---|---|---|---|
| {功能名} | {代码实现} |
官方文档 | ✅/❌ |
七、代码规范检查
| 检查项 | 状态 | 备注 |
|---|---|---|
declare(strict_types=1) |
✅/❌ | |
use 导入(无内联命名空间) |
✅/❌ | |
日志格式 [Provider][V{n}][Category][Plugin] |
✅/❌ | |
| 异常常量命名 | ✅/❌ | |
| Plugin/Shortcut 命名规范 | ✅/❌ | |
| 测试覆盖 | ✅/❌ |
八、汇总
| 类别 | 数量 | 详情 |
|---|---|---|
| 已确认 Bug | {n} | {简要列举} |
| 已排除误报 | {n} | {简要列举} |
| 风险项 | {n} | {简要列举} |
| 改进建议 | {n} | {简要列举} |
九、结论
{总体评价:代码质量、架构一致性、安全性等。明确给出 Approve / Request Changes 建议及原因。}
Review by {模型名称} | {YYYY-MM-DD} | 经人工审核确认
---
## 结语
至此,一套完整的支付 Provider 评审方法论已经清晰:从结构完整性、代码规范,到高危的多租户 `params` 安全、Webhook 验签,再到管道架构、测试与文档,每个阶段都有仓库源码(<a href="https://link.gitcode.com/i/fec5d6b6b4d3e5c27a76e737a6940c2e" target="_blank">src/</a>、<a href="https://link.gitcode.com/i/dcfb278663856c58c716b14250e7885e" target="_blank">tests/</a>、<a href="https://link.gitcode.com/i/176651921e6bef31a3361ea670cec3b3" target="_blank">web/docs/</a>)中的真实实现可作对照证据。评审者只需按七阶段顺序推进,将发现的问题填入标准报告模板,即可产出一份结构一致、证据链完整、可直接提交到 PR 的评审结论。这套清单同样适用于自研支付渠道的代码自检——在合入之前,先让自己的 Provider 通过这份检查表。