# Paycenter — 支付中心插件 把「怎么收钱」从业务里剥离:插件负责验签、下单、查单、回调、退款;业务(应用市场等) 只负责建订单和收「已支付」事件后发货。**支付宝与微信并列一等公民**,业务侧代码不区分渠道。 ## 一、边界(谁管什么) | 层 | 归属 | 说明 | |---|---|---| | 业务订单 `appmall_addon_orders` | 业务(不动) | 建单、授权签发、分红、退款冲正 | | 支付流水 `wxapp_paycenter_trade` | 本插件 | 每笔下单/回调/查单留痕,可独立对账 | | 验签 / 网关参数 / 场景差异 | 本插件 | 业务代码里不该出现任何 `Pay::alipay()` | ## 二、安装 ```bash composer require yansongda/pay # ^3.0 php think addon:manage paycenter -a develop # 开发模式安装:建表 + 注入菜单 + 启用 php think paycenter:check --ping # 自检(--channel=alipay|wechat|all) ``` ## 三、配置 密钥**只走 `.env`**(不进数据库),后台配置页(`/backend/addon/setting?addon=paycenter`) 只放开关与默认场景: | 配置项 | 说明 | |---|---| | `alipay_enable` / `wechat_enable` | 渠道开关(未配置商户参数时自动不可用) | | `default_channel` | 业务未指定渠道时用哪个 | | `alipay_scene` / `wechat_scene` | 默认场景 | `.env` 键(模板见 `.env.example`):`PAY_ALIPAY_*`(含三份证书路径)、 `PAY_WECHAT_MCH_ID/APP_ID/SECRET_KEY/SECRET_CERT/PUBLIC_CERT_PATH`、 `PAY_HTTP_CA`(Windows 无 CA 包时报 cURL error 60 时用)。 ## 四、业务侧怎么用 ```php // 1) 拉起支付 $pay = \addon\paycenter\service\Payment::create('alipay', [ 'trade_no' => $order['trade_no'], 'amount' => 99.00, 'subject' => '购买插件:xxx', 'biz' => 'appmall', // 业务标识(回调用) 'biz_id' => $order['id'], 'attach' => ['aid' => 1], // 可选,JSON 存流水 ], 'web', [ 'notify' => 'https://host/paycenter/api/notify?channel=alipay', // 留空则用插件默认 'return' => 'https://host/xxx/payResult', ]); // $pay = ['type' => 'form|qrcode|url|params', 'content' => ..., 'trade_no' => ...] // 2) 查单(回调丢了时兜底) Payment::query('alipay', $tradeNo); // ['paid'=>bool,'amount'=>float,'gateway_no'=>string] // 3) 退款(按流水自动认渠道,无需业务记录支付方式) Payment::refund($tradeNo); // 4) 可用支付方式(收银台渲染) Payment::methods(); // [['code'=>'alipay','name'=>'支付宝','scenes'=>[...],'scene'=>'web']] ``` ### 发货:监听 `PayPaid` 事件 插件验签通过后触发(事件名 `Payment::EVENT_PAID`)。在 `app/event.php` 注册监听器: ```php 'PayPaid' => [ app\appmall\listener\OrderPaid::class ], ``` 事件载荷:`trade_no / channel / amount / gateway_no / biz / biz_id / attach / scene / trade`。 监听器要做三件事:**金额校验 → 幂等判断 → 发货**(参考 `app\appmall\listener\OrderPaid`)。 ## 五、场景映射 | 渠道 | scene | 网关产品 | 返回 type | |---|---|---|---| | 支付宝 | `web` | 电脑网站支付 | `form`(自动提交表单 HTML) | | 支付宝 | `wap` | 手机网站支付 | `form` | | 支付宝 | `qr` | 当面付扫码 | `qrcode` | | 支付宝 | `app` | App 支付 | `params` | | 微信 | `qr` | Native 扫码 | `qrcode`(code_url) | | 微信 | `h5` | H5 | `url`(h5_url) | | 微信 | `mp` | 公众号 JSAPI | `params`(需 `openid`) | | 微信 | `mini` | 小程序 | `params`(需 `openid`) | | 微信 | `app` | App | `params` | ## 六、回调地址 统一入口:`POST /paycenter/api/notify?channel=alipay|wechat`(免登录)。 业务侧下单时把 `notify_url` 指向它即可;应用市场原先的 `/api/addon/notify` 保留转发(内部同样委托 `Payment::notify()`)。 ## 七、目录结构 ``` addon/paycenter/ ├─ service/Payment.php 门面:methods / create / query / notify / refund ├─ library/contract/Channel.php 渠道接口(新增渠道实现它 + 登记到 Payment::CHANNELS) ├─ library/AlipayChannel.php / WechatChannel.php ├─ library/PayFactory.php SDK 装配(两级配置、CA、日志) ├─ library/PayConfig.php 配置合并(插件静态配置 ← 核心 config/pay.php 覆盖) ├─ library/TradeLog.php 流水读写 ├─ library/Schema.php 建表/补列自愈(事实源 install.sql) ├─ command/Check.php php think paycenter:check ├─ controller/api/Notify.php 回调入口 ├─ controller/backend/Trade.php + view/backend/trade/index.html 后台交易流水 └─ controller/backend/Channel.php + view/backend/channel/index.html 渠道状态(只读诊断) ``` ### 菜单约定(踩过的坑) `AddonService::createMenu()` 会把 `menu.json` 的 backend 段挂到「应用 → 插件名目录」下, **当 backend 只有一项且该项带 `child` 时,框架把它当「包裹层」展开,只注入它的子项** (表现为:侧栏只剩按钮节点,真正的菜单页消失)。因此本插件保留了两个顶层项 (交易流水 + 渠道状态),菜单项与按钮节点的层级才是正常的: ``` 应用 └ 支付中心 paycenter:backend (目录,框架自动生成) ├ 交易流水 paycenter:backend:trade/index (菜单) │ ├ 流水数据 / 详情 / 主动查单 / 发起退款 paycenter:backend:trade:* (按钮) └ 渠道状态 paycenter:backend:channel/index(菜单) ``` 权限名必须形如 `<插件>:backend:<控制器>/<动作>` 或 `<插件>:backend:<控制器>:<动作>`, 与 `ywxapp\library\Auth` 的候选匹配保持一致,否则非超管访问会 403。 改完 `menu.json` 后执行 `php think addon:repair paycenter --menu` 重建。 ## 八、已知坑(都已在代码里处理) - **SDK 配置是两级的**:必须 `['alipay' => ['default' => [...]]]`,直接传扁平 `config('pay.alipay')` 取不到。 - **每单回调地址只能走 params**:支付宝 `_notify_url`/`_return_url`、微信 `notify_url`; 写进配置会被容器忽略(`Artful::config()` 已存在时直接 return)。 - **私钥 PKCS8/PKCS1**:PKCS8 必须自带 `PRIVATE KEY` 头,PKCS1 必须交裸 base64 让 SDK 包 RSA 头, 反了就会 `openssl_sign(): Supplied key param cannot be coerced into a private key`。 - **证书相对路径**:SDK 按进程 CWD 解析,Web 入口 CWD 是 `public/` → 已在配置层统一转绝对路径。 - **微信金额单位是分**,且必须是整数。 - **微信没有官方沙箱**:`mode` 恒为 0,验证只能真实小额支付后退款。