diff --git a/backend/.env.sample b/backend/.env.sample index ccd0f29..f29c2d9 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -9,3 +9,14 @@ username = root password = root hostport = 3306 prefix = fa_ + +[wechat] +; 虚拟支付:MP 后台「虚拟支付-基本配置」 +vp_offer_id = +vp_app_key = +vp_sandbox_app_key = +vp_env = 0 +vp_coin_per_yuan = 1 +; 消息推送 URL:{app.url}/api/order/virtualPayNotify ,加解密方式选明文时可只填 Token +vp_msg_token = +vp_aes_key = diff --git a/backend/application/api/controller/Order.php b/backend/application/api/controller/Order.php index 3eb393a..17ca338 100644 --- a/backend/application/api/controller/Order.php +++ b/backend/application/api/controller/Order.php @@ -5,13 +5,14 @@ namespace app\api\controller; use app\common\controller\Api; use app\common\service\OrderService; use app\common\service\ToolService; +use app\common\service\VirtualPayService; use think\Exception; use think\exception\HttpResponseException; use think\Log; class Order extends Api { - protected $noNeedLogin = ['payNotify', 'detail']; + protected $noNeedLogin = ['payNotify', 'virtualPayNotify', 'detail']; protected $noNeedRight = ['*']; /** @@ -296,6 +297,143 @@ class Order extends Api } } + /** + * 虚拟支付收银台成功后确认入账 + * @ApiMethod (POST) + */ + public function virtualPayConfirm() + { + try { + $orderNo = $this->request->post('orderNo', ''); + if (!$orderNo) { + $this->error('订单号不能为空'); + } + $res = OrderService::confirmVirtualPay($orderNo, $this->auth->id); + $this->success('支付成功', $res); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } + + /** + * 虚拟支付消息推送。MP 后台消息推送 URL 填本接口,明文模式即可 + */ + public function virtualPayNotify() + { + $echostr = (string)$this->request->get('echostr', ''); + if ($echostr !== '') { + try { + $this->assertVirtualPaySignature(); + } catch (\Exception $e) { + Log::error('虚拟支付消息验签失败 ' . $e->getMessage()); + http_response_code(403); + exit; + } + echo $echostr; + exit; + } + + $raw = trim((string)$this->request->getInput()); + $asJson = strpos($raw, '{') === 0; + Log::info('虚拟支付推送[进入] ' . $raw); + try { + $payload = $this->parseVirtualPayBody($raw); + if (!empty($payload['Encrypt'])) { + $plain = VirtualPayService::decryptMessage( + (string)$payload['Encrypt'], + (string)$this->request->get('msg_signature', ''), + (string)$this->request->get('nonce', ''), + (string)$this->request->get('timestamp', '') + ); + $payload = $this->parseVirtualPayBody($plain); + } else { + $this->assertVirtualPaySignature(); + } + $reply = VirtualPayService::handleMessage($payload); + } catch (\Exception $e) { + Log::error('虚拟支付推送处理失败 ' . $e->getMessage()); + $message = $e->getMessage(); + if (strpos($message, '订单不存在') !== false || strpos($message, '获取订单数据失败') !== false) { + $reply = ['ErrCode' => 0, 'ErrMsg' => 'success']; + } else { + $reply = ['ErrCode' => 1, 'ErrMsg' => $message]; + } + } + $this->replyVirtualPay(isset($reply) ? $reply : ['ErrCode' => 1, 'ErrMsg' => 'fail'], $asJson); + } + + /** + * 校验消息推送签名 + * @throws \Exception + */ + private function assertVirtualPaySignature() + { + $token = (string)config('common.virtual_pay.msg_token'); + if ($token === '') { + throw new \Exception('未配置虚拟支付消息令牌'); + } + $parts = [ + $token, + (string)$this->request->get('timestamp', ''), + (string)$this->request->get('nonce', ''), + ]; + sort($parts, SORT_STRING); + if (sha1(implode($parts)) !== (string)$this->request->get('signature', '')) { + throw new \Exception('消息签名校验失败'); + } + } + + /** + * @param string $raw + * @return array + */ + private function parseVirtualPayBody($raw) + { + $raw = trim((string)$raw); + if ($raw === '') { + return []; + } + if ($raw[0] === '{') { + $data = json_decode($raw, true); + return is_array($data) ? $data : []; + } + $xml = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET); + if (!$xml) { + return []; + } + $data = json_decode(json_encode($xml), true); + + return is_array($data) ? $data : []; + } + + /** + * @param array $data + * @param bool $asJson + */ + private function replyVirtualPay(array $data, $asJson) + { + if ($asJson) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; + } + header('Content-Type: text/xml; charset=utf-8'); + $xml = ''; + foreach ($data as $key => $value) { + if (is_int($value) || is_float($value)) { + $xml .= '<' . $key . '>' . $value . ''; + } else { + $xml .= '<' . $key . '>'; + } + } + $xml .= ''; + echo $xml; + exit; + } + public function businessTime() { $res = [ diff --git a/backend/application/common/service/OrderService.php b/backend/application/common/service/OrderService.php index c87191b..a849ffa 100644 --- a/backend/application/common/service/OrderService.php +++ b/backend/application/common/service/OrderService.php @@ -168,8 +168,11 @@ class OrderService 'tradeNo' => $orderData['order_no'], 'interactId' => 0, 'transactionId' => 0, - 'payInfo' => self::pay($orderData['order_no'], $total_amount, $openid), //统一下单接口 + 'payInfo' => self::pay($orderData['order_no'], $total_amount, $openid), ]; + if (!empty($orderData['payInfo']['paid'])) { + $orderData['order_status'] = self::ORDER_STATUS_WAIT_SHIP; + } //存入缓存 Cache::set($lockOrderKey, json_encode($orderData), 30); @@ -300,11 +303,15 @@ class OrderService 'tradeNo' => $orderData['order_no'], 'interactId' => 1, 'transactionId' => 0, - 'payInfo' => self::pay($orderInfo->order_no, $orderInfo->total_amount, $openid), //统一下单接口 + 'payInfo' => self::pay($orderInfo->order_no, $orderInfo->payment_amount, $openid), ]; - Log::info('微信下单[下单成功] ' . json_encode([ + if (!empty($orderData['payInfo']['paid'])) { + $orderData['order_status'] = self::ORDER_STATUS_WAIT_SHIP; + } + Log::info('虚拟支付下单[下单成功] ' . json_encode([ 'order_no' => $orderNo, - 'payInfo' => $orderData['payInfo'], + 'paid' => $orderData['payInfo']['paid'] ?? 0, + 'mode' => $orderData['payInfo']['mode'] ?? '', ], JSON_UNESCAPED_UNICODE)); return $orderData; @@ -471,84 +478,18 @@ class OrderService 'payment_amount'=> $orderInfo->payment_amount, ], JSON_UNESCAPED_UNICODE)); - //金额校验(v3 通知金额单位为分) - $checkMoney = sprintf('%.1f', $total / 100) == sprintf('%.1f', $orderInfo->payment_amount); - if (!$checkMoney) { - Log::error($prefix . '金额校验失败: ' . json_encode([ - 'notify_amount' => sprintf('%.2f', $total / 100), - 'order_amount' => sprintf('%.2f', $orderInfo->payment_amount), - ], JSON_UNESCAPED_UNICODE)); - throw new \Exception('金额错误:' . sprintf('%.2f', $total / 100) . '----' . sprintf('%.2f', $orderInfo->payment_amount)); - } if (($message->trade_state ?? '') != 'SUCCESS') { Log::error($prefix . '支付状态异常: trade_state=' . ($message->trade_state ?? '')); throw new \Exception('支付失败:' . ($message->trade_state ?? '')); } - - //如果为待支付,才可更新为已支付。防止重复更新状态,导致重复发货。 - if ($orderInfo->order_status != self::ORDER_STATUS_WAIT_PAY) { - Log::info($prefix . '订单已处理过,跳过状态更新(幂等): ' . json_encode([ - 'order_no' => $order_no, - 'order_status' => $orderInfo->order_status, - 'status_text' => self::ORDER_STATUS_MAP[$orderInfo->order_status] ?? '未知', - ], JSON_UNESCAPED_UNICODE)); - - return true; - } - - $paymentData = [ - 'order_no' => $order_no, - 'pay_status' => 'SUCCESS', - 'amount' => $total / 100, - 'currency' => $message->amount['currency'] ?? 'CNY', - 'pay_type' => '1', // 1微信 2支付宝 - 'pay_way' => $message->trade_type ?? 'JSAPI', - 'trace_no' => $message->transaction_id ?? '', - 'pay_time' => time(), - 'pay_success_time' => strtotime((string)($message->success_time ?? '')) ?: time(), - ]; - Log::info($prefix . '开始更新订单状态: ' . json_encode($paymentData, JSON_UNESCAPED_UNICODE)); - - try { - Db::transaction(function () use ($order_no, $paymentData, $orderInfo, $prefix) { - $updateRows = self::where('order_no', $order_no)->update(['order_status' => self::ORDER_STATUS_WAIT_SHIP]); - Log::info($prefix . '订单状态更新: order_no=' . $order_no - . ' 10(待支付) -> ' . self::ORDER_STATUS_WAIT_SHIP . '(待发货) 影响行数=' . $updateRows); - - $payment = OrderPaymentService::create($paymentData); - Log::info($prefix . '支付流水写入: ' . json_encode([ - 'payment_id' => $payment->id ?? 0, - 'order_no' => $payment->order_no ?? '', - 'pay_status' => $payment->pay_status ?? '', - 'amount' => $payment->amount ?? 0, - 'trace_no' => $payment->trace_no ?? '', - ], JSON_UNESCAPED_UNICODE)); - - if ($orderInfo->order_type == self::ORDER_TYPE_FIRST_PAYMENT) { - Log::info($prefix . '需求首付款订单,处理合同成交: parent_order_no=' . $orderInfo->parent_order_no); - DemandService::customerContractFinishedDeal($orderInfo->parent_order_no); - } elseif ($orderInfo->order_type == self::ORDER_TYPE_FINAL_PAYMENT) { - Log::info($prefix . '需求尾款订单,处理验收结算: parent_order_no=' . $orderInfo->parent_order_no); - DemandService::customerWorkAcceptFinishedDeal($orderInfo->parent_order_no); - } - }); - } catch (\Throwable $e) { - Log::error($prefix . '事务执行失败已回滚: msg=' . $e->getMessage() - . ' file=' . $e->getFile() . ':' . $e->getLine()); - throw $e; - } - - // 复核:确认状态与支付流水真的落库了 - $newOrder = OrderService::getOne(['order_no' => $order_no]); - $payment = OrderPaymentService::getOne(['order_no' => $order_no]); - Log::info($prefix . '回调处理成功: ' . json_encode([ - 'order_no' => $order_no, - 'new_order_status'=> $newOrder->order_status ?? null, - 'new_status_text' => self::ORDER_STATUS_MAP[$newOrder->order_status ?? -1] ?? '未知', - 'payment_id' => $payment->id ?? 0, - 'payment_status' => $payment->pay_status ?? '', - 'trace_no' => $payment->trace_no ?? '', - ], JSON_UNESCAPED_UNICODE)); + self::finishPaid( + $order_no, + (int)$total, + (string)($message->transaction_id ?? ''), + (string)($message->trade_type ?? 'JSAPI'), + strtotime((string)($message->success_time ?? '')) ?: time(), + $traceId + ); return true; }); @@ -568,35 +509,131 @@ class OrderService } /** - * @param $order_sn - * @param $total_fee - * @param $openid + * 支付成功入账。虚拟支付确认和微信支付回调共用,重复通知直接返回 + * @param string $orderNo + * @param int $amountFen 实付金额,单位分 + * @param string $transactionId + * @param string $payWay + * @param int $paidTime + * @param string $traceId + * @return bool + * @throws \Exception + */ + public static function finishPaid($orderNo, $amountFen, $transactionId, $payWay, $paidTime, $traceId = '') + { + $prefix = '订单支付入账 trace=' . $traceId . ' '; + try { + Db::transaction(function () use ($orderNo, $amountFen, $transactionId, $payWay, $paidTime, $prefix) { + $orderInfo = self::where('order_no', $orderNo)->lock(true)->find(); + if (!$orderInfo) { + Log::error($prefix . '订单不存在: ' . $orderNo); + throw new \Exception('获取订单数据失败'); + } + if ((int)$orderInfo->order_status !== self::ORDER_STATUS_WAIT_PAY) { + Log::info($prefix . '订单已处理过,跳过状态更新: ' . json_encode([ + 'order_no' => $orderNo, + 'order_status' => $orderInfo->order_status, + ], JSON_UNESCAPED_UNICODE)); + return; + } + $checkMoney = sprintf('%.1f', $amountFen / 100) == sprintf('%.1f', $orderInfo->payment_amount); + if (!$checkMoney) { + Log::error($prefix . '金额校验失败: ' . json_encode([ + 'notify_amount' => sprintf('%.2f', $amountFen / 100), + 'order_amount' => sprintf('%.2f', $orderInfo->payment_amount), + ], JSON_UNESCAPED_UNICODE)); + throw new \Exception('金额错误:' . sprintf('%.2f', $amountFen / 100) . '----' . sprintf('%.2f', $orderInfo->payment_amount)); + } + $paymentData = [ + 'order_no' => $orderNo, + 'pay_status' => 'SUCCESS', + 'amount' => $amountFen / 100, + 'currency' => 'CNY', + 'pay_type' => '1', + 'pay_way' => $payWay ?: 'XPAY', + 'trace_no' => $transactionId, + 'pay_time' => time(), + 'pay_success_time' => $paidTime > 0 ? $paidTime : time(), + ]; + self::where('order_no', $orderNo)->update(['order_status' => self::ORDER_STATUS_WAIT_SHIP]); + OrderPaymentService::create($paymentData); + if ($orderInfo->order_type == self::ORDER_TYPE_FIRST_PAYMENT) { + DemandService::customerContractFinishedDeal($orderInfo->parent_order_no); + } elseif ($orderInfo->order_type == self::ORDER_TYPE_FINAL_PAYMENT) { + DemandService::customerWorkAcceptFinishedDeal($orderInfo->parent_order_no); + } + Log::info($prefix . '入账成功 ' . json_encode($paymentData, JSON_UNESCAPED_UNICODE)); + }); + } catch (\Exception $e) { + Log::error($prefix . '入账失败: ' . $e->getMessage()); + throw $e; + } + + return true; + } + + /** + * 虚拟支付下单。上一笔现金单已经支付成功时直接入账,不再拉起收银台 + * @param string $order_sn + * @param float|string $total_fee + * @param string $openid * @param string $body - * @return array|\EasyWeChat\Kernel\Support\Collection|object|\Psr\Http\Message\ResponseInterface|string + * @return array * @throws \Exception */ public static function pay($order_sn, $total_fee, $openid, string $body = '软件产品服务') { if (!($body && $order_sn && $total_fee && $openid)) { - Log::error('微信下单[参数错误] ' . json_encode([ - 'body' => $body, - 'order_sn' => $order_sn, + Log::error('虚拟支付下单[参数错误] ' . json_encode([ + 'body' => $body, + 'order_sn' => $order_sn, 'total_fee' => $total_fee, - 'openid' => $openid, + 'openid' => $openid, ], JSON_UNESCAPED_UNICODE)); throw new \Exception('支付参数错误2'); } - - $EasyWeChatService = new EasyWeChatService(); - $payRes = $EasyWeChatService->unify($body, $order_sn, $total_fee, $openid); - if (!($payRes && !empty($payRes['prepay_id']))) { - Log::error('微信下单[统一下单失败] order_sn=' . $order_sn . ' res=' . json_encode($payRes, JSON_UNESCAPED_UNICODE)); - throw new \Exception('统一下单接口调用失败:' . ($payRes['message'] ?? ($payRes['errmsg'] ?? '未知错误'))); + $current = self::getOne(['order_no' => $order_sn]); + if ($current && !in_array((int)$current->order_status, [ + self::ORDER_STATUS_WAIT_PAY, + self::ORDER_STATUS_WAIT_SHIP, + self::ORDER_STATUS_SHIPPED, + self::ORDER_STATUS_FINISHED, + ], true)) { + throw new \Exception('订单状态不可支付'); + } + if (VirtualPayService::settleIfPaid($order_sn, $openid)) { + return ['paid' => 1]; } - Log::info('微信下单[prepay_id] order_sn=' . $order_sn . ' prepay_id=' . $payRes['prepay_id']); - $payData = $EasyWeChatService->sdkConfig($payRes['prepay_id']); - return $payData; + return VirtualPayService::createClientPay($order_sn, $total_fee, $openid); + } + + /** + * 客户端支付成功后确认:查现金单、扣代币、更新订单 + * @param string $orderNo + * @param int $userId + * @return array + * @throws \Exception + */ + public static function confirmVirtualPay($orderNo, $userId) + { + $order = self::getOne(['order_no' => $orderNo, 'user_id' => $userId]); + if (!$order) { + throw new \Exception('订单不存在'); + } + if ((int)$order->order_status !== self::ORDER_STATUS_WAIT_PAY) { + return ['order_status' => (int)$order->order_status]; + } + $openid = \app\common\model\User::where('id', $userId)->value('openid'); + if (!$openid) { + throw new \Exception('用户未绑定微信'); + } + if (!VirtualPayService::settleIfPaid($orderNo, $openid)) { + throw new \Exception('支付结果确认中,请稍后在订单里再次点击支付,不会重复扣款'); + } + $fresh = self::getOne(['order_no' => $orderNo]); + + return ['order_status' => (int)($fresh->order_status ?? self::ORDER_STATUS_WAIT_SHIP)]; } /** diff --git a/backend/application/common/service/UserService.php b/backend/application/common/service/UserService.php index 43e97a2..8010f9b 100644 --- a/backend/application/common/service/UserService.php +++ b/backend/application/common/service/UserService.php @@ -42,6 +42,10 @@ class UserService if (!$account) { $acRes = $user->account()->save([]); } + // 虚拟支付用户态签名依赖 session_key,只放缓存,不回给前端 + if (!empty($result['session_key']) && !empty($user->id)) { + VirtualPayService::saveSessionKey($user->id, $result['session_key']); + } return $user; } diff --git a/backend/application/common/service/VirtualPayService.php b/backend/application/common/service/VirtualPayService.php new file mode 100644 index 0000000..b3df495 --- /dev/null +++ b/backend/application/common/service/VirtualPayService.php @@ -0,0 +1,446 @@ + $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) + { + $token = (string)config('common.virtual_pay.msg_token'); + $aesKey = (string)config('common.virtual_pay.aes_key'); + $appId = (string)config('common.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' + ); + } + + /** + * @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' + ); + } + + /** + * 未发货的订单退款成功后改为已退款。已发货或需求单只记日志,避免把已结算合同改回去 + * @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('虚拟支付参数编码失败'); + } + // 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 { + $service = new EasyWeChatService(); + $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('虚拟支付请求失败'); + } + Log::info('虚拟支付请求 ' . $uri . ' ' . json_encode([ + 'payload' => $payload, + 'res' => $result, + ], JSON_UNESCAPED_UNICODE)); + + 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; + } + + /** + * @param float|string $amountYuan + * @return int + * @throws \Exception + */ + private static function coinQuantity($amountYuan) + { + $fen = self::amountFen($amountYuan); + if ($fen < 1) { + throw new \Exception('支付金额不能小于0.01元'); + } + $coinPerYuan = (int)config('common.virtual_pay.coin_per_yuan'); + if ($coinPerYuan < 1) { + $coinPerYuan = 1; + } + if (($fen * $coinPerYuan) % 100 !== 0) { + throw new \Exception('订单金额须为整数元才能按1元=1代币支付'); + } + + return intdiv($fen * $coinPerYuan, 100); + } + + /** + * @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)config('common.virtual_pay.env') === 1 ? 1 : 0; + } + + /** + * @return string + * @throws \Exception + */ + private static function appKey() + { + $config = config('common.virtual_pay'); + $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; + } +} diff --git a/backend/application/extra/common.php b/backend/application/extra/common.php index 3254b66..8baa73b 100644 --- a/backend/application/extra/common.php +++ b/backend/application/extra/common.php @@ -39,8 +39,17 @@ return [ // 免确认收款授权结果回调地址(公网可访问、HTTPS、不带参数) 'transfer_authorization_notify_url' => Env::get('app.url') . '/api/account/transferAuthorizationNotify', ], + // 小程序虚拟支付(类目不支持普通微信支付时使用) + // 代币比例须与 MP「虚拟支付-代币配置」一致:1元=1代币。buyQuantity 只能是整数,订单金额须为整数元 'virtual_pay' => [ - 'android_pay_fees' => 0.01, - 'ios_pay_fees' => 0.12, + 'offer_id' => Env::get('wechat.vp_offer_id', ''), + 'app_key' => Env::get('wechat.vp_app_key', ''), // 现网 AppKey,env=0 + 'sandbox_app_key' => Env::get('wechat.vp_sandbox_app_key', ''), // 沙箱 AppKey,env=1 + 'env' => (int)Env::get('wechat.vp_env', 0), // 0现网 1沙箱;正式版只能为 0 + 'coin_per_yuan' => (int)Env::get('wechat.vp_coin_per_yuan', 1), + 'msg_token' => Env::get('wechat.vp_msg_token', ''), // 消息推送 Token + 'aes_key' => Env::get('wechat.vp_aes_key', ''), // 消息推送 EncodingAESKey,明文模式可空 + 'android_pay_fees' => 0.01, //安卓支付费率 + 'ios_pay_fees' => 0.12, //ios支付费率 ], ]; \ No newline at end of file diff --git a/frontend/miniapp/config/api.js b/frontend/miniapp/config/api.js index f5bd41e..e0cee56 100644 --- a/frontend/miniapp/config/api.js +++ b/frontend/miniapp/config/api.js @@ -69,6 +69,7 @@ module.exports = { OrderDetail: ApiRootUrl + 'order/detail', //订单详情 OrderSettleDetail: ApiRootUrl + 'order/settleDetail', //订单结算详情 OrderCommitPay: ApiRootUrl + 'order/commitPay', //订单提交支付数据 + OrderVirtualPayConfirm: ApiRootUrl + 'order/virtualPayConfirm', //虚拟支付结果确认 OrderDelete: ApiRootUrl + 'order/delete', //订单删除 OrderCancel: ApiRootUrl + 'order/cancel', //取消订单 OrderOperate: ApiRootUrl + 'order/orderOperate', //操作订单 diff --git a/frontend/miniapp/pages/order/order-confirm/pay.js b/frontend/miniapp/pages/order/order-confirm/pay.js index ca86566..caec0cc 100644 --- a/frontend/miniapp/pages/order/order-confirm/pay.js +++ b/frontend/miniapp/pages/order/order-confirm/pay.js @@ -1,7 +1,7 @@ import Dialog from 'tdesign-miniprogram/dialog/index'; import Toast from 'tdesign-miniprogram/toast/index'; -import { dispatchCommitPay } from '../../../services/order/orderConfirm'; +import { confirmVirtualPay, dispatchCommitPay } from '../../../services/order/orderConfirm'; // 真实的提交支付 export const commitPay = (params) => { @@ -90,24 +90,64 @@ export const payFail = (payOrderInfo, resultMsg) => { } }; -// 微信支付方式 +function compareVersion(v1, v2) { + const left = String(v1 || '0').split('.'); + const right = String(v2 || '0').split('.'); + const len = Math.max(left.length, right.length); + for (let i = 0; i < len; i += 1) { + const num1 = parseInt(left[i] || '0', 10); + const num2 = parseInt(right[i] || '0', 10); + if (num1 > num2) { + return 1; + } + if (num1 < num2) { + return -1; + } + } + return 0; +} + +function canVirtualPay() { + if (typeof wx.canIUse === 'function' && wx.canIUse('requestVirtualPayment')) { + return true; + } + const version = (wx.getSystemInfoSync() || {}).SDKVersion || '0.0.0'; + return compareVersion(version, '2.19.2') >= 0; +} + +// 虚拟支付。signData 使用服务端原文字符串,不能再 JSON.stringify export const wechatPayOrder = (payOrderInfo) => { - const payInfo = payOrderInfo.payInfo; - const { timeStamp, nonceStr, signType, paySign } = payInfo; + const payInfo = payOrderInfo.payInfo || {}; + if (!canVirtualPay()) { + payFail(payOrderInfo, '当前微信版本过低,请升级后再支付'); + return Promise.resolve(); + } return new Promise((resolve) => { - // 只有 wx.requestPayment 回调 success 才认为是支付成功,不能提前跳转支付结果页 - wx.requestPayment({ - timeStamp, - nonceStr, - package: payInfo.package, - signType, - paySign, - success: function () { - paySuccess(payOrderInfo); - resolve(); + wx.requestVirtualPayment({ + signData: payInfo.signData, + paySig: payInfo.paySig, + signature: payInfo.signature, + mode: payInfo.mode, + success() { + confirmVirtualPay(payOrderInfo.tradeNo).then((res) => { + if (!res || res.code !== 1) { + payFail(payOrderInfo, (res && res.msg) || '支付结果确认失败,请稍后在订单中再次支付,不会重复扣款'); + return; + } + paySuccess(payOrderInfo); + resolve(); + }).catch((err) => { + payFail(payOrderInfo, (err && err.msg) || '支付结果确认失败,请稍后在订单中再次支付,不会重复扣款'); + }); }, - fail: function (err) { - payFail(payOrderInfo, err.errMsg); + fail(err) { + const errCode = err && err.errCode; + const errMsg = (err && err.errMsg) || '支付失败'; + if (errCode === -2 || errMsg.indexOf('cancel') !== -1) { + payFail(payOrderInfo, 'requestPayment:fail cancel'); + return; + } + payFail(payOrderInfo, errMsg); }, }); }); diff --git a/frontend/miniapp/services/order/orderConfirm.js b/frontend/miniapp/services/order/orderConfirm.js index 2ad7159..868893f 100644 --- a/frontend/miniapp/services/order/orderConfirm.js +++ b/frontend/miniapp/services/order/orderConfirm.js @@ -52,6 +52,11 @@ export function dispatchCommitPay(params) { return wx.$util.request(wx.$api.OrderCommitPay, params, 'POST'); } +/** 虚拟支付收银台成功后,通知服务端查单并入账 */ +export function confirmVirtualPay(orderNo) { + return wx.$util.request(wx.$api.OrderVirtualPayConfirm, { orderNo }, 'POST'); +} + /* 订单操作 */ export function orderOperate(params) {