Files
2026-10-27 18:52:26 +08:00

571 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service;
use app\common\model\User;
use EasyWeChat\Kernel\Encryptor;
use think\Cache;
use think\Log;
/**
* 小程序虚拟支付(代币充值)
* 文档:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/virtual-payment.html
* 客户端:wx.requestVirtualPayment,mode=short_series_coin
* 服务器:/xpay/query_order 确认现金单,/xpay/currency_pay 扣减代币
*/
class VirtualPayService
{
const SESSION_PREFIX = 'wx_session_key:';
const TRADE_PREFIX = 'vp_trade:';
const LATEST_PREFIX = 'vp_order_latest:';
const PLATFORM_PREFIX = 'vp_pay_platform:';
const CACHE_TTL = 1296000; // 15天
/**
* 登录成功后缓存 session_key,用户态签名要用
* @param int $userId
* @param string $sessionKey
*/
public static function saveSessionKey($userId, $sessionKey)
{
if (!$userId || $sessionKey === '') {
return;
}
Cache::set(self::SESSION_PREFIX . $userId, $sessionKey, self::CACHE_TTL);
}
/**
* 记下客户拉起支付时的终端。iOS 单独记,鸿蒙、Windows 等按安卓费率
* @param string $orderNo
* @param string $platform
* @return string
*/
public static function rememberPayPlatform($orderNo, $platform)
{
$normalized = self::normalizePayPlatform($platform);
if ($orderNo !== '' && $normalized !== '') {
Cache::set(self::PLATFORM_PREFIX . $orderNo, $normalized, self::CACHE_TTL);
}
return $normalized;
}
/**
* @param string $orderNo
* @return string
*/
public static function payPlatformOf($orderNo)
{
$cached = $orderNo ? Cache::get(self::PLATFORM_PREFIX . $orderNo) : '';
return self::normalizePayPlatform($cached);
}
/**
* @param string $platform
* @return string android|ios|空
*/
public static function normalizePayPlatform($platform)
{
$platform = strtolower(trim((string)$platform));
if ($platform === 'ios' || $platform === 'iphone') {
return 'ios';
}
if ($platform === '') {
return '';
}
return 'android';
}
/**
* 生成 wx.requestVirtualPayment 参数。signData 必须原样传给客户端,不能再 JSON.stringify
* @param string $orderNo
* @param float|string $amountYuan
* @param string $openid
* @return array
* @throws \Exception
*/
public static function createClientPay($orderNo, $amountYuan, $openid)
{
$offerId = (string)(self::payConfig()['offer_id'] ?? '');
if ($offerId === '') {
self::logMissingOfferId();
throw new \Exception('未配置虚拟支付 OfferID');
}
$buyQuantity = self::coinQuantity($amountYuan);
$outTradeNo = 'V' . date('ymdHis') . substr(md5(uniqid('', true)), 0, 10);
$signData = json_encode([
'offerId' => $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;
}
}