public/qytwaybill.php -> callback:queue(主路径) // | · 拉:本命令按 batch_no 调 7.8 查询(安全网) // | // | 接上推送后本命令不会下线:上游未配置接收地址不推送、推送重试 5 次后放弃、 // | 我方接收端宕机或发版重启,这三种情况都只能靠轮询兜底。此时退避间隔会自动放长, // | 见 intervalSql()。 // | // | 状态流转(oil_status_zsy): // | 0 未提交 --PartnersQueue 调7.7受理--> 1 发放中 --本命令查7.8--> 2 成功 / 3 失败 // | // | 7.8 单条 handle_status: 1 待处理 / 2 成功 / 3 失败 // | 7.8 整批 status: 1 待处理 / 2 处理中 / 3 全部成功 / 4 部分成功 / 5 处理失败 // | // | 并发/原子性: // | · 每个 order_id 用 RedisLock 串行化,可多进程运行 // | · 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才回调/退款,天然幂等 // | // | ⚠️ 绝不因为「查不到结果」而自动判失败退款: // | 钱可能已经发到司机手里,自动退款会造成资损。查询失败或长期待处理只会持续 // | 重试并在日志里告警,由人工介入处理。 // | // | 启动:php think qyt:status // +---------------------------------------------------------------------- declare (strict_types=1); namespace app\console\command; use app\common\lock\RedisLock; use app\common\model\qyt\Enterprise as EnterpriseModel; use app\common\service\QytConfig; use app\common\service\QytOil; use app\common\service\QytWaybill; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class QytStatusQueue extends Command { /** 每轮扫描的最大订单数 */ const BATCH_LIMIT = 100; /** 空转时的休眠秒数(只是一条轻量 SQL,不打上游,可以压到 1 秒) */ const IDLE_SLEEP = 1; /** 查询次数达到该值后仍未定论,视为异常滞留,写告警日志 */ const STUCK_NUM = 100; protected function configure() { $this->setName('qyt:status') ->setDescription('轮询企油通 7.8 运单发放结果,确定订单最终发放态'); } protected function execute(Input $input, Output $output) { // 通知下游 / 同步小程序端都在 QytWaybill 里做,这里只负责查结果 while (true) { $handled = 0; try { $handled = $this->scan(); } catch (\Throwable $e) { $this->log('轮询主循环异常:' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); } // 有活干就立刻进入下一轮,没有就歇一会儿 $handled === 0 && sleep(self::IDLE_SLEEP); } } /** * 扫描一批「发放中」的订单并查询结果 * @return int 本轮实际处理的订单数 */ protected function scan(): int { $now = time(); // 按退避间隔挑出到点该查的单:qyt_query_time + interval(num) <= now $ids = Db::name('partners_order') ->where('oil_status_zsy', 1) ->where('order_status', 10) ->whereRaw('qyt_query_time + ' . $this->intervalSql() . ' <= ' . $now) ->order('qyt_query_time', 'asc') ->limit(self::BATCH_LIMIT) ->column('order_id'); foreach ($ids as $orderId) { $this->handleOne((int) $orderId); } return count($ids); } /** * 退避间隔(秒)的 SQL 表达式:查得越久间隔越长,避免高频打上游 * * 为什么前几次要这么密:7.7 受理后第一次 7.8 几乎必然返回「待处理」, * 如果第二次要等 30 秒,哪怕企油通第 8 秒就发完了,用户也得等到第 35 秒。 * 绝大多数单在前 20 秒内就有结果,所以把这一段压到 3 秒一次, * 代价是每单多打上游几次;拖久的单自动退到长间隔,总 QPS 不会失控。 * * 注意:若已接入 3.10 发放结果通知(后台「企油通对接设置」里打开开关), * 走的是上面那条「安全网」曲线,不是下面这条抢时间的曲线。 * * 次数 <6 -> 3s (覆盖 0~18s,正常单在这里出结果) * 次数 <16 -> 10s (覆盖至约 2 分钟) * 次数 <26 -> 30s (覆盖至约 7 分钟) * 次数 <46 -> 120s (覆盖至约 47 分钟) * 之后 -> 600s */ protected function intervalSql(): string { // 接上 3.10 发放结果通知后,结果以推送为主,轮询退为安全网: // 只覆盖「上游没配接收地址 / 推送重试 5 次仍失败 / 我方接收端当时不可用」 // 这三种情况,没必要再抢在推送前面把上游打一遍。 if (!empty(QytConfig::get()['waybill_notify'])) { return 'CASE' . ' WHEN qyt_query_num < 3 THEN 60' . ' WHEN qyt_query_num < 8 THEN 300' . ' ELSE 900 END'; } return 'CASE' . ' WHEN qyt_query_num < 6 THEN 3' . ' WHEN qyt_query_num < 16 THEN 10' . ' WHEN qyt_query_num < 26 THEN 30' . ' WHEN qyt_query_num < 46 THEN 120' . ' ELSE 600 END'; } /** * 查询单个订单的发放结果 */ protected function handleOne(int $orderId): void { $lock = new RedisLock('qyt:status:' . $orderId, 60); if (!$lock->acquireLock()) { return; } try { $order = Db::name('partners_order')->where('order_id', $orderId)->find(); // 锁内重读:可能已被其它进程处理完 if (!$order || (int) $order['oil_status_zsy'] !== 1) { return; } if (empty($order['batch_no']) || empty($order['waybill_no_code'])) { $this->log("order={$orderId} 处于发放中但缺少 batch_no/waybill_no_code,无法查询,需人工核对"); $this->touch($orderId); return; } $enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']); if (!$enterprise) { // 不判失败:单子可能真的已经发出去了,只是企业记录被删了 $this->log("order={$orderId} 绑定的企业不存在,无法查询 7.8,需人工核对"); $this->touch($orderId); return; } $qyt = QytOil::forEnterprise($enterprise); $res = $qyt->waybillBatch((string) $order['batch_no']); // 记一次查询(无论成败都要更新时间,否则会死循环打上游) $this->touch($orderId); if ($res['code'] !== '00') { $this->log("order={$orderId} 7.8 查询失败:{$res['message']}(保持发放中,稍后重试)"); $this->alertIfStuck($order, $qyt); return; } // 判定与状态跃迁跟 3.10 推送共用 QytWaybill,两条路结论必须一致 $batchStatus = (int) ($res['data']['status'] ?? 0); $row = QytWaybill::pick($res['data'], (string) $order['waybill_no_code']); $judged = QytWaybill::judge($batchStatus, $row); if ($judged['result'] === QytWaybill::R_PENDING) { $this->alertIfStuck($order, $qyt); return; } QytWaybill::apply($order, $judged, '7.8 轮询'); } catch (\Throwable $e) { $this->log("order={$orderId} 轮询异常:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); } finally { $lock->releaseLock(); } } /** * 长期拿不到结果时告警。只写日志,不改状态、不退款 */ protected function alertIfStuck(array $order, QytOil $qyt): void { $num = (int) $order['qyt_query_num'] + 1; if ($num < self::STUCK_NUM || $num % 20 !== 0) { return; } $qyt->saveLog("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 已查询 {$num} 次" . '仍未拿到 7.8 结果,长期处于「发放中」。不会自动判失败(款可能已发出),请人工到企油通后台核对。'); } /** 记录一次查询:更新时间与次数,驱动退避 */ protected function touch(int $orderId): void { Db::name('partners_order')->where('order_id', $orderId)->update([ 'qyt_query_time' => time(), 'qyt_query_num' => Db::raw('qyt_query_num + 1'), ]); } protected function log(string $text): void { (new QytOil())->saveLog('qyt:status ' . $text); } }