config = config('common.wechat'); // 小程序 $this->app = new MiniAppApplication([ 'app_id' => $this->config['app_id'], 'secret' => $this->config['secret'], '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], ]); } /** * 小程序登录(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)); } /** * 商家转账到零钱(v3, 原 v2 企业付款已停用) * 需在商户平台开通「商家转账」产品权限 * @param string $order_sn 提现单号(字母或数字) * @param string $openid * @param string $name 收款用户姓名(商家转账不校验时可留空) * @param float $amount 金额(单位:元) * @param string $desc * @return array {out_batch_no, batch_id, create_time};失败时含 message */ public function mchPay($order_sn, $openid, $name, $amount, $desc = '用户提现') { $params = [ 'appid' => $this->config['app_id'], 'out_batch_no' => $order_sn, 'batch_name' => '用户提现', 'batch_remark' => $desc, 'total_amount' => (int)round($amount * 100), // 转为分 'total_num' => 1, 'transfer_detail_list' => [ [ 'out_detail_no' => $order_sn . 'D1', // 明细单号(字母数字,保持唯一) 'transfer_amount' => (int)round($amount * 100), 'transfer_remark' => $desc, 'openid' => $openid, ], ], ]; Log::info('商家转账-params' . json_encode($params, JSON_UNESCAPED_UNICODE)); $response = $this->payApp->getClient()->postJson('v3/transfer/batches', $params); $res = $response->toArray(false); Log::info('商家转账-res' . json_encode($res, JSON_UNESCAPED_UNICODE)); return $res; } /** * 查询商家转账批次状态 * @param string $apply_sn 商家转账单号(out_batch_no) * @return array 转账批次状态及明细 */ public function queryBalance($apply_sn) { $response = $this->payApp->getClient()->get("v3/transfer/batches/out-batch-no/{$apply_sn}", [ 'query' => [ 'need_query_detail' => 'true', 'offset' => 0, 'limit' => 20, 'detail_status' => 'ALL', ], ]); $res = $response->toArray(false); Log::info('商家转账查询-res' . json_encode(['apply_sn' => $apply_sn, 'res' => $res], JSON_UNESCAPED_UNICODE)); return $res; } /** * 微信支付退款(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; } }