config/services.php(env) */ class WangpuPayService { /** 网关应答成功码 */ public const string CODE_SUCCESS = '0000'; /** 通知应答码:成功(网关收到 {"code":"00"} 才认为通知成功) */ public const string NOTIFY_ACK_OK = '00'; /** 订单状态:支付成功(交易查询 / 后台通知的 order_status) */ public const int ORDER_STATUS_PAID = 1; /** * 统一下单(小程序 JSAPI) * * @param array $params 业务参数(mer_order_id/order_amt/open_id/notifyurl 等) * @return array 网关 data 解密后的业务报文(含 order_id/调起支付参数) * @throws RepositoryException 网关应答失败 */ public function createOrder(array $params): array { return $this->request('/industrial/payment/order', array_merge([ 'mer_no' => $this->config('mer_no'), 'mer_code' => $this->config('mer_code'), 'term_code' => $this->config('term_code'), 'payway_code' => $this->config('payway_code'), 'organiz_no' => $this->config('organiz_no'), ], $params)); } /** * 交易查询(单笔订单支付状态同步) * * @param string $merOrderId 商户唯一订单号(本系统 payment_no) * @param string $orderId 旺铺订单号(可选,与 mer_order_id 同时上送时网关以 order_id 为准) * @return array 网关 data 解密后的业务报文 * @throws RepositoryException 网关应答失败 */ public function queryOrder(string $merOrderId, string $orderId = ''): array { $params = [ 'organiz_no' => $this->config('organiz_no'), 'mer_order_id' => $merOrderId, ]; if ($orderId !== '') { $params['order_id'] = $orderId; } return $this->request('/industrial/query/order', $params); } /** * 验证支付结果后台通知签名(明文表单报文 + MD5 验签,验签通过即视为合法通知) * * @param array $params 通知表单报文(含 sign) * @return array 原样返回的通知报文(mer_order_id/order_status/order_amt/trade_no 等) * @throws RepositoryException 缺少签名 / 加签Key未配置 / 验签失败 */ public function verifyNotify(array $params): array { Log::driver('pay')->info('旺铺支付通知接收', ['params' => $params]); $sign = strtoupper(trim((string) ($params['sign'] ?? ''))); if ($sign === '' || ! hash_equals($this->sign($params), $sign)) { Log::driver('pay')->warning('旺铺支付通知验签失败', ['params' => $params]); throw new RepositoryException('通知报文验签失败'); } return $params; } /** * 通知报文 MD5 加签:剔除 sign 与空值 → 按参数名 ASCII 升序拼 key=value&... → 末尾拼 &key=加签Key → MD5 大写 * * @param array $params 通知报文 * @throws RepositoryException 加签Key未配置 */ protected function sign(array $params): string { unset($params['sign']); $params = array_filter($params, static fn (mixed $value): bool => $value !== null && $value !== ''); ksort($params, SORT_STRING); $str = implode('&', array_map(static fn (string $key, mixed $value): string => $key . '=' . $value, array_keys($params), $params)); $signKey = $this->config('sign_key'); if ($signKey === '') { throw new RepositoryException('旺铺支付未配置通知加签Key,请联系管理员'); } return strtoupper(md5($str . '&key=' . $signKey)); } /** * 支付结果后台通知地址(统一下单时上送的 notifyurl) */ public function notifyUrl(): string { return rtrim((string) config('app.url'), '/') . '/index.php/mini/payment/notify'; } /** * 支付方式代码(公众号 H5 场景优先取 mp_payway_code,留空回退 payway_code) */ public function paywayCode(string $scene = 'mini'): string { if ($scene === 'mp') { $mpPayway = $this->config('mp_payway_code'); if ($mpPayway !== '') { return $mpPayway; } } return $this->config('payway_code'); } /** * 网关 POST 请求:业务报文加密装信封 → JSON 发送 → 校验应答码 → 解密应答 * * @param array $params 接口业务参数 * @return array 应答 data 解密后的业务报文 * @throws RepositoryException 通讯失败 / 应答码非成功 / 应答解密失败 */ protected function request(string $path, array $params): array { $baseUrl = rtrim($this->config('base_url'), '/'); if ($baseUrl === '') { throw new RepositoryException('旺铺支付未配置网关地址,请联系管理员'); } $envelope = $this->encryptEnvelope($params); Log::driver('pay')->info('旺铺网关请求', ['path' => $path, 'params' => $params, 'envelope' => $envelope]); $response = Http::timeout(15)->post($baseUrl . $path, $envelope); Log::driver('pay')->info('旺铺网关响应', ['path' => $path, 'status' => $response->status(), 'body' => $response->body()]); $result = $response->json(); if (! is_array($result)) { Log::driver('pay')->error('旺铺网关应答异常', [ 'path' => $path, 'status' => $response->status(), 'body' => $response->body(), ]); throw new RepositoryException('支付网关通讯异常,请稍后重试'); } if (($result['code'] ?? '') !== self::CODE_SUCCESS) { Log::driver('pay')->warning('旺铺网关应答失败', [ 'path' => $path, 'response' => $result, ]); throw new RepositoryException('支付网关下单失败:' . ($result['msg'] ?? '未知错误')); } try { $data = $this->decryptEnvelope($result); } catch (RepositoryException $e) { Log::driver('pay')->error('旺铺网关应答解密失败', ['path' => $path, 'error' => $e->getMessage()]); throw new RepositoryException('支付网关应答解密失败,请联系管理员'); } Log::driver('pay')->info('旺铺网关应答解密', ['path' => $path, 'data' => $data]); return $data; } /** * 业务报文加密装信封:AES-128-ECB 加密报文 + 平台公钥加密 AES 密钥 * * @param array $params 业务参数 * @return array 请求信封 * @throws RepositoryException 加密失败 */ protected function encryptEnvelope(array $params): array { $key = Str::random(16); // AES 密钥(16 字节) return [ 'serialNo' => Str::random(32), // 请求流水号 'version' => '1.0', 'timestamp' => now()->format('YmdHis'), 'data' => urlencode($this->aesEncrypt($params, $key)), 'signature' => urlencode($this->rsaPublicEncrypt($key)), 'extras' => '', 'organizNo' => $this->config('organiz_no'), ]; } /** * 解密网关信封:商户私钥解 signature 得 AES 密钥 → AES 解 data → JSON * * @param array $envelope * @return array * @throws RepositoryException 解密失败 */ protected function decryptEnvelope(array $envelope): array { $data = (string) ($envelope['data'] ?? ''); $signature = (string) ($envelope['signature'] ?? ''); if ($data === '' || $signature === '') { throw new RepositoryException('报文缺少 data/signature 字段'); } $plain = $this->aesDecrypt($data, $this->rsaPrivateDecrypt($signature)); $decoded = json_decode($plain, true); if (! is_array($decoded)) { throw new RepositoryException('报文 JSON 解析失败'); } return $decoded; } /** * AES-128-ECB 加密(base64 输出,与官方示例算法一致) * * @param array $data * @throws RepositoryException 加密失败 */ protected function aesEncrypt(array $data, string $key): string { $cipher = openssl_encrypt((string) json_encode($data), 'AES-128-ECB', $key, OPENSSL_RAW_DATA); if ($cipher === false) { throw new RepositoryException('报文 AES 加密失败'); } return base64_encode($cipher); } /** * AES-128-ECB 解密(base64 输入,与官方示例算法一致) * * @throws RepositoryException 解密失败 */ protected function aesDecrypt(string $cipherBase64, string $key): string { $plain = openssl_decrypt((string) base64_decode($cipherBase64), 'AES-128-ECB', $key, OPENSSL_RAW_DATA); if (! is_string($plain)) { throw new RepositoryException('报文 AES 解密失败'); } return $plain; } /** * RSA 平台公钥加密(base64 输出,用于加密 AES 密钥) * * @throws RepositoryException 加密失败 */ protected function rsaPublicEncrypt(string $data): string { if (! openssl_public_encrypt($data, $encrypted, $this->pemKey('public_key', 'PUBLIC'))) { throw new RepositoryException('旺铺平台公钥加密失败,请检查密钥配置'); } return base64_encode($encrypted); } /** * RSA 商户私钥解密(base64 输入,用于解出 AES 密钥) * * @throws RepositoryException 解密失败 */ protected function rsaPrivateDecrypt(string $cipherBase64): string { $ok = openssl_private_decrypt((string) base64_decode($cipherBase64), $decrypted, $this->pemKey('private_key', 'PRIVATE')); if (! $ok || ! is_string($decrypted)) { throw new RepositoryException('商户私钥解密失败,请检查密钥配置'); } return $decrypted; } /** * 读取密钥并包装为 PEM 格式(配置存 base64 单行或完整 PEM 均可,自动剔除头尾与空白字符) * * 预检密钥有效性(避免 openssl 抛警告式错误),并识别公/私钥填反的常见配置错误 * * @throws RepositoryException 未配置或密钥无法解析 */ protected function pemKey(string $configKey, string $kind): string { $name = $kind === 'PUBLIC' ? '旺铺平台公钥' : '商户RSA私钥'; $body = (string) preg_replace('/-----[A-Z ]*KEY-----|\s+/', '', $this->config($configKey)); if ($body === '') { throw new RepositoryException('旺铺支付未配置' . $name . ',请联系管理员'); } $wrap = static fn (string $header): string => "-----BEGIN {$header} KEY-----\n" . wordwrap($body, 64, "\n", true) . "\n-----END {$header} KEY-----"; if ($kind === 'PUBLIC') { // 公钥兼容 SPKI(PUBLIC KEY)与 PKCS#1(RSA PUBLIC KEY)两种格式 foreach (['PUBLIC', 'RSA PUBLIC'] as $header) { if (openssl_pkey_get_public($wrap($header)) !== false) { return $wrap($header); } } $isPrivate = openssl_pkey_get_private($wrap('PRIVATE')) !== false; throw new RepositoryException($name . '配置错误' . ($isPrivate ? '(当前内容是私钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置'); } $pem = $wrap('PRIVATE'); if (openssl_pkey_get_private($pem) === false) { $isPublic = openssl_pkey_get_public($wrap('PUBLIC')) !== false; throw new RepositoryException($name . '配置错误' . ($isPublic ? '(当前内容是公钥,请检查是否填反)' : '(内容无法解析)') . ',请检查支付配置'); } return $pem; } /** * 读取网关配置:后台站点配置优先,为空回退 config/services.php(env) */ protected function config(string $key): string { $value = site_config('wangpu.' . $key, ''); if ($value === null || $value === '') { $value = config('services.wangpu.' . $key, ''); } return trim((string) $value); } }