// +---------------------------------------------------------------------- namespace driver\pay\WechatPay; use crmeb\exceptions\AdminException; use crmeb\exceptions\ApiException; use crmeb\services\CacheService; use crmeb\services\easywechat\Application; use crmeb\services\wechat\Support; use EasyWeChat\Kernel\Support\Str; use EasyWeChat\Kernel\Support\Xml; use EasyWeChat\Pay\Application as PayApplication; use EasyWeChat\Pay\Message; use Nyholm\Psr7\Response as Psr7Response; use think\facade\Db; use think\facade\Event; /** * 微信支付 V2 SDK(WechatPay 驱动包内置) * 整合原 modules/channel/wechat_mini/sdk/MiniProgramService 与 * modules/channel/wechat_mp/sdk/WechatService 中的支付能力, * 供本驱动包 Driver.php 调用,避免 driver 层反向依赖 modules/channel。 * * 通过 $scene 参数区分使用场景: * - mini:小程序(routine_appId / routine_appsecret) * - mp:公众号(仅使用 wechat_appid) * - app:APP(仅使用 wechat_app_appid) * * 场景隔离用于区分应用身份,所有支付成功通知仍执行同一签名与业务校验。 * * ⚠️ 与 driver/pay/V3WechatPay/WechatV3PaySdk.php 的关系: * - 那是 V3 驱动专用补充 SDK,仅提供 minipay 通道(小程序新版支付); * - 本类是 V2 协议完整 SDK,提供 24 个方法覆盖 mini/mp/app 三场景; * - 两者协议不同、职责不同,不是同源副本,不需同步修改。 */ class WechatPaySdk { /** * 微信支付渠道标识 * 与 app\services\pay\PayServices::WEIXIN_PAY 保持一致,driver 层不可反向依赖 app 层 * 故在此声明本地常量,Notify 事件按字符串路由 */ const WEIXIN_PAY_CHANNEL_KEY = 'weixin'; /** * 按场景隔离的 Application 实例 * @var array */ protected static $instances = []; /** @var array 场景对应的配置指纹,不保存明文密钥 */ protected static array $instanceHashes = []; /** * 从 eb_pay_channel 读取本驱动支付插件配置 * * 架构豁免说明:支付驱动插件化后配置统一存储于 eb_pay_channel.config_fields * (app\services\driver\pay\PayChannelServices 负责读写),本类位于 driver 第三方 * 驱动底座,driver 层无 DAO 体系,故此处 Db 直查为合规做法(同本类 getPemPath 模式), * 禁止"修复"为 app 层 DAO * @return array{status:int, config:array} */ protected static function channelConfig(): array { try { $channel = Db::name('pay_channel')->where('channel_key', self::WEIXIN_PAY_CHANNEL_KEY)->where('is_del', 0)->find(); } catch (\Throwable $e) { return ['status' => 0, 'config' => []]; } $config = []; if ($channel && !empty($channel['config_fields'])) { // Db 直查返回 text 列原始 JSON 字符串,需解码(模型 cast 在 driver 层不生效) $configFields = is_string($channel['config_fields']) ? json_decode($channel['config_fields'], true) : $channel['config_fields']; if (is_array($configFields)) { foreach ($configFields as $field) { if (isset($field['field'])) { $config[$field['field']] = $field['value'] ?? ''; } } } } return [ 'status' => (int)($channel['status'] ?? 0), 'config' => $config, ]; } /** * 读取支付配置 * @param string $scene 场景:mini(小程序) / mp(公众号) / app(APP) * @return array */ public static function options(string $scene = 'mp'): array { // 支付插件配置(微信支付已插件化,统一存储于 eb_pay_channel.config_fields)。 // 公众号和 APP 仅使用 AppID;仅小程序新版支付需要小程序 AppSecret。 // site_url 属系统公用配置(站点信息),允许 driver 直接 sys_config 读取 $channelConfig = self::channelConfig(); $payment = $channelConfig['config']; $siteUrl = sys_config('site_url'); // 按场景读取渠道配置 if ($scene === 'mini') { // 小程序场景 $wechat = [ 'site_url' => $siteUrl, 'routine_appId' => $payment['routine_appId'] ?? '', 'routine_appsecret' => $payment['routine_appsecret'] ?? '', ]; $appId = trim($wechat['routine_appId'] ?? ''); $appsecret = trim($wechat['routine_appsecret'] ?? ''); if ($appId === '') { throw new ApiException('微信支付V2小程序需要配置小程序AppID'); } $config = [ 'mini_program' => [ 'app_id' => $appId, 'secret' => $appsecret, ], ]; // 小程序商户号:新版用 pay_new_weixin_mchid,旧版用 pay_weixin_mchid $merchantId = !empty($payment['pay_new_weixin_open']) ? trim($payment['pay_new_weixin_mchid'] ?? '') : trim($payment['pay_weixin_mchid'] ?? ''); $config['payment'] = [ 'app_id' => $appId, 'merchant_id' => $merchantId, 'key' => trim($payment['pay_weixin_key'] ?? ''), 'cert_path' => self::getPemPath('pay_weixin_client_cert'), 'key_path' => self::getPemPath('pay_weixin_client_key'), 'notify_url' => trim($siteUrl) . '/api/pay/notify/weixin', ]; if (empty($payment['pay_new_weixin_open'])) $config['payment'] = self::withProviderConfig($config['payment'], $payment); return $config; } // 公众号 / APP 支付只需要各自 AppID,AppSecret 属于应用能力,不参与 V2 下单签名。 $wechat = [ 'wechat_appid' => $payment['wechat_appid'] ?? '', 'wechat_app_appid' => $payment['wechat_app_appid'] ?? '', ]; if ($scene === 'app') { $appId = trim($wechat['wechat_app_appid'] ?? ''); } else { $appId = trim($wechat['wechat_appid'] ?? ''); } if ($appId === '' && $scene !== 'notify') { $label = $scene === 'app' ? 'APP应用' : '微信公众号'; throw new ApiException('微信支付V2' . $label . '需要配置AppID'); } $config = [ 'app_id' => $appId, // 保留空 secret 键以兼容 EasyWeChat Application 配置结构;支付流程不会使用它。 'secret' => '', 'guzzle' => [ 'timeout' => 10.0, 'verify' => true, ], ]; // payment 配置(渠道启停由 eb_pay_channel.status 控制,插件化后不再读取 pay_weixin_open) if ((int)($channelConfig['status'] ?? 0) === 1 || $scene === 'notify') { $config['payment'] = [ 'app_id' => $appId, 'merchant_id' => trim($payment['pay_weixin_mchid'] ?? ''), 'key' => trim($payment['pay_weixin_key'] ?? ''), 'cert_path' => self::getPemPath('pay_weixin_client_cert'), 'key_path' => self::getPemPath('pay_weixin_client_key'), 'notify_url' => trim($siteUrl) . '/api/pay/notify/weixin', ]; // 服务商模式子商户号 $config['payment'] = self::withProviderConfig($config['payment'], $payment); } return $config; } /** * 服务商签名使用服务商应用,支付者与调起参数使用对应子应用。 * @param array $config 支付配置 * @param array $payment 渠道配置 * @return array */ protected static function withProviderConfig(array $config, array $payment): array { if (empty($payment['mer_type'])) return $config; $spAppid = trim((string)($payment['sp_appid'] ?? '')); $subMchid = trim((string)($payment['pay_sub_merchant_id'] ?? '')); if ($spAppid === '' || $subMchid === '') throw new ApiException('服务商支付需要配置服务商AppID和子商户号'); $config['sub_appid'] = $config['app_id']; $config['app_id'] = $spAppid; $config['sub_mch_id'] = $subMchid; return $config; } /** * 获取支付证书路径 * * 架构豁免说明:本类位于 driver 第三方驱动底座,依赖铁律禁止调用 app 层 * (SystemPemServices/SystemPemDao 均在 app 层),driver 层无 DAO 体系, * 故此处 Db 直查为合规做法(同 crmeb\services\AliPayService::getPemPath 模式), * 禁止"修复"为 app 层 DAO * @param string $name 证书配置名 * @return string */ public static function getPemPath(string $name): string { // 支付渠道配置优先,保证 V2 使用自己的证书;system_pem 仅兼容旧版应用配置。 $channelConfig = self::channelConfig(); $path = self::resolveConfiguredPemPath((string)($channelConfig['config'][$name] ?? '')); if ($path !== '') { return $path; } // 兼容旧版 system_pem 证书配置。 $info = Db::name('system_pem')->where('name', $name)->find(); if ($info && !empty($info['path'])) { $path = root_path('runtime/pem') . $info['path'] . '.pem'; if (!file_exists($path)) { // 如果 runtime/pem 文件夹不存在,创建文件夹 if (!file_exists(root_path('runtime/pem'))) { mkdir(root_path('runtime/pem'), 0777, true); } file_put_contents($path, $info['content'] ?? ''); } if ($path) return $path; } return ''; } /** * 解析支付渠道配置中的证书路径。 * @param string $path * @return string */ protected static function resolveConfiguredPemPath(string $path): string { if ($path === '') { return ''; } if (strstr($path, 'http://') || strstr($path, 'https://')) { $path = parse_url($path)['path'] ?? ''; } $path = str_replace('\\', '/', trim($path)); if ($path === '') { return ''; } $rootPath = str_replace('\\', '/', rtrim(root_path(), '/\\')) . '/'; $normalizedPath = str_replace('\\', '/', $path); if (str_starts_with($normalizedPath, $rootPath) && is_file($path) && is_readable($path)) { return $path; } $relativePath = ltrim($path, '/'); if (str_starts_with($relativePath, 'uploads/')) { $relativePath = substr($relativePath, strlen('uploads/')); } $runtimePath = root_path('runtime/pem') . $relativePath; if (is_file($runtimePath) && is_readable($runtimePath)) { return $runtimePath; } $publicPath = public_path('uploads') . $relativePath; if (is_file($publicPath) && is_readable($publicPath)) { return $publicPath; } return ''; } /** * 获取 Application 实例(按场景缓存) * @param string $scene 场景 * @param bool $cache 是否强制刷新缓存 * @return Application */ public static function application(string $scene = 'mp', bool $cache = false): Application { $config = self::channelConfig(); $certificates = []; foreach (['pay_weixin_client_cert', 'pay_weixin_client_key'] as $name) { $path = self::resolveConfiguredPemPath((string)($config['config'][$name] ?? '')); $certificates[$name] = $path !== '' ? hash_file('sha256', $path) : ''; } $hash = hash('sha256', serialize([$config, $certificates])); if (!isset(self::$instances[$scene]) || $cache === true || (self::$instanceHashes[$scene] ?? '') !== $hash) { self::$instances[$scene] = new Application(self::options($scene)); } self::$instanceHashes[$scene] = $hash; return self::$instances[$scene]; } /** * 获取支付服务 * @param string $scene 场景 * @return PayApplication */ public static function paymentService(string $scene = 'mp') { return self::application($scene)->getPaymentApp(); } /** * V2 XML 接口统一请求(xml 选项自动以 v2 key 签名) * @param string $scene * @param string $uri * @param array $data * @param bool $withCerts 双向证书(退款/企业付款需要) * @return array */ protected static function xmlRequest(string $scene, string $uri, array $data, bool $withCerts = false): array { $enterprise = in_array($uri, ['mmpaymkttransfers/promotion/transfers', 'mmpaymkttransfers/gettransferinfo'], true); if ($enterprise && !$withCerts) throw new ApiException('企业付款接口必须使用双向证书'); $app = self::paymentService($scene); $config = $app->getConfig(); // 对齐 4.x Payment BaseClient:所有 V2 请求注入商户号(6.x 仅自动补 nonce_str 与签名,不补 mch_id) // 使用安全回调过滤空值/null,避免 PHP 8.2 下 strlen(null) 触发 deprecation $data = array_filter(array_merge([ $uri === 'mmpaymkttransfers/promotion/transfers' ? 'mchid' : 'mch_id' => (string) $config->get('mch_id'), 'sub_mch_id' => $config->get('sub_mch_id'), 'sub_appid' => $config->get('sub_appid'), ], $data), static fn ($v) => $v !== '' && $v !== null); $options = ['xml' => $data]; if ($withCerts) { $options['local_cert'] = (string) $config->get('certificate'); $options['local_pk'] = (string) $config->get('private_key'); } $res = $app->getClient()->post($uri, $options)->toArray(false); if (!is_array($res)) { throw new ApiException('微信支付返回格式不正确'); } // 旧企业付款/查询协议没有响应签名;仅这两条双向 TLS 接口按请求身份核对。 if ($enterprise && ($res['result_code'] ?? '') === 'SUCCESS') { $merchant = (string)($res['mchid'] ?? $res['mch_id'] ?? ''); $appid = (string)($res['mch_appid'] ?? $res['appid'] ?? ''); if ($merchant !== (string)$config->get('mch_id') || $appid !== (string)($data['mch_appid'] ?? $data['appid'] ?? '') || ($res['partner_trade_no'] ?? '') !== ($data['partner_trade_no'] ?? '')) { throw new ApiException('企业付款响应与原商户、应用或单号不一致'); } } if (!$enterprise && ($res['return_code'] ?? '') === 'SUCCESS' && (!is_string($res['sign'] ?? null) || $res['sign'] === '' || !hash_equals(self::v2Sign($res, (string)$config->get('v2_secret_key')), $res['sign']))) { throw new ApiException('微信支付响应签名校验失败'); } return $res; } /** * V2 MD5 签名(APP 支付参数用) * @param array $params * @param string $key * @return string */ protected static function v2Sign(array $params, string $key): string { unset($params['sign']); $params = array_filter($params, static fn ($v) => $v !== '' && $v !== null); ksort($params); $parts = []; foreach ($params as $name => $value) { if (!is_scalar($value)) { throw new ApiException('微信支付签名字段格式不正确'); } $parts[] = $name . '=' . $value; } $content = implode('&', $parts) . '&key=' . $key; $type = $params['sign_type'] ?? 'MD5'; if (!in_array($type, ['MD5', 'HMAC-SHA256'], true) || $key === '') { throw new ApiException('微信支付签名算法或密钥不正确'); } return strtoupper($type === 'HMAC-SHA256' ? hash_hmac('sha256', $content, $key) : md5($content)); } /** * 生成支付订单对象(小程序场景) * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return array */ protected static function paymentOrderMini($openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'JSAPI', $options = []) { $total_fee = bcmul($total_fee, 100, 0); $order = compact('openid', 'out_trade_no', 'total_fee', 'attach', 'body', 'detail', 'trade_type') + array_intersect_key($options, array_flip(['time_expire', 'expire_time'])); if ($order['detail'] === '') unset($order['detail']); return $order; } /** * 生成支付订单对象(公众号场景) * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return array */ protected static function paymentOrderMp($openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'JSAPI', $options = []) { $total_fee = bcmul($total_fee, 100, 0); $order = compact('out_trade_no', 'total_fee', 'attach', 'body', 'detail', 'trade_type') + array_intersect_key($options, array_flip(['time_expire', 'expire_time'])); if (!is_null($openid)) $order['openid'] = $openid; if ($order['detail'] === '') unset($order['detail']); return $order; } /** * 统一下单补齐 V2 必填字段(对齐 4.x Payment Order Client::unify) * @param string $scene * @param array $order * @param string $tradeType * @return array */ protected static function withUnifyFields(string $scene, array $order, string $tradeType): array { $config = self::paymentService($scene)->getConfig(); unset($order['expire_time']); $order['appid'] = (string) $config->get('app_id'); if ($config->get('sub_appid') && isset($order['openid'])) { $order['sub_openid'] = $order['openid']; unset($order['openid']); } $order['notify_url'] = (string) $config->get('notify_url'); if (empty($order['spbill_create_ip'])) { $order['spbill_create_ip'] = ($tradeType === 'NATIVE') ? self::serverIp() : request()->ip(); } if ($tradeType === 'MWEB') { $order['scene_info'] = json_encode(['h5_info' => [ 'type' => 'Wap', 'wap_url' => sys_config('site_url'), 'wap_name' => sys_config('site_name'), ]], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } return $order; } /** * 按支付身份、金额、场景隔离预支付缓存。 * @param string $scene 场景 * @param string $orderNo 订单号 * @param mixed $openid 支付者 * @param mixed $amount 金额 * @param string $mode 支付模式 * @return string */ protected static function prepayCacheKey(string $scene, string $orderNo, $openid, $amount, string $mode): string { $config = self::paymentService($scene)->getConfig(); $identity = [$scene, $openid, (string)$amount, $mode, $config->get('app_id'), $config->get('sub_appid'), $config->get('mch_id'), $config->get('sub_mch_id')]; return 'pay:v2:prepay:' . $orderNo . ':' . hash('sha256', json_encode($identity, JSON_THROW_ON_ERROR)); } /** * 获取服务器出口 IP(对齐 4.x Support\get_server_ip,NATIVE 下单场景需要) * @return string */ protected static function serverIp(): string { if (!empty($_SERVER['SERVER_ADDR'])) { $ip = $_SERVER['SERVER_ADDR']; } elseif (!empty($_SERVER['SERVER_NAME'])) { $ip = gethostbyname($_SERVER['SERVER_NAME']); } else { $ip = gethostbyname(gethostname()); } return filter_var($ip, FILTER_VALIDATE_IP) ?: '127.0.0.1'; } /** * 统一下单 - 小程序场景 * 返回 prepay_id 字符串(与原 MiniProgramService::paymentPrepare 一致) * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return string */ public static function paymentPrepare(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'JSAPI', $options = []) { $key = self::prepayCacheKey($scene, $out_trade_no, $openid, $total_fee, $trade_type); $result = CacheService::get($key); if ($result) { return $result; } if ($scene === 'mini') { // 小程序:缓存 prepay_id 字符串 $order = self::paymentOrderMini($openid, $out_trade_no, $total_fee, $attach, $body, $detail, $trade_type, $options); $order = self::withUnifyFields('mini', $order, $trade_type); $result = self::xmlRequest('mini', 'pay/unifiedorder', $order); if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') { CacheService::set($key, $result['prepay_id'], 7000); return $result['prepay_id']; } else { if ($result['return_code'] == 'FAIL') { exception('微信支付错误返回:' . $result['return_msg']); } else if (isset($result['err_code'])) { exception('微信支付错误返回:' . $result['err_code_des']); } else { exception('没有获取微信支付的预支付ID,请重新发起支付!'); } exit; } } // 公众号 / APP:缓存整个 result 数组 $order = self::paymentOrderMp($openid, $out_trade_no, $total_fee, $attach, $body, $detail, $trade_type, $options); $order = self::withUnifyFields($scene, $order, $trade_type); $result = self::xmlRequest($scene, 'pay/unifiedorder', $order); if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') { CacheService::set($key, $result, 7000); return $result; } else { if ($result['return_code'] == 'FAIL') { exception('微信支付错误返回:' . $result['return_msg']); } else if (isset($result['err_code'])) { exception('微信支付错误返回:' . $result['err_code_des']); } else { exception('没有获取微信支付的预支付ID,请重新发起支付!'); } exit; } } /** * 统一下单 - 新版小程序支付(minipay 通道) * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param array $options * @return array */ public static function newPaymentPrepare(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $options = []) { $key = self::prepayCacheKey($scene, $out_trade_no, $openid, $total_fee, 'mini_shop'); $result = CacheService::get($key); if ($result) { return $result; } if ($scene === 'mini') { // 小程序:缓存 payment_params $application = self::application('mini'); if (trim((string)($application->getConfig()['mini_program']['secret'] ?? '')) === '') { throw new ApiException('微信支付V2新小程序支付需要配置小程序AppSecret'); } $order = self::paymentOrderMini($openid, $out_trade_no, $total_fee, $attach, $body, $detail, 'JSAPI', $options); $result = $application->minipay->createorder($order); if ($result['errcode'] === 0) { CacheService::set($key, $result['payment_params'], 7000); return $result['payment_params']; } else { exception('微信支付错误返回:' . '[' . $result['errcode'] . ']' . $result['errmsg']); exit; } } // 公众号:缓存整个 result(含 return_code/result_code) $order = self::paymentOrderMp($openid, $out_trade_no, $total_fee, $attach, $body, $detail, 'JSAPI', $options); $result = self::application('mp')->minipay->createorder($order); if ($result['return_code'] == 'SUCCESS' && $result['result_code'] == 'SUCCESS') { CacheService::set($key, $result, 7000); return $result; } else { if ($result['return_code'] == 'FAIL') { exception('微信支付错误返回:' . $result['return_msg']); } else if (isset($result['err_code'])) { exception('微信支付错误返回:' . $result['err_code_des']); } else { exception('没有获取微信支付的预支付ID,请重新发起支付!'); } exit; } } /** * 获得 jsSdk 支付参数 * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return array|string */ public static function jsPay(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'JSAPI', $options = []) { $prepare = self::paymentPrepare($scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail, $trade_type, $options); // 小程序场景 prepare 为 prepay_id 字符串;公众号场景 prepare 为 result 数组 $prepayId = is_array($prepare) ? $prepare['prepay_id'] : $prepare; $app = self::paymentService($scene); return $app->getUtils()->buildSdkConfig($prepayId, (string) ($app->getConfig()->get('sub_appid') ?: $app->getConfig()->get('app_id')), 'MD5'); } /** * 获得 jsSdk 支付参数 - 新版小程序支付 * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param array $options * @return array */ public static function newJsPay(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $options = []) { if ($scene === 'mini') { // 小程序:newPaymentPrepare 返回 payment_params,直接使用 $config = self::newPaymentPrepare('mini', $openid, $out_trade_no, $total_fee, $attach, $body, $detail, $options); $config['timestamp'] = $config['timeStamp']; unset($config['timeStamp']); return $config; } // 公众号:newPaymentPrepare 返回 result,取 prepay_id $prepare = self::newPaymentPrepare('mp', $openid, $out_trade_no, $total_fee, $attach, $body, $detail, $options); $app = self::paymentService('mp'); return $app->getUtils()->buildSdkConfig($prepare['prepay_id'], (string) $app->getConfig()->get('app_id'), 'MD5'); } /** * 获得 App 支付参数 * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return array|string */ public static function appPay(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'APP', $options = []) { $prepare = self::paymentPrepare($scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail, $trade_type, $options); $prepayId = is_array($prepare) ? $prepare['prepay_id'] : $prepare; // V2 APP 支付参数(MD5 签名,保持与 4.x jssdk->appConfig 相同形状) $app = self::paymentService($scene); $params = [ 'appid' => (string) ($app->getConfig()->get('sub_appid') ?: $app->getConfig()->get('app_id')), 'partnerid' => (string) ($app->getConfig()->get('sub_mch_id') ?: $app->getConfig()->get('mch_id')), 'prepayid' => $prepayId, 'package' => 'Sign=WXPay', 'noncestr' => Str::random(16), 'timestamp' => (string) time(), ]; $params['sign'] = self::v2Sign($params, (string) $app->getConfig()->get('v2_secret_key')); return $params; } /** * 获得 native 支付参数(公众号场景专用) * @param string $scene * @param string|null $openid * @param string $out_trade_no * @param mixed $total_fee * @param string $attach * @param string $body * @param string $detail * @param string $trade_type * @param array $options * @return array */ public static function nativePay(string $scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail = '', $trade_type = 'NATIVE', $options = []) { $data = self::paymentPrepare($scene, $openid, $out_trade_no, $total_fee, $attach, $body, $detail, $trade_type, $options); $res = []; if ($data) { // paymentPrepare 在 mp 场景返回 result 数组 $prepayId = is_array($data) ? ($data['code_url'] ?? '') : $data; $res['code_url'] = is_array($data) ? $prepayId : ''; $res['invalid'] = time() + 60; // logo 由 Driver::create 从注入的 options 填充,SDK 自身不读取系统配置 $res['logo'] = ''; } return $res; } /** * 使用商户订单号退款 * @param string $scene * @param string $orderNo * @param string $refundNo * @param float $totalFee * @param float|null $refundFee * @param string|null $opUserId * @param string $refundReason * @param string $type * @param string $refundAccount * @return array */ public static function refund(string $scene, $orderNo, $refundNo, $totalFee, $refundFee = null, $opUserId = null, $refundReason = '', $type = 'out_trade_no', $refundAccount = 'REFUND_SOURCE_UNSETTLED_FUNDS') { $totalFee = floatval($totalFee); $refundFee = floatval($refundFee); $data = [ 'out_refund_no' => $refundNo, 'total_fee' => (int)$totalFee, 'refund_fee' => (int)$refundFee, 'reason' => $refundReason, 'refund_account' => $refundAccount, ]; if ($type == 'out_trade_no') { $data['out_trade_no'] = $orderNo; } else { $data['transaction_id'] = $orderNo; } // 对齐 4.x Payment Refund Client:退款请求注入 appid $config = self::paymentService($scene)->getConfig(); $data['appid'] = (string)$config->get('app_id'); // 退款结果通知与支付通知共用入口,不能仅靠分钟级查单推进订单状态。 $notifyUrl = trim((string)$config->get('notify_url')); if ($notifyUrl !== '') { $data['notify_url'] = $notifyUrl; } return self::xmlRequest($scene, 'secapi/pay/refund', $data, true); } /** * 使用商户订单号退款 - 小程序新版(minipay 通道) * @param string $scene * @param string $orderNo * @param float $totalFee * @param float|null $refundFee * @param array $opt * @return mixed */ public static function miniRefund(string $scene, $orderNo, $totalFee, $refundFee = null, array $opt = []) { $totalFee = floatval($totalFee); $refundFee = floatval($refundFee); $order = [ 'openid' => $opt['open_id'] ?? '', 'trade_no' => $opt['order_id'] ?? '', 'transaction_id' => $opt['trade_no'] ?? '', 'refund_no' => $opt['refund_id'] ?? $opt['refund_no'] ?? '', 'total_amount' => $totalFee, 'refund_amount' => $refundFee, ]; return self::application($scene)->minipay->refundorder($order); } /** * 查询并核对新版支付订单。 * @param string $orderNo 原订单号 * @param array $context 原支付信息 * @return array */ public static function getMiniOrder(string $orderNo, array $context): array { $config = self::options('mini')['payment'] ?? []; if (($context['app_id'] ?? '') !== ($config['app_id'] ?? '') || ($context['mch_id'] ?? '') !== ($config['merchant_id'] ?? '')) { throw new ApiException('原新版支付商户或应用配置已变化'); } $result = self::application('mini')->minipay->getorder($orderNo); $order = $result['order'] ?? []; if ((int)($result['errcode'] ?? -1) !== 0 || ($order['trade_no'] ?? '') !== $orderNo || ($order['mchid'] ?? '') !== ($context['mch_id'] ?? '')) { throw new ApiException('新版支付原订单查询或归属校验失败'); } return $order; } /** * 查询新版支付固定退款号。 * @param string $orderNo 原订单号 * @param string $refundNo 固定退款号 * @param array $context 原支付信息 * @return array */ public static function queryMiniRefund(string $orderNo, string $refundNo, array $context): array { $order = self::getMiniOrder($orderNo, $context); foreach ($order['refund_list'] ?? [] as $refund) { if (($refund['refund_no'] ?? '') === $refundNo) { return ['status' => strtoupper((string)($refund['result'] ?? 'PROCESSING')), 'refund_id' => $refund['refund_id'] ?? '', 'out_refund_no' => $refundNo]; } } return ['status' => 'UNKNOWN']; } /** * 查询普通支付订单。 * @param string $scene 原支付场景 * @param string $orderNo 原订单号 * @return array */ public static function queryOrder(string $scene, string $orderNo): array { return self::xmlRequest($scene, 'pay/orderquery', ['appid' => self::paymentService($scene)->getConfig()->get('app_id'), 'out_trade_no' => $orderNo]); } /** * 关闭普通支付订单。 * @param string $scene 原支付场景 * @param string $orderNo 原订单号 * @return array */ public static function closeOrder(string $scene, string $orderNo): array { return self::xmlRequest($scene, 'pay/closeorder', ['appid' => self::paymentService($scene)->getConfig()->get('app_id'), 'out_trade_no' => $orderNo]); } /** * 根据订单号退款(带异常包装) * @param string $scene * @param string $orderNo * @param array $opt * @return bool */ public static function payOrderRefund(string $scene, $orderNo, array $opt) { if (!isset($opt['pay_price'])) throw new AdminException('缺少pay_price'); // 证书配置校验(插件化后从 eb_pay_channel.config_fields 读取) $channelConfig = self::channelConfig(); if (($channelConfig['config']['pay_weixin_client_key'] ?? '') === '' || ($channelConfig['config']['pay_weixin_client_cert'] ?? '') === '') { throw new AdminException('请配置支付证书'); } $totalFee = floatval(bcmul($opt['pay_price'], 100, 0)); $refundFee = isset($opt['refund_price']) ? floatval(bcmul($opt['refund_price'], 100, 0)) : null; $refundReason = $opt['desc'] ?? ''; $refundNo = $opt['refund_id'] ?? $orderNo; $opUserId = $opt['op_user_id'] ?? null; $type = $opt['type'] ?? 'out_trade_no'; /*仅针对老资金流商户使用 REFUND_SOURCE_UNSETTLED_FUNDS---未结算资金退款(默认使用未结算资金退款) REFUND_SOURCE_RECHARGE_FUNDS---可用余额退款*/ $refundAccount = $opt['refund_account'] ?? 'REFUND_SOURCE_UNSETTLED_FUNDS'; try { $res = self::refund($scene, $orderNo, $refundNo, $totalFee, $refundFee, $opUserId, $refundReason, $type, $refundAccount); if ($res['return_code'] == 'FAIL') throw new AdminException('退款失败:{:msg}', ['msg' => $res['return_msg']]); if (isset($res['err_code'])) throw new AdminException('退款失败:{:msg}', ['msg' => $res['err_code_des']]); } catch (\Exception $e) { throw new AdminException($e->getMessage()); } return true; } /** * 企业付款到零钱(公众号场景专用) * @param string $scene * @param string $openid openid * @param string $orderId 订单号 * @param string $amount 金额 * @param string $desc 说明 */ public static function merchantPay(string $scene, string $openid, string $orderId, string $amount, string $desc) { $identity = self::options($scene)['payment'] ?? []; $options = self::options('notify'); if (!empty($identity['sub_mch_id'])) throw new ApiException('旧企业付款不支持子商户身份,请使用已开通的直连转账渠道'); if (!isset($options['payment']['cert_path'])) { throw new ApiException('企业微信支付到零钱需要支付证书,检测到您没有上传'); } if (!$options['payment']['cert_path']) { throw new ApiException('企业微信支付到零钱需要支付证书,检测到您没有上传'); } $merchantPayData = [ 'mch_appid' => $identity['app_id'] ?? '', 'mchid' => self::paymentService('notify')->getConfig()->get('mch_id'), 'partner_trade_no' => $orderId, //随机字符串作为订单号,跟红包和支付一个概念。 'openid' => $openid, //收款人的openid 'check_name' => 'NO_CHECK', //文档中有三种校验实名的方法 NO_CHECK OPTION_CHECK FORCE_CHECK 'amount' => (int)bcmul($amount, '100', 0), //单位为分 'desc' => $desc, 'spbill_create_ip' => request()->ip(), //发起交易的IP地址 ]; $result = self::xmlRequest('notify', 'mmpaymkttransfers/promotion/transfers', $merchantPayData, true); if (($result['return_code'] ?? '') === 'SUCCESS' && ($result['result_code'] ?? '') === 'SUCCESS' && !empty($result['payment_no'])) { return $result; } else { throw new ApiException($result['err_code_des'] ?? '企业付款到零钱失败,请稍后再试'); } } /** * 旧企业付款超时或处理中时按固定单号查询,不换号重付。 * @param string $orderNo 原付款单号 * @param array $context 原付款身份 * @return array */ public static function queryEnterpriseTransfer(string $orderNo, array $context): array { $config = self::options('notify')['payment'] ?? []; $payment = self::channelConfig()['config']; $appField = ($context['platform'] ?? '') === 'wechat_mini' ? 'routine_appId' : (($context['platform'] ?? '') === 'app' ? 'wechat_app_appid' : 'wechat_appid'); if (!empty($payment['mer_type']) || ($config['merchant_id'] ?? '') !== ($context['mch_id'] ?? '') || trim((string)($payment[$appField] ?? '')) !== ($context['app_id'] ?? '')) throw new ApiException('原企业付款商户或应用已变化'); $result = self::xmlRequest('notify', 'mmpaymkttransfers/gettransferinfo', [ 'appid' => $context['app_id'], 'partner_trade_no' => $orderNo, ], true); if (($result['return_code'] ?? '') !== 'SUCCESS' || ($result['result_code'] ?? '') !== 'SUCCESS') throw new ApiException('企业付款查单尚未确认,请稍后重试'); return $result; } /** * 微信 V2 支付及退款通知入口 * * 架构说明:driver 层不可反向依赖 app 层(PayNotifyServices/PayServices 均在 app 层), * 故统一通过 Event::until('Notify', ...) 将回调路由交给 app 层监听器处理。 * NotifyListener 监听器内部按 attach 字段分发到 PayNotifyServices::wechat{Attach} 方法, * 与原 Hook 调用路径等价(监听器实现见 app/listener/pay/NotifyListener.php)。 * * V2 回调为 XML 推送,应答也必须是 XML(6.x Server 的默认 JSON 应答不适用)。 * * @param string $scene 场景:notify / mini / mp / app * @return \think\Response */ public static function handleNotify(string $scene = 'mp') { $app = self::paymentService($scene); $app->setRequestFromSymfonyRequest(Support::symfonyRequest()); $server = $app->getServer(); $server->with(function (Message $message, \Closure $next) use ($scene) { $reply = static function (string $code, string $msg = 'OK') { return new Psr7Response(200, ['Content-Type' => 'text/xml'], Xml::build([ 'return_code' => $code, 'return_msg' => $msg, ])); }; // EasyWeChat 已解密 req_info;退款通知没有普通支付签名,须查验签名结果后分发。 $original = Xml::parse($message->getOriginalContents()); if (isset($original['req_info'])) { try { $notify = self::verifyRefundNotification($scene, $original, $message->toArray()); $result = Event::until('Notify', [$notify, self::WEIXIN_PAY_CHANNEL_KEY]); return $result === true ? $reply('SUCCESS') : $reply('FAIL', '处理失败'); } catch (\Throwable $e) { \think\facade\Log::error('[WechatV2Refund] 通知处理失败 type=' . get_class($e)); return $reply('FAIL', '退款结果校验或处理失败'); } } // 校验 V2 回调签名(4.x Payment Notify Handler::validate 行为,6.x Server 不校验) $sign = $message['sign'] ?? ''; try { $valid = is_string($sign) && $sign !== '' && hash_equals( self::v2Sign($message->toArray(), (string)self::paymentService($scene)->getConfig()->get('v2_secret_key')), $sign); } catch (\Throwable $e) { $valid = false; } if (!$valid) { return $reply('FAIL', 'sign error'); } $data = $message->toArray(); $data['payment_mode'] = 'v2'; $data['amount'] = ['total' => isset($data['total_fee']) && ctype_digit((string)$data['total_fee']) ? (int)$data['total_fee'] : null, 'currency' => $data['fee_type'] ?? 'CNY']; $data['mchid'] = $data['mch_id'] ?? ''; $data['sub_mchid'] = $data['sub_mch_id'] ?? ''; $data['openid'] = $data['sub_openid'] ?? $data['openid'] ?? ''; if ($scene === 'mini') { // 小程序场景:原 MiniProgramService::handleNotify 逻辑 // return_code=FAIL 的通知没有 result_code 字段,统一 ?? 容错 if (($message['return_code'] ?? '') === 'SUCCESS' && ($message['result_code'] ?? '') === 'SUCCESS' && isset($message['out_trade_no'])) { if (isset($message['attach']) && $message['attach']) { $result = Event::until('Notify', [$data, self::WEIXIN_PAY_CHANNEL_KEY]); return $result === true ? $reply('SUCCESS') : $reply('FAIL', '处理失败'); } } return $reply('SUCCESS'); } // 公众号场景:原 WechatService::handleNotify 逻辑 if (($message['return_code'] ?? '') === 'SUCCESS' && ($message['result_code'] ?? '') === 'SUCCESS') { $result = Event::until('Notify', [$data, self::WEIXIN_PAY_CHANNEL_KEY]); return $result === true ? $reply('SUCCESS') : $reply('FAIL', '处理失败'); } return $reply('SUCCESS'); }); try { return Support::toThinkResponse($server->serve()); } catch (\Throwable $e) { \think\facade\Log::error('[WechatV2] 通知处理失败 type=' . get_class($e)); return Support::toThinkResponse(new Psr7Response(500, ['Content-Type' => 'text/xml'], Xml::build([ 'return_code' => 'FAIL', 'return_msg' => '处理失败', ]))); } } /** * 核对加密退款通知与同一退款号的已验签查询结果,防止无认证 XML 提前推进退款。 * @param string $scene 支付场景 * @param array $original 原始 XML 外层字段 * @param array $refund 已由 EasyWeChat 解密的退款字段 * @return array 统一退款成功通知 */ protected static function verifyRefundNotification(string $scene, array $original, array $refund): array { $config = self::paymentService($scene)->getConfig(); $merchantId = (string)$config->get('mch_id'); $subMerchantId = (string)$config->get('sub_mch_id'); if (($original['return_code'] ?? '') !== 'SUCCESS' || empty($original['req_info']) || $merchantId === '' || ($original['mch_id'] ?? '') !== $merchantId || !is_string($original['appid'] ?? null) || $original['appid'] === '' || ($subMerchantId !== '' && $original['appid'] !== (string)$config->get('app_id')) || (isset($original['sub_mch_id']) && $original['sub_mch_id'] !== $subMerchantId) || ($refund['refund_status'] ?? '') !== 'SUCCESS') { throw new ApiException('V2退款通知的商户或状态不一致'); } foreach (['out_trade_no', 'transaction_id', 'out_refund_no', 'refund_id'] as $field) { if (!is_string($refund[$field] ?? null) || $refund[$field] === '') { throw new ApiException('V2退款通知缺少必要单号'); } } foreach (['total_fee', 'refund_fee'] as $field) { if (!is_string($refund[$field] ?? null) || !preg_match('/^\d{1,10}$/D', $refund[$field])) { throw new ApiException('V2退款通知金额无效'); } } // ECB 加密不提供完整性认证,必须用验签查单结果确认订单、交易、退款号与金额。 $query = self::xmlRequest($scene, 'pay/refundquery', [ 'appid' => $original['appid'], 'out_refund_no' => $refund['out_refund_no'], // 共用通知入口的公众号子应用不一定是原支付应用,查询按子商户与固定退款号定位。 'sub_appid' => null, ]); if (($query['return_code'] ?? '') !== 'SUCCESS' || ($query['result_code'] ?? '') !== 'SUCCESS' || ($query['mch_id'] ?? '') !== $merchantId || ($query['appid'] ?? '') !== $original['appid'] || ($query['sub_mch_id'] ?? '') !== $subMerchantId || ($query['fee_type'] ?? 'CNY') !== 'CNY' || ($query['out_trade_no'] ?? '') !== $refund['out_trade_no'] || ($query['transaction_id'] ?? '') !== $refund['transaction_id'] || (string)($query['total_fee'] ?? '') !== $refund['total_fee']) { throw new ApiException('V2退款查询与通知身份或订单不一致'); } // 查询可能包含多笔退款,按固定退款号定位,不能直接取第零笔。 foreach ($query as $field => $value) { if (!preg_match('/^out_refund_no_(\d+)$/D', (string)$field, $match) || $value !== $refund['out_refund_no']) { continue; } $index = $match[1]; if (($query['refund_status_' . $index] ?? '') !== 'SUCCESS' || ($query['refund_id_' . $index] ?? '') !== $refund['refund_id'] || (string)($query['refund_fee_' . $index] ?? '') !== $refund['refund_fee']) { throw new ApiException('V2退款尚未确认成功或退款金额不一致'); } return [ 'event_type' => 'REFUND.SUCCESS', 'payment_mode' => 'v2', 'refund_status' => 'SUCCESS', 'out_trade_no' => $refund['out_trade_no'], 'transaction_id' => $refund['transaction_id'], 'out_refund_no' => $refund['out_refund_no'], 'refund_id' => $refund['refund_id'], 'mchid' => $merchantId, 'sub_mchid' => $subMerchantId, 'appid' => $query['appid'], 'sub_appid' => $query['sub_appid'] ?? '', 'amount' => ['total' => (int)$refund['total_fee'], 'refund' => (int)$refund['refund_fee'], 'currency' => 'CNY'], ]; } throw new ApiException('V2退款查询未找到对应退款号'); } /** * 查询退款订单(公众号场景专用) * @param string $scene * @param string $outTradeNo * @param string $type 查询字段:out_trade_no / transaction_id / out_refund_no / refund_id * @return array */ public static function queryRefund(string $scene, string $outTradeNo, string $type = 'out_trade_no') { // 对齐 4.x Payment Refund Client::query:查询请求注入 appid $data = ['appid' => (string) self::paymentService($scene)->getConfig()->get('app_id')]; // 系统固定退款号使用 out_refund_no,不能作为微信生成的 refund_id 传入。 if (in_array($type, ['out_trade_no', 'transaction_id', 'out_refund_no'], true)) { $data[$type] = $outTradeNo; } else { $data['refund_id'] = $outTradeNo; } return self::xmlRequest($scene, 'pay/refundquery', $data); } }