聚合支付 平台开发文档
适用对象:维护和扩展本平台的开发者。 版本:2026-11-07,基于当前代码整理。商户对接口径见 aggpay-merchant-api.md;设计原稿见 aggpay-design.md(部分细节已过时,以本文和代码为准);各渠道联调状态见 aggpay-handoff.md。
1. 技术栈与运行形态
| 项 | 选型 |
|---|---|
| 后端 | PHP 8.3,webman 2.1(workerman,阻塞事件循环,无协程),Webman Workbench + Admin Preset |
| 数据库 | MySQL 8.4,Phinx 迁移 |
| 渠道 SDK | yansongda/pay ~3.7.20(微信支付 v3、支付宝),HTTP 用 Guzzle 8 |
| 后台前端 | Vben Admin(Vue 3 + Naive UI),frontend/admin-web-vben |
| 进程 | webman HTTP 进程(WEBMAN_HTTP_WORKERS)+ aggpay-worker 后台任务进程 |
渠道 SDK 的配置是进程全局状态,当前靠“每个进程一次只处理一个请求”保证不串账号。改用协程事件循环(Swoole/Swow)前必须重新设计
PaySdk,见 5.2。
2. 目录结构
app/modules/aggpay/
├── module.php 模块声明
├── api/
│ ├── controller/
│ │ ├── MerchantOrderController 商户开放 API /api/aggpay/v1/*
│ │ ├── ChannelNotifyController 渠道回调 /api/aggpay/notify/{channel}/{account}(POST 通知 / GET 地址校验)
│ │ └── CashierController 收银台 /api/aggpay/cashier/*
│ ├── middleware/MerchantSignatureMiddleware 商户签名认证 + 响应签名
│ └── request/ 商户接口参数校验
├── admin/
│ ├── controller/ 后台接口 /admin-api/aggpay/*(全部 Permission + Audit)
│ └── request/ 后台参数校验
├── channel/ 渠道层(只依赖契约,不碰数据库事务)
│ ├── ChannelAdapter / CashierAdapter / NotifyEndpointVerifier 契约
│ ├── ChannelRegistry / ChannelResult / ChannelNotification / ChannelAccount
│ ├── PaymentContext / RefundContext / ChannelTransportException / ChannelUrls
│ ├── MockChannelAdapter 沙箱渠道
│ ├── WechatPay{Adapter,Client,Credentials} 微信支付 v3
│ ├── Alipay{Adapter,Client,Credentials} 支付宝
│ ├── WechatXpayAdapter, Xpay{Client,Credentials,Push} 小程序虚拟支付
│ └── sdk/ yansongda/pay 接入层(PaySdk、私有容器、严格验签插件)
├── service/ 业务层(事务、状态、授权)
│ ├── PaymentService 下单 / 查单 / 关单 / 补单
│ ├── RefundService 退款
│ ├── SettlementService 订单与退款状态机的唯一写入口
│ ├── ChannelGateway 服务层访问渠道的唯一通道
│ ├── ChannelNotifyService 渠道回调处理
│ ├── CashierService 收银台
│ ├── NotificationDispatcher 商户通知投递
│ ├── MaintenanceService 后台任务入口
│ └── …Admin / Merchant* / Operator* 后台业务
├── repository/ 查询、行锁、CAS、商户范围
├── model/ Eloquent 模型
├── migrations/ Phinx 迁移
├── process/AggpayWorker.php 后台任务进程
├── support/ 配置、时钟、错误码、签名、加密、输入校验等
└── config/ 菜单、权限
app/command/AggpayTickCommand.php php webman aggpay:tick
frontend/admin-web-vben/apps/webman-admin/src/modules/aggpay/ 后台页面
docs/ 文档;aggpay-cashier-miniprogram/ 收银台小程序示例
.planning/aggpay-mvp/scratch/ 验收脚本、假渠道服务器(本地,不入库)
分层规则:Controller → Request(校验)→ Service(状态、事务、授权)→ Repository(查询、锁、范围)→ Model。Controller 不写业务,Repository 不调渠道,渠道层不开事务。
3. 核心规则(改代码前必读)
这些规则是资金正确性的基础,改动前要能说明为什么不违反它们。
| # | 规则 | 落地位置 |
|---|---|---|
| 1 | 金额一律整数分,禁止浮点 | Fee、渠道适配器内的元/分换算 |
| 2 | 时间一律 UTC 存储,取时间只用 Clock |
support/Clock.php |
| 3 | 订单/退款状态只能经 SettlementService 推进,且必须是 CAS(WHERE status = ? 并检查影响行数),同一事务写事件、账本、商户通知 |
SettlementService、OrderRepository::transition |
| 4 | 渠道调用不得放在数据库事务里:先提交本地状态,再调渠道 | PaymentService::requestPayAction、RefundService::apply |
| 5 | 渠道结果只有确定的成功/失败才改状态;网络错误、5xx、验签失败、未知错误码一律 UNKNOWN,交给恢复任务重查。宁可晚确认,不可错判 |
ChannelGateway::call、各适配器 |
| 6 | 入账前核对:渠道账号 = 订单快照账号,金额 = 订单金额;不符只记 payment_rejected 事件 |
SettlementService::markPaid |
| 7 | 已关闭/失败的订单收到付款,不复活,只记 late_payment 事件待人工退款 |
SettlementService::markPaid |
| 8 | 未验签的渠道回调不落库、不推进状态 | ChannelNotifyService::handle |
| 9 | 商户只能访问自己的数据:商户 API 用 merchant_app_id 过滤;后台商户操作员由 Repository 统一加 merchant_id 范围 |
MerchantScope、各 Repository |
| 10 | 密钥与凭证只以 SecretBox(AES-256-GCM)加密存库,日志不记录;渠道凭证只在带审计的 GET channel-accounts/{id}/credential(权限 aggpay:channel:credential)回显,列表/详情不回显 |
SecretBox、ChannelGateway 日志只记类名 |
| 11 | 外呼商户通知必须过 SSRF 防护(白名单、公网地址、固定 DNS、不跟随重定向) | NotifyUrlGuard、NotificationDispatcher |
| 12 | 新增开关默认关闭(fail closed) | AggpayConfig |
4. 主要流程
4.1 下单
MerchantSignatureMiddleware 验签(先于解析 body)→ MerchantContext
CreateOrderRequest 字段白名单校验
PaymentService::create
├─ 已有同 out_trade_no:比对 request_hash → 返回原单 / 409
├─ route():按 priority 升序取第一个“启用 + 渠道可交易”的产品路由
├─ 校验 adapter->requirements()(如 openid、client_ip、goods_id)
├─ 事务:锁商户行 → 单笔/日限额 → 插入订单 + created 事件
└─ 事务外 requestPayAction()
├─ CashierAdapter 产品:生成一次性令牌,pay_action = 拉起收银台参数(不调渠道)
└─ 其他:ChannelGateway::create → PENDING 存 pay_action / FAILED 标记失败 / UNKNOWN 返回 502
request_hash 的字段在 PaymentService::requestHash()。新增参与幂等的下单字段时只能“存在才追加”,否则历史订单的摘要会全部失效。
4.2 渠道回调
ChannelNotifyController headers 统一小写;额外放入伪头 ':query'(原始 query string)
ChannelNotifyService::handle
├─ 渠道存在、账号存在且属于该渠道、body ≤ 64 KB
├─ adapter->parseNotification():验签、解密,返回 ChannelNotification
├─ verified = false → 直接应答失败(不落库)
└─ 事务:记录 (channel_account_id, dedupe_key)(重复即直接应答成功)→ markPaid / markClosed / markRefund*
adapter->acknowledge(bool) 渠道要求的应答格式
适配器把
$headers转成 PSR-7 请求或交给 SDK 前,要过滤掉非法头名(如:query),参考WechatPayClient::callback()。
4.3 收银台(CashierAdapter)
渠道实现 CashierAdapter 后,其 cashierProducts() 中的产品走收银台流程:
- 下单不调渠道,
cashierLaunchAction()生成拉起参数;令牌只存 SHA-256(aggpay_cashier_sessions)。 /cashier/prepare:令牌 → 订单(必须 created 且未过期)→cashierIdentify()换付款人身份 → 首个付款人 CAS 绑定(OrderRepository::bindPayer)→PaymentContext::withPayer()→createPayment()返回调起参数。session_key只放在PaymentContext内存中,不落库、不进日志(#[\SensitiveParameter]+__debugInfo)。
4.4 退款
申请时在事务内锁订单、校验可退余额、增加 refunding_amount 并插入 pending 退款,提交后调渠道。RefundContext::refundedBefore = 其他退款已退或已预留的金额(虚拟支付的 left_fee 依赖它)。pending 且无渠道退款号的退款,同步任务会用同一退款单号重新提交,所以渠道侧退款接口必须对退款单号幂等。
退款可带 complaint_id(商户 API 的 complaint_no):SettlementService::markRefundSucceeded 在同一事务里把投诉的 refund_report_status 置为 pending 并累加 refund_report_amount,由后台任务在事务外回报渠道。
4.5 投诉
渠道投诉通知 ─> parseNotification 返回 KIND_COMPLAINT(只带渠道投诉单号)
─> ChannelNotifyService::complaint ─> ComplaintService::syncFromChannel
├─ ComplaintAdapter::fetchComplaint(事务外,向渠道取已验证的数据;取不到则应答失败让渠道重推)
└─ ingest(事务):按 (channel_account_id, channel_complaint_id) 锁行
├─ 新投诉:生成 complaint_no,按渠道商户单号 = 本平台 order_no 且同一渠道账号归属到订单/商户
│ → complaint_received 事件 + complaint.created 商户通知
└─ 已有投诉:字段有变化才 version+1 并发 complaint.updated(resource_no = complaint_no-version)
- 通知只是信号,内容一律以
fetchComplaint的结果为准;对不上本平台订单(单号不存在、渠道账号不同、渠道交易号不同)的投诉只对平台可见,merchant_id为空。 - 投诉人手机号由适配器解密后,用
SecretBox加密存库;只在商户/complaints/query与后台单独的/phone接口(独立权限 + 审计)返回,商户通知、列表、后台详情都不带。 - 回复、处理完成:
ChannelRejectedException→ 409 / 40903(渠道明确拒绝),其他异常 → 502(结果未知,可重试)。成功后尽力再同步一次。 - 退款回报:
reportRefunds只回报problem_type = refund的投诉,其他类型记skipped;适配器reportComplaintRefund返回false(渠道自己看得到退款,如虚拟支付)也记skipped;finishRefundReport用“状态仍为 pending 且金额未变”做 CAS,回报过程中又有新退款成功时不会丢失。 - 新渠道接投诉:实现
ComplaintAdapter,并在parseNotification里把投诉事件识别为KIND_COMPLAINT。核心服务无需修改。
4.6 后台任务
AggpayWorker(config/process.php 注册)与 php webman aggpay:tick --job=<name> 走同一套 MaintenanceService:
| job | 周期 | 内容 |
|---|---|---|
dispatch |
2s | 投递商户通知(租约 60s,退避 15s→24h,10 次后 dead) |
sync_orders |
30s | created、创建超过 60s、未过期的订单向渠道查单 |
expire_orders |
30s | 过期订单:先查单,未付再关单。每次尝试记录 last_synced_at,结果未知的订单 60 秒内不重试并排到队尾,避免一批卡住的订单挡住新过期的订单 |
sync_refunds |
30s | pending 重新提交 / processing 查询 |
sync_complaints |
300s | 向支持投诉的渠道账号补拉近 3 天投诉;刷新超过 10 分钟未同步的未完结投诉;回报待回报的投诉退款 |
purge_nonces |
600s | 清理过期 nonce |
prune_channel_stats |
3600s | 清理 7 天前的渠道调用计数(aggpay_channel_call_stats,支付工作台「渠道健康」用) |
全部基于租约或 CAS,多进程、多机器同时运行结果一致。
5. 渠道层
5.1 契约
interface ChannelAdapter
{
public function code(): string; // 渠道代码,与注册表键一致
public function products(): array; // 产品代码(全局唯一,一个产品只属于一个渠道)
public function supportsMode(string $mode): bool; // sandbox / production
public function requirements(string $product): array; // 下单额外必填:client_ip / openid / goods_id
public function validateConfig(array $config): ?string; // 非敏感配置校验,返回错误或 null
public function validateCredential(array $credential): ?string; // 凭证校验(加密前)
public function createPayment(PaymentContext $c): ChannelResult; // PENDING + payAction / FAILED / UNKNOWN
public function queryPayment(PaymentContext $c): ChannelResult; // SUCCESS(含渠道单号、金额) / PENDING / CLOSED / UNKNOWN
public function closePayment(PaymentContext $c): ChannelResult; // CLOSED / SUCCESS(已付) / UNKNOWN
public function refund(RefundContext $c): ChannelResult; // SUCCESS / PENDING / FAILED / UNKNOWN
public function queryRefund(RefundContext $c): ChannelResult;
public function parseNotification(array $headers, string $rawBody, ChannelAccount $account): ChannelNotification;
public function acknowledge(bool $accepted): array; // ['status' => int, 'headers' => [], 'body' => string]
}
可选能力接口:
CashierAdapter:收银台流程(4.3)。ComplaintAdapter:渠道有消费者投诉系统(4.5),包括取单个投诉、列表、回复、处理完成、回报退款和注册通知地址。ChannelFormFields:声明后台“渠道账号”表单的配置项和密钥项(标签、必填、是否保密、是否多行、提示、下拉选项),用FormField::text/secret/pem/select/notifyBase构造。后台据此生成输入框,运营不需要手写 JSON;没实现的渠道回退为 JSON 输入。它只管展示,校验仍由validateConfig/validateCredential负责。NotifyEndpointVerifier:渠道要求先 GET 校验回调地址(如小程序消息推送 echostr)。没实现该接口的渠道,回调地址上的 GET 请求按通知处理(如哆啦宝),查询串以headers[':query']传给parseNotification。
实现约定:
- 业务结果映射为
ChannelResult的 outcome;传输异常、验签失败、未知响应直接抛ChannelTransportException,ChannelGateway会转成UNKNOWN并只记录异常类名。 ChannelGateway::call()每次适配器调用后按分钟桶累加aggpay_channel_call_stats(ok = 确定结果,rejected = FAILED,unknown = 适配器返回 UNKNOWN,error = 抛异常;实盘开关拦截的不计)。写入是单条 upsert,失败只记日志,不影响支付流程。只覆盖 create / query / close / refund / query_refund;投诉、收银台的渠道调用不经网关,不计入。数据供支付工作台(/aggpay/dashboard,权限aggpay.dashboard)的「渠道健康」使用。- 一律用平台
order_no/refund_no作为渠道侧商户单号,让重试在渠道侧天然幂等。 - 能确认的失败才返回
FAILED:失败会释放额度或把订单标记为 failed,误判就是资损或客诉。拿不准时返回UNKNOWN。 parseNotification验签失败返回ChannelNotification::rejected(),不要抛异常。回调只是信号时(如虚拟支付),应在适配器内主动查单确认后再返回SUCCESS。- 金额必须来自已验签的渠道数据;渠道不返回金额时返回
null,markPaid会因金额缺失而拒绝入账(如果渠道确实不给,需要像虚拟支付那样以签名绑定的价格兜底,并在代码中写明原因)。
5.2 SDK 接入层(channel/sdk/)
用 yansongda/pay 的渠道必须通过 PaySdk::run($provider, $tenantConfig, $callable, $testBase) 调用:
- 每次只装入一个账号的配置,结束即清空;使用私有容器
SdkContainer,不挂到框架全局容器。 - SDK 的默认验签/响应插件不安全(错误响应不验签、支付宝对重新编码的 JSON 验签),微信、支付宝分别用
WechatStrictResponsePlugin、AlipayStrictResponsePlugin替换。新接 SDK 渠道(如银联)也要先读 SDK 源码确认验签时机,再决定是否替换。 - 测试端点:
TestEndpointClient只替换 scheme/host/port,路径和签名不变;只有对应*_ALLOW_TEST_ENDPOINT=1时才会传入$testBase。 - SDK 日志保持关闭(会输出完整报文和密钥)。
5.3 新增一个渠道:步骤
以“某支付 foopay”为例:
- 凭证类
channel/FoopayCredentials.php:fromAccount()、configError()、credentialError();敏感字段#[\SensitiveParameter],实现__debugInfo()只暴露 id。回调地址用ChannelUrls::notifyUrl($base, 'foopay', $accountId),测试端点用ChannelUrls::testBase($config, AggpayConfig::foopayAllowTestEndpoint())。 - 客户端
channel/FoopayClient.php:签名、发请求、验证每一个响应的签名;网络错误、非预期 HTTP、验签失败抛ChannelTransportException。 - 适配器
channel/FoopayAdapter.php implements ChannelAdapter, ChannelFormFields,在类注释里写清楚:产品、outcome 映射规则、哪些字段未与官方文档核实;formFields()列出凭证类注释里的每个配置项和密钥项。 - 注册:
AggpayConfig::channels()加'foopay' => FoopayAdapter::class;需要的开关在AggpayConfig中新增,默认关闭。 - 下单新字段(如有):
CreateOrderRequest校验 → 迁移加列 →Order模型$fillable→OrderRepository::formatItem→PaymentService::create写入 →PaymentContext→requestHash按“存在才追加”。 - 测试:写假服务器与验收脚本(第 7 节),覆盖开关 on/off 两个阶段,再回归全部已有套件。
- 文档:更新本文、
aggpay-merchant-api.md的产品与pay_action表、aggpay-handoff.md的配置表与联调状态。
核心服务、状态机、后台页面一般不需要改:后台渠道下拉和产品列表来自 /admin-api/aggpay/channel-accounts/options 返回的渠道目录。
5.4 现有渠道速览
| 渠道 | 产品 | 账号模式 | 要点 |
|---|---|---|---|
mock |
mock_qr、mock_h5 |
sandbox | 需 AGGPAY_MOCK_ENABLED=1;渠道侧状态在 aggpay_mock_trades / aggpay_mock_complaints;后台可“沙箱模拟付款”“沙箱模拟投诉”;支持投诉 |
wechat |
wechat_native/h5/jsapi/app |
production | 微信支付 v3 直连;只接受账号配置的平台公钥序列号;回调 5 分钟时间窗;支持消费者投诉 2.0(路径来自 SDK,返回字段与回报退款请求体未核实),投诉通知与支付共用回调地址,需在后台“注册投诉通知地址” |
alipay |
alipay_qr/wap/page/app |
production;sandbox 需开关 | 公钥模式或证书模式;原始字节验签;扫码单过期后才关闭 |
duolabao |
duolabao_qr、duolabao_wx_jsapi |
production(哆啦宝无沙箱) | 京东收银 / 哆啦宝商户接口,自行签名(SHA1)。回调是 GET 且签名不覆盖参数,只作信号,以“支付结果查询”为准;无退款通知,退款结果用 /use/{refundRequestNum} 查询;部分退款最多两次;支付链接无有效期,过期后本地关单。未文档化项见 DuolabaoAdapter 类注释 |
wechat_xpay |
wechat_xpay_goods |
production;sandbox 需开关 | 收银台 + 小程序虚拟支付道具直购;推送只作信号,以 query_order 为准;字段已对照官方文档核实(“订单不存在”= 268490002 + errmsg「数据不存在」,沙箱实测);投诉按官方文档接入、未实盘验证,仅 production 账号(投诉接口在 sandbox 也返回现网数据),无回报退款与注册通知地址接口 |
各渠道 config / 凭证字段见对应 *Credentials.php 顶部注释。
6. 后台
6.1 接口与权限
后台接口前缀 /admin-api/aggpay,用 #[AdminApi] + #[Permission] 声明;写操作带 Audit 且不记录载荷。
| 资源 | 接口 | 查看权限 | 写权限 |
|---|---|---|---|
| 商户 | /merchants |
aggpay.merchants |
aggpay:merchant:create/update |
| 商户应用 | /apps |
aggpay.apps |
aggpay:app:create/update/rotate |
| 渠道账号 | /channel-accounts |
aggpay.channels |
aggpay:channel:create/update/credential |
| 产品路由与费率 | /products |
aggpay.products |
aggpay:product:create/update |
| 订单 | /orders |
aggpay.orders |
aggpay:order:sync/close/mock_pay、aggpay:refund:create |
| 退款 | /refunds |
aggpay.refunds |
aggpay:refund:sync |
| 投诉 | /complaints |
aggpay.complaints |
aggpay:complaint:sync/respond/complete、aggpay:complaint:phone(查看手机号,审计)、aggpay:complaint:register_notify(仅平台)、aggpay:order:mock_complaint(沙箱) |
| 商户通知 | /notifications |
aggpay.notifications |
aggpay:notification:retry |
| 商户操作员 | /operators |
aggpay.operators |
aggpay:operator:create/delete |
- 菜单、权限码定义在
config/menus/*.php、config/permissions/*.php(文件内用闭包返回,不要用顶层变量,否则会覆盖聚合器的$items)。 - 绑定到商户的后台用户(
aggpay_merchant_operators)只能看本商户数据,且不能访问平台级配置;未绑定的管理员按平台范围处理,给普通员工分配 aggpay 权限前先确认其范围。 - 新增接口或菜单后运行
admin-preset:check,要求 0 错误 0 警告。
6.2 前端
页面在 frontend/admin-web-vben/apps/webman-admin/src/modules/aggpay/。路由契约要求模块 index.ts 中使用静态字面量。新增或修改页面路由后,先运行 node scripts/route-contract.mjs --write(即 pnpm routes:export)重新生成 resources/admin-route-contract.json 并检查差异,否则 pnpm typecheck 会报 “Route contract is stale”。然后运行 pnpm typecheck、pnpm test、pnpm build。
7. 测试
7.1 验收套件
验收脚本是真实 HTTP + 真实 MySQL 的黑盒测试,渠道一律用本地假服务器,不调用任何真实渠道。
| 套件 | 脚本 | 假服务器 | 当前结果 |
|---|---|---|---|
| 核心(mock 渠道) | acceptance.php(php dev.php run core,自动关闭 worker,否则 worker 会先投递掉通知) |
— | 99/99 |
| 投诉(mock 渠道) | complaint-acceptance.php(php dev.php run regress 的一部分) |
— | 49/49 |
| 后台进程 | worker-check.php(run regress 的一部分,需 worker 开启,复用核心套件数据) |
— | 通过 |
| 微信支付(含投诉) | php dev.php run wx on/off |
fake-wechat.php(9100 / 控制口 9101) |
66/66,5/5 |
| 支付宝 | php dev.php run ali on/off |
fake-alipay.php(9200 / 9201) |
65/65,6/6 |
| 虚拟支付 + 收银台 | php dev.php run xp on/off |
fake-xpay.php(9300 / 9301,按官方字段) |
100/100(含投诉),7/7 |
| 京东收银 / 哆啦宝 | php dev.php run dlb on/off |
fake-duolabao.php(9400 / 9401) |
51/51,6/6 |
均在 .planning/aggpay-mvp/scratch/。lib.php 提供 merchant()(签名调用商户 API)、admin()、q()(直接查库)、tick()、order()、check() 等。
所有套件由 scratch/dev.php 驱动(用 FlyEnv 的 PHP 运行,-q 只打印 FAIL / RESULT;run-*.ps1 是它的薄壳):on 阶段以 live + 对应测试开关、worker 关闭重启后端;off 阶段开关为 0。核心与渠道套件结束时都会按默认参数重启后端,不会遗留 live 开关。php dev.php run all 依次跑全部套件并汇总。地址集中在 scratch/devenv.php。
假服务器每个起两个 php -S 实例(共用状态文件):API 口给网关调用(config.api_base),控制口给脚本调 /_test/*。原因:Windows 的 php -S 单线程,而 /_test/notify|pay 会同步回调网关,网关处理回调时又去查同一个假服务器,单实例会自锁。
7.2 给新渠道写验收
- 假服务器:按官方协议验证我们的请求签名,并按官方方式签名响应和回调;提供
/_test/*控制接口(模拟付款、改状态、注入故障);行为开关用*-control.json。 - 验收脚本至少覆盖:账号与凭证校验、凭证在列表/详情不回显且加密存储、每个产品的下单与支付参数、回调验签(伪造签名、过期、错账号、错金额、重复)、丢回调后补单、关单与过期、退款(成功/处理中/失败/超额)、渠道 5xx、篡改响应、错误凭证不误判失败、商户通知送达。
off阶段:开关关闭时不能交易、测试端点配置被拒、指向假服务器的账号的回调被拒。- 假服务器如果是“按理解”写的(没有官方原文对照),在脚本和适配器注释中标明,离线通过不代表协议正确。
7.3 静态检查
php .planning/aggpay-mvp/scratch/dev.php run regress 已包含全量 php -l、probe-classes.php、workbench:module:check aggpay 与 admin-preset:check。单独执行(仓库根目录):
php webman workbench:module:check aggpay
php webman admin-preset:check
8. 本地开发
8.1 环境
Windows 本机,运行时由 FlyEnv 管理(2026-11-10 起不再使用 Docker):
| 组件 | 版本 / 地址 |
|---|---|
| PHP(CLI,含 pdo_mysql、openssl、sodium、bcmath、intl) | FlyEnv php-8.3.36/php.exe |
MySQL(FlyEnv 服务 mysql,库与用户 aggpay,3306 与 33060 仅回环) |
8.4.13,127.0.0.1:3306 |
| webman(HTTP + aggpay-worker + monitor) | 127.0.0.1:18788(.env 的 WEBMAN_LISTEN) |
测试用商户通知接收器(scratch/receiver.php,写 scratch/receiver.log) |
127.0.0.1:9000 |
| 假渠道服务器(仅测试,各两个实例,见 7.1) | 9100/9101、9200/9201、9300/9301、9400/9401 |
8.2 启动
# 默认开关启动后端(后台、与终端脱离;日志 runtime/devenv/backend.log)
php .planning/aggpay-mvp/scratch/dev.php backend start
php .planning/aggpay-mvp/scratch/dev.php backend start AGGPAY_WORKER_ENABLED=0 # 开关只按这里给的生效
php .planning/aggpay-mvp/scratch/dev.php backend stop|status
php .planning/aggpay-mvp/scratch/dev.php servers start|stop # 接收器 + 全部假服务器
# 后台前端(pnpm 走 corepack 转发脚本)
$env:PATH = "D:/Dev/repos/agg/.planning/aggpay-mvp/bin;$env:PATH"
cd D:/Dev/repos/agg/frontend/admin-web-vben
pnpm dev # http://127.0.0.1:5180,代理到 127.0.0.1:18788(apps/webman-admin/.env.local)
后台账号为 .env 中的 ADMIN_SEED_USERNAME / ADMIN_SEED_PASSWORD。
Windows 下的注意事项:
- 后端入口是
windows.php(也可前台运行:仓库根目录php windows.php)。它用createUnsafeImmutable加载.env,环境变量优先于.env(与 Linux 的start.php相反)。开关类变量不要写进.env,缺省即关闭(AGGPAY_WORKER_ENABLED除外:未设为0即开启)。 - 每个进程只起一个(
count无效);文件监控在 Windows 关闭,改 PHP 代码后要重启后端。windows.php会跳过enable => false的进程(否则 Workerman 每秒重拉一次)。 - 必须设置
OPENSSL_CONF(指向 FlyEnv PHP 的extras/ssl/openssl.cnf):否则openssl_pkey_export()失败,微信 / 支付宝账号的私钥被重新导出为空,下单报“缺少 [mch_secret_cert]”。PHP 在启动时读取该变量,写进.env无效。dev.php会自动设置;前台运行windows.php时需自己在环境中设好。 dev.php用 WMI 创建后台进程:Windows 上proc_open的子进程会继承调用方句柄,从带管道的命令启动的服务会占住管道、随管道一起被结束。- 管理员登录有频率限制(每窗口 5 次);
lib.php的login()遇到 42901 会按retry_after等待重试。
8.3 常用命令(仓库根目录,FlyEnv 的 PHP)
php webman admin-preset:migrate # 执行迁移(会加载 .env;直接跑 phinx 不加载 .env,会连错库)
php webman aggpay:tick --job=all # 手动跑一轮后台任务;--job=dispatch|sync_orders|expire_orders|sync_refunds|sync_complaints|purge_nonces|prune_channel_stats
php webman admin-preset:sync # 新增菜单 / 权限后同步到数据库(否则 admin-preset:check 会报 not synced)
php webman aggpay:tick --job=sync_orders --min-age=0 # 不等 60 秒,立即补单
后台菜单层级完全由代码声明:app/modules/aggpay/config/menus/merchants.php 定义 5 个一级目录(交易中心、售后风控、商户中心、通道中心、运维监控),系统管理的排序在 app/modules/admin-system/config/menu.php(sort 900,置底)。新环境按 migrate → sync 执行即可,无需手动调整菜单。旧版的单一根目录「聚合支付」(code aggpay)由迁移 20261110010000_disable_legacy_aggpay_root_menu 停用;sync --prune 只清理仍声明的根目录内的菜单,停不掉它。dev.php run regress 的 MENU 一步(scratch/menu-check.php)校验这一层级。
8.4 开通一个测试商户
后台依次操作:渠道账号(mock、sandbox、启用,凭证 {"notify_key":"至少16位"})→ 商户 → 产品路由(mock_qr,费率 bps)→ 商户应用(填通知域名,保存弹出的一次性密钥)。然后用商户接入文档的示例代码下单,在订单页点“沙箱模拟付款”。
8.5 数据库迁移
新迁移放 app/modules/aggpay/migrations/,文件名 YYYYMMDDHHMMSS_snake_name.php,类名为对应的驼峰。表选项与已有迁移一致:['engine' => 'InnoDB', 'collation' => 'utf8mb4_unicode_ci', 'signed' => false]。只做增量变更,不修改已执行过的迁移。
9. 配置开关
完整列表见 support/AggpayConfig.php 与 aggpay-handoff.md 第 2 节。要点:
| 类别 | 变量 | 生产要求 |
|---|---|---|
| 必填 | AGGPAY_DATA_KEY(keyid:base64(32字节)),AGGPAY_DATA_KEYS_PREVIOUS(轮换期旧钥) |
必须配置;丢失则所有密钥与凭证无法解密 |
| 交易总开关 | AGGPAY_LIVE_ENABLED |
接入真实渠道时才置 1 |
| 沙箱 | AGGPAY_MOCK_ENABLED、AGGPAY_ALIPAY_SANDBOX_ENABLED、AGGPAY_XPAY_SANDBOX_ENABLED |
0 |
| 测试端点 | AGGPAY_WECHAT/ALIPAY/XPAY/DUOLABAO_ALLOW_TEST_ENDPOINT |
必须 0 |
| 商户通知 | AGGPAY_NOTIFY_ALLOW_HTTP、AGGPAY_NOTIFY_ALLOW_PRIVATE |
必须 0(开启即 SSRF 风险) |
| 网络 | AGGPAY_TRUSTED_PROXIES |
只填真实反向代理 IP,代理必须覆盖 X-Real-IP |
| 进程 | AGGPAY_WORKER_ENABLED、AGGPAY_WORKER_COUNT、WEBMAN_HTTP_WORKERS |
进程数 × 连接池上限 < MySQL max_connections |
| 虚拟支付 | AGGPAY_XPAY_NOT_FOUND_ERRCODES |
沙箱联调拿到真实错误码后填写 |
10. 编码约定
- 新文件放进对应分层目录,命名空间
App\Modules\Aggpay\…;路由用控制器上的 Workbench 注解,不写路由文件。 - 输入只用
support/Input的白名单方法,类型不符直接拒绝,不做隐式转换。 - 错误用
support/Errors的工厂方法抛出,不向外暴露 SQL、堆栈或渠道原始错误。 - 敏感参数加
#[\SensitiveParameter];含凭证的对象实现__debugInfo();日志只记 id、操作名、异常类名。 - 渠道原始报文不落库,回调只存
payload_sha256。 - 对“未与官方文档核实”的渠道字段,在类注释中列出,并在交接文档中标注。
- 改动共享代码(下单、退款、回调入口、渠道契约)后,回归全部验收套件,不只跑当前渠道。
11. 上线前检查清单
-
APP_DEBUG=false;所有沙箱、测试端点、http/内网通知开关为 0 -
AGGPAY_DATA_KEY已配置并离线备份 - 全站 HTTPS;
notify_base_url为公网 https 源;AGGPAY_TRUSTED_PROXIES正确 - 商户 API 已按
app_id限流(安装workbench-rate-limit,Redis store) - 删除或停用测试渠道账号、测试商户、测试产品路由
- 各真实渠道完成无扣款或小额联调,未核实的字段已核实
- worker 至少运行一个实例;数据库连接数预算已核算
- 上游 Workbench / Admin Preset 从本地快照 path 仓库切换为正式 Composer 制品
12. 官网与文档站
官网首页和开发文档都是 public/ 下的静态文件,由 webman 直接提供(与 API 同一个端口):
| 地址 | 来源 |
|---|---|
/ |
public/index.html(手写;路由在 config/route.php) |
/assets/site.css |
首页与文档共用样式,不依赖任何外部字体或 CDN |
/docs/merchant-api.html |
由 docs/aggpay-merchant-api.md 生成 |
/docs/cashier-miniprogram.html |
由 docs/aggpay-cashier-miniprogram/README.md 生成,并附上 pages/cashier/* 源码 |
/docs/developer-guide.html |
由本文生成 |
/docs、/docs/ |
302 到商户接入文档 |
改完 Markdown 后要重新生成(仓库根目录):
php webman site:build
app/command/SiteBuildCommand.php 用 league/commonmark(GFM:表格、任务列表)渲染,输入 HTML 一律转义,标题自动加锚点并生成侧边目录。
- 只发布
SiteBuildCommand::DOCS中列出的文档;新增要发布的文档时加到DOCS,并在LINKS中登记它的.md路径。 - 文档之间的
.md链接会改写为对应的.html;指向未发布文档(如aggpay-handoff.md、aggpay-design.md)的链接只保留文字,不会生成链接。 - 首页上的渠道、产品数量是手写的,新增渠道或产品时要同步修改
public/index.html。
13. 已知限制
- 结算出款、对账文件、风控、代理商分润未实现。
- 商户 API 未限流。
- 下单对商户行加
FOR UPDATE保证日限额准确,同一商户下单串行化;超高并发需改为日额度计数表。 - 商户通知租约 60 秒、超时 10 秒,极端情况下可能重复投递,商户需按
notification_id去重。 - IP 白名单只支持精确 IP 与 IPv4 CIDR。
- 服务商模式(微信 partner、支付宝 app_auth_token)未开放;产品路由只按 priority 选第一条可用路由,没有渠道故障时的自动切换。
- 微信、支付宝、虚拟支付都尚未用真实账号联调;虚拟支付“订单不存在”错误码官方未列出;微信支付投诉的返回字段与回报退款请求体未核实(虚拟支付文档中的微信支付投诉字段与我们的实现一致,可作旁证)。
- 投诉接入了沙箱、微信支付和小程序虚拟支付(仅 production 账号,未实盘验证);支付宝投诉未接入。投诉图片、协商历史未同步;没有处理时限提醒。
- yansongda/pay 锁定
~3.7.20,部分安全补丁通过继承 SDK 插件实现,升级 SDK 前必须跑完 7.1 的全部套件。