current(); if (!$this->configured($app)) { UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付'); } $amount = (int) $order['total_amount'] - (int) $order['paid_amount']; if ($amount <= 0) { UtilsService::getInstance()->errorThrow('该订单无需支付'); } // 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no, // 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账 $outTradeNo = 'WX' . $order['id'] . 'T' . time(); OrderPaymentModel::insert([ 'order_id' => (int) $order['id'], 'pay_type' => OrderModel::PAY_TYPE_WECHAT, 'amount' => $amount, 'out_trade_no' => $outTradeNo, 'status' => OrderPaymentModel::STATUS_AUDITING, 'created_at' => time(), ]); $body = [ 'appid' => $app['app_id'], 'mchid' => $app['mch_id'], 'description' => '订单 ' . $order['order_no'], 'out_trade_no' => $outTradeNo, 'notify_url' => $app['notify_url'], 'amount' => ['total' => $amount, 'currency' => 'CNY'], 'payer' => ['openid' => $openId], ]; $path = '/v3/pay/transactions/jsapi'; $payload = json_encode($body, JSON_UNESCAPED_UNICODE); $response = Http::withHeaders([ 'Authorization' => $this->authorization('POST', $path, $payload, $app), 'Accept' => 'application/json', 'Content-Type' => 'application/json', ])->withBody($payload, 'application/json')->post($this->baseUrl . $path); $result = $response->json(); if (empty($result['prepay_id'])) { UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body())); } return $this->buildPayParams($app, (string) $result['prepay_id']); } /** * 处理支付回调 * * @param array $headers Wechatpay-* 头 * @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败) */ public function handleNotify(array $headers, string $rawBody): array { $app = WxAppService::getInstance()->current(); if (!$this->verifySignature($headers, $rawBody, $app)) { return ['code' => 'FAIL', 'message' => '验签失败']; } $body = json_decode($rawBody, true) ?: []; $resource = $body['resource'] ?? []; $plain = $this->decryptResource($resource, (string) $app['mch_key']); if (empty($plain['out_trade_no'])) { return ['code' => 'FAIL', 'message' => '报文缺少订单号']; } if (($plain['trade_state'] ?? '') !== 'SUCCESS') { // 非成功态直接应答成功,避免微信持续重推 return ['code' => 'SUCCESS', 'message' => 'OK']; } OrderCoreService::getInstance()->confirmWechatPay( (string) $plain['out_trade_no'], (string) ($plain['transaction_id'] ?? ''), (int) ($plain['amount']['total'] ?? 0) ); return ['code' => 'SUCCESS', 'message' => 'OK']; } /** * 小程序端调起支付的签名参数 */ private function buildPayParams(array $app, string $prepayId): array { $timestamp = (string) time(); $nonce = bin2hex(random_bytes(16)); $package = 'prepay_id=' . $prepayId; $message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n"; return [ 'appId' => $app['app_id'], 'timeStamp' => $timestamp, 'nonceStr' => $nonce, 'package' => $package, 'signType' => 'RSA', 'paySign' => $this->sign($message, (string) $app['mch_private_key']), ]; } /** * APIv3 Authorization 头 */ private function authorization(string $method, string $path, string $body, array $app): string { $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; $signature = $this->sign($message, (string) $app['mch_private_key']); return sprintf( 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', $app['mch_id'], $nonce, $timestamp, $app['mch_serial_no'], $signature ); } private function sign(string $message, string $privateKey): string { $key = openssl_pkey_get_private($privateKey); if ($key === false) { UtilsService::getInstance()->errorThrow('商户私钥无效'); } openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption'); return base64_encode($signature); } /** * 回调验签 * * 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。 */ private function verifySignature(array $headers, string $rawBody, array $app): bool { $publicKey = (string) ($app['platform_public_key'] ?? ''); if ($publicKey === '') { return false; } $timestamp = (string) ($headers['wechatpay-timestamp'] ?? ''); $nonce = (string) ($headers['wechatpay-nonce'] ?? ''); $signature = (string) ($headers['wechatpay-signature'] ?? ''); if ($timestamp === '' || $nonce === '' || $signature === '') { return false; } // 时间戳偏移超过 5 分钟视为重放 if (abs(time() - (int) $timestamp) > 300) { return false; } $message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n"; $key = openssl_pkey_get_public($publicKey); if ($key === false) { return false; } return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1; } /** * 解密回调报文(AEAD_AES_256_GCM) */ private function decryptResource(array $resource, string $apiV3Key): array { $ciphertext = base64_decode((string) ($resource['ciphertext'] ?? '')); $nonce = (string) ($resource['nonce'] ?? ''); $associated = (string) ($resource['associated_data'] ?? ''); if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') { return []; } $tagLength = 16; $tag = substr($ciphertext, -$tagLength); $data = substr($ciphertext, 0, -$tagLength); $plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated); return $plain === false ? [] : (json_decode($plain, true) ?: []); } }