diff --git a/addons/shopro/library/PayService.php b/addons/shopro/library/PayService.php index f501410..bfaedea 100644 --- a/addons/shopro/library/PayService.php +++ b/addons/shopro/library/PayService.php @@ -236,6 +236,20 @@ class PayService return $result; } + // 主动查询网关侧订单状态(支付对账/掉单补偿用,无需验签回调) + public function query($out_trade_no) + { + $pay = $this->getPay(); + + $order = ['out_trade_no' => $out_trade_no]; + + if ($this->payment === 'wechat') { + return $pay->find($order); + } + + return $pay->find($order, 'web'); + } + public function notifyRefund($callback) { diff --git a/application/admin/command/ShoproPayReconcile.php b/application/admin/command/ShoproPayReconcile.php new file mode 100644 index 0000000..86ef3ed --- /dev/null +++ b/application/admin/command/ShoproPayReconcile.php @@ -0,0 +1,176 @@ +setName('shopro:pay-reconcile') + ->addOption('dry-run', null, Option::VALUE_NONE, 'only report, do not complete orders') + ->addOption('limit', null, Option::VALUE_REQUIRED, 'max orders to check per run', 50) + ->addOption('min-age', null, Option::VALUE_REQUIRED, 'only check orders created at least N minutes ago', 15) + ->addOption('max-age', null, Option::VALUE_REQUIRED, 'only check orders created within N hours', 72) + ->setDescription('Reconcile unpaid shopro orders against wechat/alipay gateway status'); + } + + protected function execute(Input $input, Output $output) + { + $dryRun = (bool)$input->getOption('dry-run'); + $limit = max(1, min(500, (int)$input->getOption('limit'))); + $minAge = max(1, (int)$input->getOption('min-age')); + $maxAge = max(1, (int)$input->getOption('max-age')); + + $now = time(); + $candidates = Db::name('shopro_order') + ->where('status', 0) + ->where('pay_type', 'in', ['wechat', 'alipay', 'group']) + ->where('createtime', '<=', $now - $minAge * 60) + ->where('createtime', '>=', $now - $maxAge * 3600) + ->order('id', 'asc') + ->limit($limit) + ->select(); + + $checked = 0; + $completed = 0; + foreach ($candidates as $row) { + $checked++; + try { + $done = $this->reconcileOne($row, $dryRun, $output); + if ($done) { + $completed++; + } + } catch (\Exception $e) { + $msg = "pay-reconcile order {$row['order_sn']} failed: " . $e->getMessage(); + Log::write($msg, 'error'); + $output->error($msg); + } + } + + $output->info(($dryRun ? '[dry-run] ' : '') . "checked {$checked}, completed {$completed}."); + return 0; + } + + /** + * @param array $row + * @return bool 是否完成(或 dry-run 下是否“可完成”) + */ + private function reconcileOne($row, $dryRun, $output) + { + $orderSn = $row['order_sn']; + $payType = $row['pay_type']; + $platform = $row['platform']; + + // group(组合支付)走微信网关查询 + $gateway = $payType === 'group' ? 'wechat' : $payType; + + if ($gateway === 'wechat' + && !in_array($platform, ['wxMiniProgram', 'wxOfficialAccount', 'H5', 'App'], true) + ) { + $output->comment("skip {$orderSn}: unsupported platform {$platform}"); + return false; + } + + $pay = new \addons\shopro\library\PayService($gateway, $platform); + $data = $pay->query($orderSn); + + $paidInfo = $this->parseGatewayResult($gateway, $data); + if (!$paidInfo) { + $output->comment("skip {$orderSn}: gateway reports unpaid/unknown"); + return false; + } + + // 金额必须与本地应付一致,否则只告警、不自动完成 + if (bccomp((string)$paidInfo['pay_fee'], (string)$row['pay_fee'], 2) !== 0) { + $msg = "pay-reconcile amount mismatch {$orderSn}: gateway {$paidInfo['pay_fee']} vs local {$row['pay_fee']}"; + Log::write($msg, 'error'); + $output->error($msg . ', manual review required'); + return false; + } + + if ($dryRun) { + $output->info("would complete {$orderSn} ({$paidInfo['pay_fee']})"); + return true; + } + + // 与 Pay::notifyx 回调相同的完成路径:加锁复查 + 事务 + paymentProcess + $order = Order::nopay()->where('order_sn', $orderSn)->lock(true)->find(); + if (!$order) { + // 已被回调或其他进程完成 + $output->info("skip {$orderSn}: already processed"); + return false; + } + + Db::transaction(function () use ($order, $orderSn, $paidInfo, $gateway) { + $notify = [ + 'order_sn' => $orderSn, + 'transaction_id' => $paidInfo['transaction_id'], + 'notify_time' => $paidInfo['notify_time'], + 'buyer_email' => $paidInfo['buyer'], + 'payment_json' => json_encode($paidInfo['raw']), + 'pay_fee' => $paidInfo['pay_fee'], + 'pay_type' => $order->pay_type == 'group' ? 'group' : $gateway, + ]; + $order->paymentProcess($order, $notify); + }); + + $msg = "pay-reconcile completed {$orderSn} ({$paidInfo['pay_fee']})"; + Log::write($msg); + $output->info($msg); + return true; + } + + /** + * 解析网关查询结果,已确认付款返回统一结构,否则返回 null + */ + private function parseGatewayResult($gateway, $data) + { + $arr = $data instanceof \think\Collection ? $data->all() : (array)$data; + + if ($gateway === 'wechat') { + if (($arr['result_code'] ?? '') !== 'SUCCESS' || ($arr['trade_state'] ?? '') !== 'SUCCESS') { + return null; + } + $notifyTime = isset($arr['time_end']) && ($ts = strtotime($arr['time_end'])) + ? date('Y-m-d H:i:s', $ts) + : date('Y-m-d H:i:s'); + return [ + 'transaction_id' => $arr['transaction_id'], + 'notify_time' => $notifyTime, + 'buyer' => $arr['openid'] ?? '', + 'pay_fee' => bcdiv((string)$arr['total_fee'], '100', 2), + 'raw' => $arr, + ]; + } + + // alipay: alipay.trade.query + if (($arr['code'] ?? '') !== '10000' || ($arr['trade_status'] ?? '') !== 'TRADE_SUCCESS') { + return null; + } + $notifyTime = isset($arr['gmt_payment']) && ($ts = strtotime($arr['gmt_payment'])) + ? date('Y-m-d H:i:s', $ts) + : date('Y-m-d H:i:s'); + return [ + 'transaction_id' => $arr['trade_no'], + 'notify_time' => $notifyTime, + 'buyer' => $arr['buyer_logon_id'] ?? '', + 'pay_fee' => (string)$arr['total_amount'], + 'raw' => $arr, + ]; + } +} diff --git a/application/command.php b/application/command.php index dcd994a..371c12d 100644 --- a/application/command.php +++ b/application/command.php @@ -18,4 +18,5 @@ return [ 'app\admin\command\Addon', 'app\admin\command\Api', 'app\admin\command\ShoproPrintRetry', + 'app\admin\command\ShoproPayReconcile', ];