config/services.php(env) */ class WechatPayService { /** 通知应答码:成功 / 失败(微信收到 SUCCESS 才停止重推) */ public const string NOTIFY_ACK_OK = 'SUCCESS'; public const string NOTIFY_ACK_FAIL = 'FAIL'; /** 交易状态:支付成功(交易查询 / 支付通知的 trade_state) */ public const string TRADE_STATE_SUCCESS = 'SUCCESS'; protected ?Application $application = null; /** * 统一下单(JSAPI:小程序 / 公众号 H5 通用) * * @param string $paymentNo 商户订单号(本系统支付单号) * @param string $amountYuan 金额(元,两位小数) * @param string $openid 付款人 openid(按下单 appid 维度:小程序 openid 或公众号 openid) * @param string $description 订单标题 * @param string $appid 下单应用 appid(公众号 JSAPI 传公众号 appid;留空取配置的小程序 appid) * @return array 调起支付参数(appId/timeStamp/nonceStr/package/signType/paySign, * 小程序喂 wx.requestPayment,公众号 H5 喂 WeixinJSBridge getBrandWCPayRequest,结构同构) * @throws RepositoryException 未配置或下单失败 */ public function createOrder(string $paymentNo, string $amountYuan, string $openid, string $description, string $appid = ''): array { $appid = trim($appid) !== '' ? trim($appid) : $this->appid(); $result = $this->call('POST', '/v3/pay/transactions/jsapi', [ 'appid' => $appid, 'mchid' => $this->config('mch_id'), 'description' => mb_substr($description, 0, 127), 'out_trade_no' => $paymentNo, 'notify_url' => $this->notifyUrl(), 'amount' => [ 'total' => (int) bcmul($amountYuan, '100'), // APIv3 金额单位:分 'currency' => 'CNY', ], 'payer' => ['openid' => $openid], ], '下单'); $prepayId = (string) ($result['prepay_id'] ?? ''); if ($prepayId === '') { Log::driver('pay')->error('微信支付下单应答缺少 prepay_id', ['response' => $result]); throw new RepositoryException('微信支付下单失败:应答缺少 prepay_id'); } // buildMiniAppConfig 即 buildBridgeConfig(小程序 wx.requestPayment 与公众号 WeixinJSBridge 参数同构) $params = $this->app()->getUtils()->buildMiniAppConfig($prepayId, $appid); Log::driver('pay')->info('微信支付下单成功', ['out_trade_no' => $paymentNo, 'prepay_id' => $prepayId, 'appid' => $appid]); return $params; } /** * 交易查询(按商户订单号) * * @param string $paymentNo 商户订单号(本系统支付单号) * @return array 微信交易报文(trade_state/transaction_id/success_time/amount.total 等) * @throws RepositoryException 查询失败 */ public function queryOrder(string $paymentNo): array { return $this->call('GET', '/v3/pay/transactions/out-trade-no/' . $paymentNo, [ 'mchid' => $this->config('mch_id'), ], '查询'); } /** * 解密支付结果后台通知(配置平台证书/公钥时先验签,再以 APIv3 密钥 AES-256-GCM 解密) * * @return array 解密后的交易报文(out_trade_no/trade_state/transaction_id/success_time/amount 等) * @throws RepositoryException 验签或解密失败 */ public function decryptNotify(Request $request): array { Log::driver('pay')->info('微信支付通知接收', ['body' => $request->getContent()]); $app = $this->app(); $server = $app->getServer(); $server->setRequestFromSymfonyRequest($request); // 配置平台证书/公钥时校验 Wechatpay-Signature;未配置时依赖 AES-GCM 认证解密(APIv3 密钥仅微信与商户持有) if ($this->config('platform_cert') !== '' && $this->config('platform_serial') !== '') { try { $app->getValidator()->validate($server->getRequest()); } catch (Throwable $e) { throw new RepositoryException('微信支付通知验签失败:' . $e->getMessage()); } } try { $message = $server->getRequestMessage(); } catch (Throwable $e) { throw new RepositoryException('微信支付通知解密失败:' . $e->getMessage()); } $params = $message->toArray(); Log::driver('pay')->info('微信支付通知解密', ['params' => $params]); return $params; } /** * 支付结果后台通知地址(统一下单时上送的 notify_url) */ public function notifyUrl(): string { return rtrim((string) config('app.url'), '/') . '/mini/payment/wechat-notify'; } /** * 注入 EasyWeChat Application(测试 mock 用) */ public function setApplication(Application $application): static { $this->application = $application; return $this; } /** * EasyWeChat Pay Application(按当前配置懒加载构建) * * @throws RepositoryException 必填配置缺失或密钥无法解析 */ public function app(): Application { if ($this->application !== null) { return $this->application; } $mchId = $this->config('mch_id'); if ($mchId === '') { throw new RepositoryException('微信支付未配置商户号,请联系管理员'); } $platformCerts = []; $platformCert = $this->config('platform_cert'); $platformSerial = $this->config('platform_serial'); if ($platformCert !== '' && $platformSerial !== '') { $platformCerts[$platformSerial] = $this->pemContent($platformCert, 'CERTIFICATE'); } try { $this->application = new Application([ 'mch_id' => $mchId, 'private_key' => $this->pemContent($this->config('private_key'), 'PRIVATE'), 'certificate' => $this->pemContent($this->config('certificate'), 'CERTIFICATE'), 'secret_key' => $this->config('secret_key'), 'platform_certs' => $platformCerts, ]); } catch (RepositoryException $e) { throw $e; } catch (Throwable $e) { throw new RepositoryException('微信支付配置错误:' . $e->getMessage()); } return $this->application; } /** * 调起微信 v3 接口并处理应答(通讯/业务失败统一抛 RepositoryException) * * @param array $payload POST 时为 JSON 报文,GET 时为 query 参数 * @return array 应答报文 * @throws RepositoryException */ protected function call(string $method, string $uri, array $payload, string $action): array { try { $response = $method === 'GET' ? $this->app()->getClient()->get($uri, ['query' => $payload]) : $this->app()->getClient()->postJson($uri, $payload); } catch (RepositoryException $e) { throw $e; } catch (Throwable $e) { Log::driver('pay')->error('微信支付' . $action . '通讯异常', ['uri' => $uri, 'error' => $e->getMessage()]); throw new RepositoryException('微信支付' . $action . '通讯异常,请稍后重试'); } $body = $response->getContent(false); Log::driver('pay')->info('微信支付' . $action . '应答', [ 'uri' => $uri, 'status' => $response->getStatusCode(), 'body' => $body, ]); $result = json_decode($body, true); if ($response->isFailed()) { $message = is_array($result) ? (string) ($result['message'] ?? $result['code'] ?? '') : ''; throw new RepositoryException('微信支付' . $action . '失败:' . ($message !== '' ? $message : '未知错误')); } return is_array($result) ? $result : []; } /** * 下单小程序 appid(默认取小程序自身 appid) */ protected function appid(): string { $appid = $this->config('appid'); if ($appid === '') { $appid = (string) site_config('wechat.mini_appid', ''); } if ($appid === '') { $appid = (string) config('services.wechat.mini.appid', ''); } return $appid; } /** * 密钥/证书内容归一化为 PEM:支持完整 PEM、base64 单行、file:// 路径或本地文件路径 * * @throws RepositoryException 未配置 */ protected function pemContent(string $value, string $kind): string { $value = trim($value); if ($value === '') { $name = $kind === 'PRIVATE' ? '商户API私钥' : '证书'; throw new RepositoryException('微信支付未配置' . $name . ',请联系管理员'); } if (str_starts_with($value, 'file://') || str_contains($value, '-----BEGIN')) { return $value; } if (is_file($value)) { return 'file://' . $value; } // base64 单行内容包装为 PEM $header = $kind === 'PRIVATE' ? 'PRIVATE' : 'CERTIFICATE'; return "-----BEGIN {$header}-----\n" . wordwrap($value, 64, "\n", true) . "\n-----END {$header}-----"; } /** * 读取支付配置:后台站点配置优先,为空回退 config/services.php(env) */ protected function config(string $key): string { $value = site_config('wxpay.' . $key, ''); if ($value === null || $value === '') { $value = config('services.wxpay.' . $key, ''); } return trim((string) $value); } }