一套代码搞定微信+支付宝全端支付:元点Admin 支付系统设计
一套代码搞定微信支付宝全端支付元点Admin 支付系统设计微信支付就有5种方式支付宝又是一套每个渠道的参数结构、签名算法、回调格式全不一样——你有没有因为多端支付把自己逼成适配地狱这篇文章带你看元点Admin是怎么用一套架构干净地解决这个问题的。一、多端支付开发者的适配地狱做过电商或者 SaaS 平台的同学一定有体会支付看起来是个小功能实际上一旦需要适配多端复杂度会成倍增长。以微信支付为例光官方支持的场景就有小程序JSAPI需要openid前端调用wx.requestPayment()公众号 H5同样是 JSAPI但appid不同需要 OAuth 获取openid手机 H5MWEB唤起微信客户端支付需要mweb_url跳转原生 APPAPP 支付SDK 调起需要前端二次签名PC 网页Native 模式生成二维码让用户扫码五种场景每种的请求参数不同、签名方式不同、前端唤起方式不同、回调处理也有细微差异。如果你再加上支付宝——网页支付、手机网站支付、APP 支付——每一个都要单独对接代码量轻松翻倍。更头疼的是边界 bug订单号里的大写字母被 URI normalize 处理成 kebab-case 导致签名失败notify_url写死了域名导致测试环境回调打到生产PC 扫码支付要轮询状态但订单还没创建时微信返回ORDER_NOT_EXIST需要特殊处理……这些问题元点Adminydadmin在 v1.1.0 的支付模块里都做了系统性的解决。下面我们拆解它的设计思路。二、支付架构PaymentManager Driver 模式元点Admin 的支付系统核心是一个经典的策略模式Strategy Pattern变体PaymentManager作为统一入口负责根据配置和运行时上下文选择合适的 DriverDriver 层实现具体的支付渠道逻辑。PaymentManager ├── WechatPayDriver │ ├── create() // 创建支付订单 │ ├── query() // 查询订单状态 │ ├── refund() // 发起退款 │ └── notify() // 处理异步回调 └── AlipayDriver ├── create() ├── query() ├── refund() └── notify()对业务代码来说无论底层是微信还是支付宝调用接口是统一的// 业务层统一接口不关心底层渠道 $manager app(PaymentManager::class); $driver $manager-driver(); // 根据上下文自动选择 $result $driver-create([ out_trade_no $order-sn, total_fee $order-amount, body 订单支付, ]);PaymentManager::driver()的选择逻辑依赖两个输入请求头X-Client-Type标识当前客户端类型配置文件中的渠道开关决定是走微信还是支付宝这样做的好处显而易见业务代码零改动只要在配置层和请求头层做好约定新增渠道只需实现一个新 Driver 即可。WechatPayDriver::create()额外支持动态传入appid参数这是为了应对一个 app 下挂载多个微信主体的场景// 动态指定 appid适配多商户场景 $driver-create([ out_trade_no $order-sn, total_fee $order-amount, appid $customAppId, // 运行时覆盖配置文件中的默认值 ]);三、平台识别X-Client-Type 请求头 白名单校验多端支付的第一个难题是后端怎么知道这笔支付请求来自哪个端最直接的方案是前端在每个请求里带上标识。元点Admin 定义了一个自定义请求头X-Client-Type: miniapp | wechat_h5 | h5 | app | pc五个值对应五种客户端场景X-Client-Type对应场景微信支付方式miniapp微信小程序JSAPI小程序 appidwechat_h5微信内置浏览器 H5JSAPI公众号 appidh5普通手机浏览器MWEBH5 支付app原生 APPAPP 支付pcPC 浏览器Native二维码前端 UniApp 侧的配置非常简单在请求拦截器里统一注入// UniApp 请求拦截器 uni.addInterceptor(request, { invoke(args) { // 根据编译平台自动注入 X-Client-Type const clientType (() { // #ifdef MP-WEIXIN return miniapp; // #endif // #ifdef H5 // 判断是否在微信内置浏览器 return /MicroMessenger/i.test(navigator.userAgent) ? wechat_h5 : h5; // #endif // #ifdef APP-PLUS return app; // #endif })(); args.header { ...args.header, X-Client-Type: clientType, }; } });白名单校验是这套机制的安全保障。后端中间件会验证X-Client-Type的值是否在允许列表内非法值直接拒绝// 中间件校验 X-Client-Type class ValidateClientType { protected array $allowed [miniapp, wechat_h5, h5, app, pc]; public function handle(Request $request, Closure $next) { $clientType $request-header(X-Client-Type); if (!in_array($clientType, $this-allowed, true)) { return response()-json([ code 400, message 无效的客户端类型, ], 400); } return $next($request); } }有了这个请求头WechatPayDriver内部的路由逻辑就非常清晰public function create(array $params): array { $clientType request()-header(X-Client-Type); return match ($clientType) { miniapp $this-createJsapi($params, $this-config[miniapp_appid]), wechat_h5 $this-createJsapi($params, $this-config[mp_appid]), h5 $this-createMweb($params), app $this-createApp($params), pc $this-createNative($params), default throw new \InvalidArgumentException(Unsupported client type), }; }四、微信支付五种方式详解4.1 小程序 JSAPI 与公众号 JSAPI这两种方式底层都是 JSAPI核心差异在于使用哪个appid以及如何获取openid。小程序使用小程序appidopenid通过code2Session接口获取公众号使用公众号appidopenid通过 OAuth 网页授权获取元点Admin 通过多 AppID 配置解决了这个问题// config/payment.php wechat [ miniapp_appid env(WECHAT_MINIAPP_APPID), // 小程序 AppID mp_appid env(WECHAT_MP_APPID), // 公众号 AppID open_appid env(WECHAT_OPEN_APPID), // 开放平台 AppIDAPP 登录用 app_appid env(WECHAT_APP_APPID), // 移动应用 AppIDAPP 支付用 mch_id env(WECHAT_MCH_ID), api_v3_key env(WECHAT_API_V3_KEY), cert_path storage_path(certs/wechat), ],JSAPI 支付的后端响应需要返回前端二次签名所需的参数buildJsapiParams()方法封装了这个签名过程protected function buildJsapiParams(string $prepayId, string $appId): array { $timestamp (string) time(); $nonceStr Str::random(32); $package prepay_id . $prepayId; $message implode(\n, [$appId, $timestamp, $nonceStr, $package, ]); // 使用商户私钥对消息进行 SHA256withRSA 签名 $signature $this-signWithPrivateKey($message); return [ appId $appId, timeStamp $timestamp, nonceStr $nonceStr, package $package, signType RSA, paySign $signature, ]; }前端小程序拿到这些参数后直接调用// 小程序端调起支付 const res await uni.requestPayment({ provider: wxpay, timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, });4.2 手机 H5MWEB普通手机浏览器无法直接调起微信支付需要通过 MWEB 方式后端向微信发起下单微信返回一个mweb_url前端用这个 URL 跳转唤起微信客户端。protected function createMweb(array $params): array { $order [ appid $this-config[mp_appid], mchid $this-config[mch_id], description $params[body], out_trade_no $params[out_trade_no], notify_url $this-buildNotifyUrl(), amount [total (int)($params[total_fee] * 100)], scene_info [ payer_client_ip request()-ip(), h5_info [type Wap], ], ]; $response $this-client-postJson(v3/pay/transactions/h5, $order); return [mweb_url $response[h5_url]]; }4.3 APP 支付APP 支付的核心在于buildAppParams()方法提供的二次签名参数结构与 JSAPI 类似但字段略有不同protected function buildAppParams(string $prepayId): array { $timestamp (string) time(); $nonceStr Str::random(32); // APP 签名消息格式与 JSAPI 不同appid timestamp nonce prepayid $message implode(\n, [ $this-config[app_appid], $timestamp, $nonceStr, $prepayId, , ]); return [ appid $this-config[app_appid], partnerid $this-config[mch_id], prepayid $prepayId, package SignWXPay, noncestr $nonceStr, timestamp $timestamp, sign $this-signWithPrivateKey($message), ]; }4.4 PC Native 二维码PC 端用户无法用手机号登录微信唤起微信提供 Native 方式下单后返回code_url前端渲染成二维码用户用手机扫码完成支付。这部分我们在第七节单独详解。五、支付宝集成同一套抽象不同的实现支付宝同样有多种支付场景APP 支付、手机网站支付、电脑网站支付以及未来可能接入的小程序支付。AlipayDriver实现了与WechatPayDriver相同的接口约定但内部走支付宝 SDKclass AlipayDriver implements PaymentDriverInterface { public function create(array $params): array { $clientType request()-header(X-Client-Type); return match ($clientType) { app $this-createAppPay($params), h5 $this-createWapPay($params), // 手机网站支付 pc $this-createPagePay($params), // 电脑网站支付 default $this-createWapPay($params), }; } protected function createWapPay(array $params): array { $result $this-alipay-wap()-pay( new WapPayRequest([ subject $params[body], out_trade_no $params[out_trade_no], total_amount $params[total_fee], quit_url $params[quit_url] ?? , return_url $params[return_url] ?? , ]) ); return [pay_url $result-pay_url]; } }业务层代码无需区分底层渠道通过配置文件切换// 根据配置决定用哪个支付渠道 $channel config(payment.default); // wechat 或 alipay $driver app(PaymentManager::class)-driver($channel);这种设计让多商户、多渠道的场景变得非常灵活甚至可以在同一个系统里为不同用户提供不同的支付渠道只需在调用时动态传入渠道名称即可。六、关键技术细节那些让你踩坑的地方6.1 JSAPI/APP 支付参数二次签名微信支付 v3 API 引入了 RSA 签名机制前端唤起支付需要的参数不能直接把微信返回的prepay_id传给前端必须由后端用商户私钥重新签名一次。这个二次签名的概念很重要很多开发者踩坑就在这里误以为微信返回的数据可以直接给前端用导致支付签名验证失败。签名的消息格式是固定的以换行符\n拼接末尾也要有一个\n// JSAPI 签名消息格式 appId\n timeStamp\n nonceStr\n package\n (末尾空行)buildJsapiParams()和buildAppParams()这两个方法的存在就是为了把这个容易出错的细节封装起来保证签名格式正确。6.2 微信平台证书自动下载与缓存微信支付 v3 API 要求用微信平台证书而非商户证书来验证微信回调的签名。平台证书有有效期需要定期更新。元点Admin 实现了证书的自动下载和本地缓存机制protected function getPlatformCert(): string { $certPath $this-config[cert_path] . /platform_cert.pem; // 检查本地缓存的证书是否仍在有效期内 if (file_exists($certPath)) { $cert openssl_x509_read(file_get_contents($certPath)); $certInfo openssl_x509_parse($cert); // 证书还有 7 天以上有效期直接使用缓存 if ($certInfo[validTo_time_t] - time() 86400 * 7) { return file_get_contents($certPath); } } // 证书不存在或即将过期从微信接口下载最新证书 $certs $this-client-get(v3/certificates); $latestCert $this-decryptCertificate($certs[data][0]); // 保存到本地 file_put_contents($certPath, $latestCert); return $latestCert; }6.3 notify_url 支持相对路径运行时自动补全域名notify_url是支付回调地址很多项目会硬编码完整 URL导致测试环境和生产环境需要维护不同的配置。元点Admin 允许在配置文件中只写相对路径运行时自动补全当前域名protected function buildNotifyUrl(): string { $path config(payment.wechat.notify_url, /api/payment/notify/wechat); // 如果已经是完整 URL直接使用 if (str_starts_with($path, http)) { return $path; } // 自动补全当前请求的域名 return request()-getSchemeAndHttpHost() . $path; }这样配置文件只需写WECHAT_NOTIFY_URL/api/payment/notify/wechat无论是本地开发、测试环境还是生产环境回调地址都会自动适配当前域名。6.4 订单号大写被 URI normalize 转为 kebab-case 的坑这是一个非常隐蔽的 bug某些 HTTP 客户端在构建 URL 时如果 URL 中包含大写字母可能会做 URI 规范化处理将驼峰或大写字母转成 kebab-case 格式。例如订单号ORD20260402ABC拼接到查询 URLv3/pay/transactions/out-trade-no/ORD20260402ABC中被处理后变成v3/pay/transactions/out-trade-no/ord20260402-a-b-c导致微信接口返回签名错误或订单不存在。修复方案是在拼接 URL 前对订单号做显式的 URL encode跳过 HTTP 客户端的自动处理// 查询订单时对订单号做显式编码避免被 normalize $url v3/pay/transactions/out-trade-no/ . rawurlencode($outTradeNo); $response $this-client-get($url);rawurlencode()不会改变大写字母但会阻止 HTTP 客户端的自动规范化这是修复这个坑的关键。七、PC 端二维码 轮询支付状态PC Native 支付的流程比手机端多一个环节用户点击支付 ↓ 后端调微信 Native 下单接口 ↓ 微信返回 code_urlweixin://wxpay/bizpayurl?prxxxxx ↓ 后端用 qrcode 库将 code_url 渲染成 Base64 图片 ↓ 前端展示二维码启动轮询 ↓ 用户手机扫码支付 ↓ 微信回调 notify_url后端更新订单状态 ↓ 前端轮询到 paid 状态跳转成功页后端生成二维码的代码protected function createNative(array $params): array { $order [ appid $this-config[miniapp_appid], mchid $this-config[mch_id], description $params[body], out_trade_no $params[out_trade_no], notify_url $this-buildNotifyUrl(), amount [total (int)($params[total_fee] * 100)], ]; $response $this-client-postJson(v3/pay/transactions/native, $order); $codeUrl $response[code_url]; // 使用 qrcode 库生成 Base64 图片前端直接用 img srcdata:image/png;base64,... 显示 $qrCode QrCode::format(png) -size(300) -errorCorrection(M) -generate($codeUrl); return [ qrcode data:image/png;base64, . base64_encode($qrCode), out_trade_no $params[out_trade_no], ]; }前端展示二维码并启动轮询// 展示二维码启动轮询 async function startQrCodePayment(orderSn) { const res await createPayment({ out_trade_no: orderSn }); qrcodeImg.src res.data.qrcode; const timer setInterval(async () { const status await queryPaymentStatus(orderSn); if (status.data.trade_state SUCCESS) { clearInterval(timer); router.push(/payment/success); } else if ([CLOSED, PAYERROR].includes(status.data.trade_state)) { clearInterval(timer); showError(支付失败请重新尝试); } // PENDING / NOTPAY 继续轮询 }, 2000); // 每 2 秒轮询一次 // 5 分钟后自动停止轮询对应微信 Native 订单的默认超时时间 setTimeout(() clearInterval(timer), 300_000); }ORDER_NOT_EXIST 静默返回 pending 的处理是一个重要的健壮性设计。微信 Native 下单成功后如果用户还没扫码在极短时间内通常几十毫秒内查询订单状态微信系统有时会返回ORDER_NOT_EXIST错误码而不是NOTPAY。如果前端把ORDER_NOT_EXIST当作错误停止轮询会导致用户体验很差扫码前就显示支付失败。元点Admin 的处理方案是在query()方法里把ORDER_NOT_EXIST静默映射成pending状态继续等待public function query(string $outTradeNo): array { try { $url v3/pay/transactions/out-trade-no/ . rawurlencode($outTradeNo); $response $this-client-get($url . ?mchid . $this-config[mch_id]); return [ trade_state $response[trade_state], raw $response, ]; } catch (WechatPayException $e) { // ORDER_NOT_EXIST订单刚创建、微信系统未同步静默返回 pending if ($e-getErrorCode() ORDER_NOT_EXIST) { return [trade_state PENDING]; } throw $e; // 其他异常正常抛出 } }这个细节处理得好轮询流程就不会误判用户扫码体验非常流畅。八、快速接入步骤清单如果你想在自己的项目里使用元点Admin 的支付模块接入步骤如下1. 环境配置在.env文件中配置各平台的 AppID 和密钥# 微信支付 WECHAT_MINIAPP_APPIDwx_your_miniapp_appid WECHAT_MP_APPIDwx_your_mp_appid WECHAT_APP_APPIDwx_your_app_appid WECHAT_MCH_IDyour_mch_id WECHAT_API_V3_KEYyour_api_v3_key WECHAT_PRIVATE_KEY_PATH/path/to/apiclient_key.pem WECHAT_CERT_SERIALyour_cert_serial_no WECHAT_NOTIFY_URL/api/payment/notify/wechat # 支付宝 ALIPAY_APP_IDyour_alipay_appid ALIPAY_PRIVATE_KEYyour_private_key ALIPAY_PUBLIC_KEYalipay_public_key ALIPAY_NOTIFY_URL/api/payment/notify/alipay2. 前端注入 X-Client-Type 请求头在 UniApp 请求拦截器中根据编译目标自动注入X-Client-Type参考第三节代码示例。3. 注册中间件在需要支付的路由上挂载ValidateClientType中间件。4. 调用支付接口// 控制器中调用 public function pay(Request $request): JsonResponse { $driver app(PaymentManager::class)-driver(); $result $driver-create([ out_trade_no $order-sn, total_fee $order-amount, body 商品名称, ]); return response()-json([code 0, data $result]); }5. 处理异步回调// 回调路由无需身份验证但需验证微信签名 Route::post(/api/payment/notify/wechat, [PaymentController::class, wechatNotify]) -withoutMiddleware([auth, ValidateClientType]);接入完成。无论用户从小程序、公众号、手机 H5、APP 还是 PC 发起支付后端都会自动路由到正确的支付方式前端只需消费统一格式的响应数据。结语多端支付的本质复杂度是客观存在的——微信就是有5种支付方式支付宝也是一套独立体系。但通过合理的架构设计这种复杂度可以被很好地封装对业务代码透明。元点Admin 的支付模块用PaymentManager Driver模式统一了多渠道的接口用X-Client-Type请求头解决了端识别问题用buildJsapiParams()/buildAppParams()封装了容易出错的二次签名逻辑用自动补全的notify_url消除了环境配置差异还修复了订单号 URI normalize 和ORDER_NOT_EXIST这两个高频 bug。这套设计是完全可以独立学习和参考的即使你不使用 ydadmin 的其他功能支付模块的这些思路也值得借鉴。如果你正在做多端 UniApp PHP 的项目欢迎直接参考或 ForkGitHubhttps://github.com/yuandianxitong/ydadminGitee: https://gitee.com/yuandianxitong/ydadmin在线 Demohttps://admin.dev007.cn有问题欢迎在 GitHub Issues 区反馈也欢迎点个 Star 支持一下。