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 $isCompleted = false; Db::transaction(function () use ($orderSn, $paidInfo, $gateway, &$isCompleted) { $order = Order::nopay()->where('order_sn', $orderSn)->lock(true)->find(); if (!$order) { return; } $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); $isCompleted = true; }); if (!$isCompleted) { // 已被回调或其他进程完成 $output->info("skip {$orderSn}: already processed"); return false; } $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, ]; } }