$offerId, 'buyQuantity' => $buyQuantity, 'env' => self::env(), 'currencyType' => 'CNY', 'outTradeNo' => $outTradeNo, 'attach' => (string)$orderNo, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($signData === false) { throw new \Exception('虚拟支付参数编码失败'); } $sessionKey = self::sessionKeyByOpenid($openid); Cache::set(self::TRADE_PREFIX . $outTradeNo, (string)$orderNo, self::CACHE_TTL); Cache::set(self::LATEST_PREFIX . $orderNo, $outTradeNo, self::CACHE_TTL); Log::info('虚拟支付下单 ' . json_encode([ 'order_no' => $orderNo, 'out_trade_no' => $outTradeNo, 'buy_quantity' => $buyQuantity, 'env' => self::env(), ], JSON_UNESCAPED_UNICODE)); return [ 'mode' => 'short_series_coin', 'signData' => $signData, 'paySig' => hash_hmac('sha256', 'requestVirtualPayment&' . $signData, self::appKey()), 'signature' => hash_hmac('sha256', $signData, $sessionKey), ]; } /** * 现金单已支付时扣减代币并入账。没有可确认的支付单时返回 false,调用方再拉起新的支付 * @param string $orderNo * @param string $openid * @return bool * @throws \Exception */ public static function settleIfPaid($orderNo, $openid) { $order = OrderService::getOne(['order_no' => $orderNo]); if (!$order) { throw new \Exception('订单不存在'); } if ((int)$order->order_status !== OrderService::ORDER_STATUS_WAIT_PAY) { return in_array((int)$order->order_status, [ OrderService::ORDER_STATUS_WAIT_SHIP, OrderService::ORDER_STATUS_SHIPPED, OrderService::ORDER_STATUS_FINISHED, ], true); } $outTradeNo = Cache::get(self::LATEST_PREFIX . $orderNo); if (!$outTradeNo) { return false; } $res = self::queryOrder($openid, $outTradeNo); if ((int)($res['errcode'] ?? -1) !== 0) { Log::info('虚拟支付查单未支付 ' . json_encode([ 'order_no' => $orderNo, 'out_trade_no' => $outTradeNo, 'res' => $res, ], JSON_UNESCAPED_UNICODE)); return false; } $wxOrder = $res['order'] ?? []; $status = (int)($wxOrder['status'] ?? 0); // 2已支付待发货 3发货中 4已发货。代币由平台入账,这三种都表示现金已支付 if (!in_array($status, [2, 3, 4], true)) { return false; } self::deductCoins($order, $openid, $wxOrder); return true; } /** * 处理虚拟支付消息推送 * @param array $msg * @return array * @throws \Exception */ public static function handleMessage(array $msg) { $event = (string)($msg['Event'] ?? ''); if ($event === 'xpay_coin_pay_notify') { self::finishFromCoinNotify($msg); return ['ErrCode' => 0, 'ErrMsg' => 'success']; } if ($event === 'xpay_refund_notify') { self::handleRefundNotify($msg); return ['ErrCode' => 0, 'ErrMsg' => 'success']; } if ($event === 'xpay_subscribe_ios_refund_query_notify') { return self::iosRefundQuery($msg); } Log::info('虚拟支付推送 ' . $event . ' ' . json_encode($msg, JSON_UNESCAPED_UNICODE)); return ['ErrCode' => 0, 'ErrMsg' => 'success']; } /** * 解密安全模式下的消息,返回明文字符串 * @param string $encrypt * @param string $msgSignature * @param string $nonce * @param string $timestamp * @return string * @throws \Exception */ public static function decryptMessage($encrypt, $msgSignature, $nonce, $timestamp) { $payConfig = self::payConfig(); $wechat = config('common.wechat'); $token = (string)($payConfig['msg_token'] ?? ''); $aesKey = (string)($payConfig['aes_key'] ?? ''); $appId = is_array($wechat) ? (string)($wechat['app_id'] ?? '') : ''; if ($token === '' || $aesKey === '' || $appId === '') { throw new \Exception('未配置虚拟支付消息加解密参数'); } $encryptor = new Encryptor($appId, $token, $aesKey); return $encryptor->decrypt($encrypt, $msgSignature, $nonce, $timestamp); } /** * @param string $openid * @param string $outTradeNo * @return array * @throws \Exception */ private static function queryOrder($openid, $outTradeNo) { return self::postXpay('/xpay/query_order', [ 'openid' => $openid, 'env' => self::env(), 'order_id' => (string)$outTradeNo, ]); } /** * 校验现金金额后扣减代币,再把业务订单标成已支付 * @param \think\Model $order * @param string $openid * @param array $wxOrder * @throws \Exception */ private static function deductCoins($order, $openid, array $wxOrder) { $paidFee = (int)($wxOrder['paid_fee'] ?? 0); if ($paidFee <= 0) { $paidFee = (int)($wxOrder['order_fee'] ?? 0); } $expect = self::amountFen($order->payment_amount); if ($paidFee !== $expect) { Log::error('虚拟支付金额不一致 ' . json_encode([ 'order_no' => $order->order_no, 'paid_fee' => $paidFee, 'expect' => $expect, ], JSON_UNESCAPED_UNICODE)); throw new \Exception('支付金额与订单不一致'); } $buyQuantity = self::coinQuantity($order->payment_amount); $res = self::postXpay('/xpay/currency_pay', [ 'openid' => $openid, 'env' => self::env(), 'user_ip' => self::userIp(), 'amount' => $buyQuantity, 'order_id' => (string)$order->order_no, 'payitem' => json_encode([[ 'productid' => 'order', 'unit_price' => $buyQuantity, 'quantity' => 1, ]], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'remark' => '订单' . $order->order_no, ], self::sessionKeyByOpenid($openid)); $errcode = (int)($res['errcode'] ?? -1); // 268490004:重复扣减,上一笔已经成功 if ($errcode !== 0 && $errcode !== 268490004) { if ($errcode === 268490006) { throw new \Exception('支付确认中,请稍后在订单里再次点击支付,不会重复扣款'); } throw new \Exception('代币扣减失败:' . ($res['errmsg'] ?? '未知错误')); } $transactionId = (string)($wxOrder['wxpay_order_id'] ?? ($wxOrder['channel_order_id'] ?? '')); $paidTime = (int)($wxOrder['paid_time'] ?? 0); OrderService::finishPaid( $order->order_no, $expect, $transactionId, 'XPAY', $paidTime > 0 ? $paidTime : time(), 'settle', self::payPlatformOf($order->order_no) ); } /** * @param array $msg * @throws \Exception */ private static function finishFromCoinNotify(array $msg) { $outTradeNo = (string)($msg['OutTradeNo'] ?? ''); $orderNo = Cache::get(self::TRADE_PREFIX . $outTradeNo) ?: $outTradeNo; $coinInfo = isset($msg['CoinInfo']) && is_array($msg['CoinInfo']) ? $msg['CoinInfo'] : []; $order = OrderService::getOne(['order_no' => $orderNo]); if (!$order) { Log::error('虚拟支付代币通知未找到订单 ' . $outTradeNo); return; } $fen = isset($coinInfo['ActualPrice']) ? (int)$coinInfo['ActualPrice'] : self::amountFen($order->payment_amount); $payInfo = isset($msg['WeChatPayInfo']) && is_array($msg['WeChatPayInfo']) ? $msg['WeChatPayInfo'] : []; $paidTime = (int)($payInfo['PaidTime'] ?? 0); OrderService::finishPaid( $orderNo, $fen, (string)($payInfo['TransactionId'] ?? ''), 'XPAY', $paidTime > 0 ? $paidTime : time(), 'coin_notify', self::payPlatformOf($orderNo) ); } /** * 未发货的订单退款成功后改为已退款。已发货或需求单只记日志,避免把已结算合同改回去 * @param array $msg */ private static function handleRefundNotify(array $msg) { Log::info('虚拟支付退款通知 ' . json_encode($msg, JSON_UNESCAPED_UNICODE)); if ((int)($msg['RetCode'] ?? 1) !== 0) { return; } $mchOrderId = (string)($msg['MchOrderId'] ?? ''); $orderNo = Cache::get(self::TRADE_PREFIX . $mchOrderId) ?: $mchOrderId; $order = OrderService::getOne(['order_no' => $orderNo]); if (!$order) { Log::error('虚拟支付退款未找到订单 ' . $mchOrderId); return; } if ((int)$order->order_status === OrderService::ORDER_STATUS_WAIT_SHIP && (int)$order->order_type === OrderService::ORDER_TYPE_SOFTWARE) { OrderService::where('order_no', $order->order_no)->update([ 'order_status' => OrderService::ORDER_STATUS_REFUNDED, ]); } } /** * iOS 退款问询:已交付建议拒绝,未交付建议退款。最终由苹果决定 * @param array $msg * @return array */ private static function iosRefundQuery(array $msg) { $payOrderId = (string)($msg['pay_order_id'] ?? ($msg['PayOrderId'] ?? '')); $orderNo = $payOrderId ? (Cache::get(self::TRADE_PREFIX . $payOrderId) ?: $payOrderId) : ''; $order = $orderNo ? OrderService::getOne(['order_no' => $orderNo]) : null; $delivered = $order && in_array((int)$order->order_status, [ OrderService::ORDER_STATUS_SHIPPED, OrderService::ORDER_STATUS_FINISHED, ], true); if ($delivered) { return [ 'result_code' => 1, 'result_info' => '已交付', 'evidence' => '虚拟商品已交付,建议拒绝退款', ]; } return [ 'result_code' => 0, 'result_info' => '未交付', 'evidence' => '订单未交付,建议退款', ]; } /** * @param string $uri * @param array $payload * @param string $sessionKey 需要用户态签名的接口才传 * @return array * @throws \Exception */ private static function postXpay($uri, array $payload, $sessionKey = '') { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($body === false) { throw new \Exception('虚拟支付参数编码失败'); } $service = new EasyWeChatService(); $result = self::requestXpay($service, $uri, $body, $sessionKey); $errcode = (int)($result['errcode'] ?? 0); // 40001 凭证不是最新,42001 凭证过期。刷新后只重试一次 if ($errcode === 40001 || $errcode === 42001) { Log::info('虚拟支付 access_token 失效,刷新后重试 ' . $uri); $service->refreshMiniAccessToken(true); $result = self::requestXpay($service, $uri, $body, $sessionKey); } Log::info('虚拟支付请求 ' . $uri . ' ' . json_encode([ 'payload' => $payload, 'res' => $result, ], JSON_UNESCAPED_UNICODE)); return is_array($result) ? $result : []; } /** * @param EasyWeChatService $service * @param string $uri * @param string $body * @param string $sessionKey * @return array * @throws \Exception */ private static function requestXpay(EasyWeChatService $service, $uri, $body, $sessionKey) { // access_token 由小程序 HTTP 客户端自动附在 query 上,不参与 pay_sig $query = [ 'pay_sig' => hash_hmac('sha256', $uri . '&' . $body, self::appKey()), ]; if ($sessionKey !== '') { $query['signature'] = hash_hmac('sha256', $body, $sessionKey); } try { $response = $service->app->getClient()->post($uri, [ 'query' => $query, 'headers' => ['Content-Type' => 'application/json'], 'body' => $body, ]); $result = $response->toArray(false); } catch (\Exception $e) { Log::error('虚拟支付请求异常 ' . $uri . ' ' . $e->getMessage()); throw new \Exception('虚拟支付请求失败'); } return is_array($result) ? $result : []; } /** * @param string $openid * @return string * @throws \Exception */ private static function sessionKeyByOpenid($openid) { $userId = User::where('openid', $openid)->value('id'); $sessionKey = $userId ? Cache::get(self::SESSION_PREFIX . $userId) : ''; if (!$sessionKey) { throw new \Exception('登录态已过期,请退出小程序后重新进入再支付'); } return (string)$sessionKey; } /** * ThinkPHP 5 的 config() 只按第一个点拆成两级,三级键会读成空 * @return array */ private static function payConfig() { $config = config('common.virtual_pay'); return is_array($config) ? $config : []; } /** * OfferID 为空时记下 .env 有没有被读到,不记录密钥内容 */ private static function logMissingOfferId() { $envFile = (defined('ROOT_PATH') ? ROOT_PATH : '') . '.env'; $exists = $envFile !== '.env' && is_file($envFile); $section = ''; $found = null; if ($exists) { $lines = file($envFile, FILE_IGNORE_NEW_LINES); if (is_array($lines)) { foreach ($lines as $line) { $trim = trim($line); if ($trim === '' || $trim[0] === ';' || $trim[0] === '#') { continue; } if (preg_match('/^\[(.+)\]$/', $trim, $sectionMatch)) { $section = $sectionMatch[1]; continue; } if (preg_match('/^vp_offer_id\s*=\s*(.*)$/', $trim, $valueMatch)) { $value = trim($valueMatch[1], " \t\"'"); $found = [ 'section' => $section, 'len' => strlen($value), ]; } } } } $fromEnv = getenv('PHP_WECHAT_VP_OFFER_ID'); $virtualPay = config('common.virtual_pay'); Log::error('虚拟支付 OfferID 为空 ' . json_encode([ 'env_file' => $envFile, 'env_exists' => $exists, 'file_item' => $found ?: 'missing', 'getenv' => $fromEnv === false ? 'false' : ('len:' . strlen(trim((string)$fromEnv))), 'config_keys' => is_array($virtualPay) ? array_keys($virtualPay) : gettype($virtualPay), ], JSON_UNESCAPED_UNICODE)); } /** * 代币数量,保留两位小数。1元=1代币时,0.01元对应 0.01 个代币 * @param float|string $amountYuan * @return float * @throws \Exception */ private static function coinQuantity($amountYuan) { $fen = self::amountFen($amountYuan); if ($fen < 1) { throw new \Exception('支付金额不能小于0.01元'); } $coinPerYuan = (int)(self::payConfig()['coin_per_yuan'] ?? 0); if ($coinPerYuan < 1) { $coinPerYuan = 1; } $quantity = bcdiv(bcmul((string)$fen, (string)$coinPerYuan, 0), '100', 2); return (float)$quantity; } /** * @param float|string $amountYuan * @return int */ private static function amountFen($amountYuan) { return (int)bcmul(sprintf('%.2f', $amountYuan), '100', 0); } /** * @return int */ private static function env() { return (int)(self::payConfig()['env'] ?? 0) === 1 ? 1 : 0; } /** * @return string * @throws \Exception */ private static function appKey() { $config = self::payConfig(); $key = self::env() === 1 ? (string)($config['sandbox_app_key'] ?? '') : (string)($config['app_key'] ?? ''); if ($key === '') { throw new \Exception('未配置虚拟支付 AppKey'); } return $key; } /** * @return string */ private static function userIp() { $ip = request()->ip(); if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return '127.0.0.1'; } return $ip; } }