更新于 2026-11-10 20:53 · 源文件 docs/aggpay-developer-guide.md

聚合支付 平台开发文档

适用对象:维护和扩展本平台的开发者。 版本: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() 中的产品走收银台流程:

  1. 下单不调渠道,cashierLaunchAction() 生成拉起参数;令牌只存 SHA-256(aggpay_cashier_sessions)。
  2. /cashier/prepare:令牌 → 订单(必须 created 且未过期)→ cashierIdentify() 换付款人身份 → 首个付款人 CAS 绑定(OrderRepository::bindPayer)→ PaymentContext::withPayer() → createPayment() 返回调起参数。
  3. 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”为例:

  1. 凭证类 channel/FoopayCredentials.php:fromAccount()、configError()、credentialError();敏感字段 #[\SensitiveParameter],实现 __debugInfo() 只暴露 id。回调地址用 ChannelUrls::notifyUrl($base, 'foopay', $accountId),测试端点用 ChannelUrls::testBase($config, AggpayConfig::foopayAllowTestEndpoint())。
  2. 客户端 channel/FoopayClient.php:签名、发请求、验证每一个响应的签名;网络错误、非预期 HTTP、验签失败抛 ChannelTransportException。
  3. 适配器 channel/FoopayAdapter.php implements ChannelAdapter, ChannelFormFields,在类注释里写清楚:产品、outcome 映射规则、哪些字段未与官方文档核实;formFields() 列出凭证类注释里的每个配置项和密钥项。
  4. 注册:AggpayConfig::channels() 加 'foopay' => FoopayAdapter::class;需要的开关在 AggpayConfig 中新增,默认关闭。
  5. 下单新字段(如有):CreateOrderRequest 校验 → 迁移加列 → Order 模型 $fillable → OrderRepository::formatItem → PaymentService::create 写入 → PaymentContext → requestHash 按“存在才追加”。
  6. 测试:写假服务器与验收脚本(第 7 节),覆盖开关 on/off 两个阶段,再回归全部已有套件。
  7. 文档:更新本文、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 给新渠道写验收

  1. 假服务器:按官方协议验证我们的请求签名,并按官方方式签名响应和回调;提供 /_test/* 控制接口(模拟付款、改状态、注入故障);行为开关用 *-control.json。
  2. 验收脚本至少覆盖:账号与凭证校验、凭证在列表/详情不回显且加密存储、每个产品的下单与支付参数、回调验签(伪造签名、过期、错账号、错金额、重复)、丢回调后补单、关单与过期、退款(成功/处理中/失败/超额)、渠道 5xx、篡改响应、错误凭证不误判失败、商户通知送达。
  3. off 阶段:开关关闭时不能交易、测试端点配置被拒、指向假服务器的账号的回调被拒。
  4. 假服务器如果是“按理解”写的(没有官方原文对照),在脚本和适配器注释中标明,离线通过不代表协议正确。

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. 已知限制

  1. 结算出款、对账文件、风控、代理商分润未实现。
  2. 商户 API 未限流。
  3. 下单对商户行加 FOR UPDATE 保证日限额准确,同一商户下单串行化;超高并发需改为日额度计数表。
  4. 商户通知租约 60 秒、超时 10 秒,极端情况下可能重复投递,商户需按 notification_id 去重。
  5. IP 白名单只支持精确 IP 与 IPv4 CIDR。
  6. 服务商模式(微信 partner、支付宝 app_auth_token)未开放;产品路由只按 priority 选第一条可用路由,没有渠道故障时的自动切换。
  7. 微信、支付宝、虚拟支付都尚未用真实账号联调;虚拟支付“订单不存在”错误码官方未列出;微信支付投诉的返回字段与回报退款请求体未核实(虚拟支付文档中的微信支付投诉字段与我们的实现一致,可作旁证)。
  8. 投诉接入了沙箱、微信支付和小程序虚拟支付(仅 production 账号,未实盘验证);支付宝投诉未接入。投诉图片、协商历史未同步;没有处理时限提醒。
  9. yansongda/pay 锁定 ~3.7.20,部分安全补丁通过继承 SDK 插件实现,升级 SDK 前必须跑完 7.1 的全部套件。