config = config('common.wechat'); // 小程序 $this->app = new MiniAppApplication([ 'app_id' => $this->config['app_id'], 'secret' => $this->config['secret'], // 稳定版凭证重复获取不会把上一张作废。旧接口并发刷新会返回 40001 'use_stable_access_token' => true, 'http' => ['throw' => false, 'timeout' => 10.0], ]); // 微信支付 v3 $this->payApp = new PayApplication([ 'mch_id' => $this->config['mch_id'], 'secret_key' => $this->config['secret_key'], // APIv3 密钥 'v2_secret_key' => $this->config['v2_secret_key'], // 旧 v2 密钥(兼容 v2 模式回调) 'private_key' => $this->config['private_key'], // 商户证书私钥 'certificate' => $this->config['certificate'], // 商户API证书 'platform_certs' => $this->config['platform_certs'], // 微信支付公钥/平台证书 'http' => ['throw' => false, 'timeout' => 10.0], ]); } /** * 刷新小程序 access_token。普通刷新不会使有效凭证失效;force 只在微信返回凭证无效时使用 * @param bool $force * @return string */ public function refreshMiniAccessToken($force = false) { $accessToken = $this->app->getAccessToken(); if ($force && method_exists($accessToken, 'getStableAccessToken')) { return $accessToken->getStableAccessToken(true); } if ($accessToken instanceof \EasyWeChat\Kernel\Contracts\RefreshableAccessToken) { return $accessToken->refresh(); } return ''; } /** * 小程序登录(code2session) * @param string $code wx.login 获取的 code * @return array {openid, session_key, unionid?} */ public function login($code) { $res = $this->app->getUtils()->codeToSession($code); Log::info('login-res' . json_encode([$res], JSON_UNESCAPED_UNICODE)); return $res; } /** * 解密开放数据(手机号等) * @param $sessionKey code2session 返回的 session_key * @param $iv * @param $encryptedData * @return array */ public function decryData($sessionKey, $iv, $encryptedData) { return $this->app->getUtils()->decryptSession($sessionKey, $iv, $encryptedData); } /** * 统一下单(小程序 JSAPI 支付, v3) * @param string $body 商品描述 * @param string $out_trade_no 商户订单号 * @param float $total_fee 金额(单位:元) * @param string $openid 支付用户 openid * @param string $notify_url 支付结果通知地址,留空使用配置默认 * @return array 含 prepay_id;失败时含 message 等错误信息 */ public function unify($body, $out_trade_no, $total_fee, $openid, $notify_url = '') { $params = [ 'appid' => $this->config['app_id'], 'mchid' => (string)$this->payApp->getMerchant()->getMerchantId(), 'description' => $body, 'out_trade_no' => $out_trade_no, 'notify_url' => $notify_url ?: $this->config['notify_url'], 'amount' => ['total' => (int)round($total_fee * 100), 'currency' => 'CNY'], // 转为分 'payer' => ['openid' => $openid], ]; Log::info('unify-params' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $this->payApp->getClient()->postJson('v3/pay/transactions/jsapi', $params); $result = $response->toArray(false); Log::info('unify-res' . json_encode($result, JSON_UNESCAPED_UNICODE)); return $result; } /** * 小程序调起支付参数(wx.requestPayment) * @param string $prepayId * @return array {timeStamp, nonceStr, package, signType, paySign} */ public function sdkConfig($prepayId) { $config = $this->payApp->getUtils()->buildMiniAppConfig($prepayId, $this->config['app_id']); Log::info('buildMiniAppConfig-res' . json_encode($config, JSON_UNESCAPED_UNICODE)); return $config; } /** * 发送小程序订阅消息 * 只要走了订阅消息,就同步发送一条站内消息(失败不影响订阅消息主流程) * @param $user_id * @param $template_id * @param array $msgData */ public function sendMsg($user_id, $template_id, array $msgData = []) { $data = SubscribeMessageService::packMsg($user_id, $template_id, $msgData); $response = $this->app->getClient()->postJson('/cgi-bin/message/subscribe/send', $data); $res = $response->toArray(false); Log::info('发送订阅消息-res' . json_encode(['data' => $data, 'res' => $res], JSON_UNESCAPED_UNICODE)); //同步写站内消息(extra_json.page 直接复用订阅消息的跳转页面) try { MessageService::createBySubscribe($user_id, $template_id, $msgData, $data['page'] ?? ''); } catch (\Exception $e) { Log::error('订阅消息同步站内消息失败:' . json_encode([ 'user_id' => $user_id, 'template_id' => $template_id, 'error' => $e->getMessage(), ], JSON_UNESCAPED_UNICODE)); } } /** * 免确认收款转账(商家转账-用户已授权模式) * 接口:POST /v3/fund-app/mch-transfer/transfer-bills/transfer * 前置:用户需先通过 /v3/fund-app/mch-transfer/user-confirm-authorization 完成授权, * 转账时以授权单(out_authorization_no / authorization_id 二选一)定位收款人 * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4014399371 * @param string $order_sn 提现单号(out_bill_no, 字母或数字) * @param string $name 收款用户姓名(金额>=2000元时必须传,需加密) * @param float $amount 金额(单位:元) * @param string $out_authorization_no 商户侧授权单号 * @param string $desc 转账备注(≤32 字符) * @return array {out_bill_no, transfer_bill_no, state, transfer_amount, ...};失败时含 message */ public function mchPay($order_sn, $name, $amount, $out_authorization_no, $desc = '用户提现') { try { $params = [ 'appid' => $this->config['app_id'], 'out_bill_no' => $order_sn, 'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '', 'transfer_amount' => (int)round($amount * 100), // 转为分 'transfer_remark' => mb_substr($desc, 0, 32), // 最多 32 字符 //授权转账:以授权单定位收款人,不再传 openid 'out_authorization_no' => $out_authorization_no, //转账场景报备信息:必须与 transfer_scene_id 对应(1005=佣金报酬 需传「岗位类型」「报酬说明」两条) 'transfer_scene_report_infos' => $this->buildTransferSceneReportInfos($desc), ]; //规则:转账金额 >= 2,000 元时必须传入收款人姓名(加密) if ($amount >= 2000 && $name !== '') { $params['user_name'] = $this->encryptSensitive($name); } //含敏感字段加密时须带 Wechatpay-Serial(微信支付公钥ID/平台证书序列号) $client = $this->payApp->getClient(); $platformCerts = $this->config['platform_certs'] ?? []; if ($platformCerts) { $client = $client->withHeader('Wechatpay-Serial', (string)array_key_first($platformCerts)); } Log::info('商家转账-params' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $client->postJson('v3/fund-app/mch-transfer/transfer-bills/transfer', $params); $res = $response->toArray(false); Log::info('商家转账-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); return $res; } catch (\Exception $e) { Log::error('商家转账请求异常:' . $e->getMessage()); return [ 'message' => $e->getMessage(), ]; } } /** * 发起免确认收款授权(用户确认模式的前置授权) * 接口:POST /v3/fund-app/mch-transfer/user-confirm-authorization * 返回 package_info 供前端 wx.requestMerchantTransfer 拉起授权页 * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167 * @param string $out_authorization_no 商户侧授权单号(每用户唯一,未确认单30天后单号可复用) * @param string $openid 收款用户 openid * @param string $display_name 授权详情展示的开通账号名称(≤32字符) * @return array {out_authorization_no, state, create_time, package_info};失败时含 message */ public function applyTransferAuthorization($out_authorization_no, $openid, $display_name) { try { $params = [ 'appid' => $this->config['app_id'], 'out_authorization_no' => $out_authorization_no, 'openid' => $openid, 'transfer_scene_id' => $this->config['transfer_scene_id'] ?? '', 'user_display_name' => mb_substr($display_name, 0, 32), 'authorization_notify_url' => $this->config['transfer_authorization_notify_url'] ?? '', ]; Log::info('免确认收款授权-params' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $this->payApp->getClient()->postJson('v3/fund-app/mch-transfer/user-confirm-authorization', $params); $res = $response->toArray(false); Log::info('免确认收款授权-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); return $res; } catch (\Exception $e) { Log::error('免确认收款授权请求异常:' . $e->getMessage()); return [ 'message' => $e->getMessage(), ]; } } /** * 处理免确认收款授权结果回调(验签+解密),返回 PSR-7 响应 * 注意:handlePaid 只处理 TRANSACTION.SUCCESS,授权回调须用通用管道 * @param \Closure $handler function(PayMessage $message){ 业务处理;异常时抛出则返回失败 } * @return \Psr\Http\Message\ResponseInterface */ public function handleTransferAuthorizationNotify(\Closure $handler) { $traceId = date('YmdHis') . substr(md5(uniqid('', true)), 0, 6); Log::info('免确认收款授权回调[收到请求] trace=' . $traceId . ' ' . json_encode([ 'body' => (string)$this->payApp->getRequest()->getBody(), ], JSON_UNESCAPED_UNICODE)); $server = $this->payApp->getServer(); $server->with(function (PayMessage $message, \Closure $next) use ($handler, $traceId) { Log::info('免确认收款授权回调[解密数据] trace=' . $traceId . ' ' . json_encode($message->toArray(), JSON_UNESCAPED_UNICODE)); //回调签名验证(失败抛异常 -> serve 返回失败,微信将重试) $this->payApp->getValidator()->validate($this->payApp->getRequest()); $handler($message, $traceId); return $next($message); }); return $server->serve(); } /** * 构造转账场景报备信息 * 报备信息必须与 transfer_scene_id 一一对应,且 info_type 不可重复,否则接口会报 * 「未传入完整且对应的转账场景报备信息」: * 1000 现金营销 -> 活动名称 + 奖励说明 * 1005 佣金报酬 -> 岗位类型 + 报酬说明 * 可通过 extra/common.php 的 transfer_scene_report_infos 覆盖 * @param string $desc 转账备注,用于 info_content 缺省时兜底 * @return array */ private function buildTransferSceneReportInfos(string $desc = ''): array { $reportInfos = $this->config['transfer_scene_report_infos'] ?? []; if (!$reportInfos) { $sceneId = (string)($this->config['transfer_scene_id'] ?? '1005'); $reportInfos = $sceneId === '1000' ? [ ['info_type' => '活动名称', 'info_content' => $desc ?: '平台活动'], ['info_type' => '奖励说明', 'info_content' => $desc ?: '活动奖励'], ] : [ ['info_type' => '岗位类型', 'info_content' => '平台接单用户'], ['info_type' => '报酬说明', 'info_content' => $desc ?: '用户提现'], ]; } //info_type ≤15 字符、info_content ≤32 字符 return array_map(function ($item) use ($desc) { $infoType = mb_substr((string)($item['info_type'] ?? ''), 0, 15); $infoContent = mb_substr((string)($item['info_content'] ?? ''), 0, 32); if ($infoContent === '') { $infoContent = mb_substr($desc ?: '佣金报酬', 0, 32); } return [ 'info_type' => $infoType, 'info_content' => $infoContent, ]; }, $reportInfos); } /** * 查询免确认收款授权单(用于同步授权状态:授权回调丢失时以微信侧状态为准) * 接口:GET /v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/{no} * 文档:https://pay.weixin.qq.com/doc/v3/merchant/4015901167 * @param string $out_authorization_no 商户侧授权单号 * @param bool $isDisplayAuthorization 是否请求展示授权信息页的 package_info。 * 注意:仅当授权已生效(TAKING_EFFECT)时才有效; * 待确认/已关闭状态下微信会返回 INVALID_REQUEST 且不返回 state, * 因此“查状态”必须传 false,拉起授权确认页的 package 应取申请接口的响应 * @return array {out_authorization_no, authorization_id, state, ...};失败时含 message */ public function queryTransferAuthorization($out_authorization_no, $isDisplayAuthorization = false) { try { $options = []; if ($isDisplayAuthorization) { $options['query'] = ['is_display_authorization' => 'true']; } $response = $this->payApp->getClient()->get( 'v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/' . rawurlencode($out_authorization_no), $options ); $res = $response->toArray(false); Log::info('查询免确认收款授权-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); return $res; } catch (\Exception $e) { Log::error('查询免确认收款授权异常:' . $e->getMessage()); return [ 'message' => $e->getMessage(), ]; } } /** * 查询新版商家转账单状态 * @param string $out_bill_no 商户单号 * @return array 转账单状态及明细 */ public function queryTransferBill($out_bill_no) { try { $response = $this->payApp->getClient()->get( "v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{$out_bill_no}" ); $res = $response->toArray(false); Log::info('查询商家转账单-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); return $res; } catch (\Exception $e) { Log::error('查询商家转账单异常:' . $e->getMessage()); return [ 'message' => $e->getMessage(), ]; } } /** * 敏感信息加密:新版商家转账的收款人姓名需使用微信支付公钥加密(RSA-OAEP) * @param string $plaintext * @return string base64 密文 * @throws \Exception */ public function encryptSensitive(string $plaintext): string { if ($plaintext === '') { return ''; } $platformCerts = $this->config['platform_certs'] ?? []; if (!$platformCerts) { throw new \Exception('未配置微信支付公钥(platform_certs),无法加密收款人姓名'); } //取第一个可用公钥(微信支付公钥ID -> 公钥文件) $certFile = reset($platformCerts); if (!is_file($certFile)) { throw new \Exception('微信支付公钥文件不存在:' . $certFile); } $publicKey = openssl_pkey_get_public(file_get_contents($certFile)); if (!$publicKey) { throw new \Exception('微信支付公钥解析失败'); } $encrypted = ''; if (!openssl_public_encrypt($plaintext, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) { throw new \Exception('收款人姓名加密失败'); } return base64_encode($encrypted); } /** * 查询商家转账单状态(提现已改用新版接口,统一走新版查询) * @param string $apply_sn 提现单号(out_bill_no) * @return array 转账单状态及明细 */ public function queryBalance($apply_sn) { //提现单已改用新版商家转账接口创建,查询统一走新版,避免查不到单据 return $this->queryTransferBill($apply_sn); } /** * 微信支付退款(v3) * @param string $order_sn 商户订单号 * @param string $refund_no 商户退款单号 * @param float $actual_amount 原订单金额(单位:元) * @param float $refund_amount 退款金额(单位:元) * @param string $desc 退款原因 * @return array */ public function refund($order_sn, $refund_no, $actual_amount, $refund_amount, $desc = '出票失败退款') { $params = [ 'out_trade_no' => $order_sn, 'out_refund_no' => $refund_no, 'reason' => $desc, 'amount' => [ 'refund' => (int)bcmul($refund_amount, 100), // 转为分 'total' => (int)bcmul($actual_amount, 100), 'currency' => 'CNY', ], ]; Log::info('微信订单退款-params' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $this->payApp->getClient()->postJson('v3/refund/domestic/refunds', $params); $res = $response->toArray(false); Log::info('微信订单退款-res' . json_encode(['param' => func_get_args(), 'res' => $res], JSON_UNESCAPED_UNICODE)); return $res; } /** * 查询退款(v3) * @param string $refund_no 商户退款单号 * @return array */ public function refundQuery($refund_no) { $response = $this->payApp->getClient()->get("v3/refund/domestic/refunds/{$refund_no}"); $res = $response->toArray(false); Log::info('微信订单退款查询-res' . json_encode(['param' => func_get_args(), 'res' => $res], JSON_UNESCAPED_UNICODE)); return $res; } /** * 处理微信支付 v3 回调(验签+解密),返回 PSR-7 响应 * @param \Closure $paidHandler function(PayMessage $message){ 业务处理;异常时抛出则返回失败 } * @return \Psr\Http\Message\ResponseInterface */ public function handlePayNotify(\Closure $paidHandler) { $request = $this->payApp->getRequest(); $traceId = date('YmdHis') . substr(md5(uniqid('', true)), 0, 6); // 【日志1】回调原始报文:确认微信有没有真的打过来、打到哪个地址 Log::info('微信支付回调[收到请求] trace=' . $traceId . ' ' . json_encode([ 'method' => $request->getMethod(), 'uri' => (string)$request->getUri(), 'headers' => self::getNotifyHeaders($request), 'body' => (string)$request->getBody(), ], JSON_UNESCAPED_UNICODE)); $server = $this->payApp->getServer(); $server->handlePaid(function (PayMessage $message, \Closure $next) use ($paidHandler, $traceId) { // 【日志2】解密后的业务数据(含 out_trade_no / trade_state / amount) Log::info('微信支付回调[解密数据] trace=' . $traceId . ' ' . json_encode($message->toArray(), JSON_UNESCAPED_UNICODE)); // 回调签名验证(官方推荐,验签失败抛异常 -> serve 返回失败,微信将重试) try { $this->payApp->getValidator()->validate($this->payApp->getRequest()); Log::info('微信支付回调[验签通过] trace=' . $traceId); } catch (\Throwable $e) { Log::error('微信支付回调[验签失败] trace=' . $traceId . ' msg=' . $e->getMessage()); throw $e; } try { $paidHandler($message, $traceId); } catch (\Throwable $e) { Log::error('微信支付回调[业务处理异常] trace=' . $traceId . ' msg=' . $e->getMessage() . ' file=' . $e->getFile() . ':' . $e->getLine()); throw $e; } Log::info('微信支付回调[业务处理完成] trace=' . $traceId); return $next($message); }); $response = $server->serve(); // 注意:先转字符串(内部会 seek(0))并 rewind,保证控制器后续仍能读取 body 回给微信 $responseBody = (string)$response->getBody(); $response->getBody()->rewind(); // 【日志3】应答给微信的结果:status=200 且 code=SUCCESS 才算回调成功 Log::info('微信支付回调[响应微信] trace=' . $traceId . ' status=' . $response->getStatusCode() . ' body=' . $responseBody); return $response; } /** * 提取回调请求中与验签相关的请求头(便于排查签名/证书问题) * @param \Psr\Http\Message\ServerRequestInterface $request * @return array */ private static function getNotifyHeaders($request): array { $headers = []; foreach ($request->getHeaders() as $name => $values) { if (stripos($name, 'wechatpay') !== false || in_array(strtolower($name), ['content-type', 'user-agent'])) { $headers[$name] = implode(', ', $values); } } return $headers; } }