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\PartnersBalance; use app\common\service\QueueService; use app\common\service\QytOil; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class QytStatusQueue extends Command { /** 每轮扫描的最大订单数 */ const BATCH_LIMIT = 100; /** 空转时的休眠秒数 */ const IDLE_SLEEP = 5; /** 查询次数达到该值后仍未定论,视为异常滞留,写告警日志 */ const STUCK_NUM = 100; /** @var QueueService */ protected $queueService; protected function configure() { $this->setName('qyt:status') ->setDescription('轮询企油通 7.8 运单发放结果,确定订单最终发放态'); } protected function execute(Input $input, Output $output) { $this->queueService = new QueueService(); 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 表达式:查得越久间隔越长,避免高频打上游 * 前 10 次 30s,10~30 次 120s,之后 600s */ protected function intervalSql(): string { return 'CASE WHEN qyt_query_num < 10 THEN 30 WHEN qyt_query_num < 30 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; } $batchStatus = (int) ($res['data']['status'] ?? 0); $row = QytOil::pickWaybill($res['data'], (string) $order['waybill_no_code']); // 整批处理失败,且明细里没有本条 -> 判失败 if (!$row && $batchStatus === 5) { $this->markFailed($order, '企油通返回整批处理失败', $qyt); return; } // 明细里还没有本条:批次还在排队,继续等 if (!$row) { $this->alertIfStuck($order, $qyt); return; } switch ($row['handle_status']) { case 2: // 成功 $this->markSuccess($order, $row['remark'] ?: '发放成功', $qyt); break; case 3: // 失败 $this->markFailed($order, $row['error_msg'] ?: '企油通发放失败', $qyt); break; default: // 1 待处理,或未知值 -> 继续等 $this->alertIfStuck($order, $qyt); break; } } catch (\Throwable $e) { $this->log("order={$orderId} 轮询异常:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); } finally { $lock->releaseLock(); } } /** * 发放成功:条件跃迁 1 -> 2,胜出者才回调下单平台 */ protected function markSuccess(array $order, string $text, QytOil $qyt): void { $affected = Db::name('partners_order') ->where('order_id', $order['order_id']) ->where('oil_status_zsy', 1) ->update([ 'oil_status' => 1, 'oil_status_zsy' => 2, 'oil_time' => time(), 'oil_text' => $text, 'update_time' => time(), ]); if (!$affected) { return; } $this->queueService->push('notify_queue', json_encode([ 'order_id' => $order['order_id'], 'type' => 'add', 'optType' => 'share_oil', 'retry_count' => 0, ], 320)); $qyt->saveLog("7.8 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}" . " 查询次数={$order['qyt_query_num']}"); } /** * 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次) */ protected function markFailed(array $order, string $reason, QytOil $qyt): void { $affected = Db::name('partners_order') ->where('order_id', $order['order_id']) ->where('oil_status_zsy', 1) ->update([ 'oil_status_zsy' => 3, 'oil_text' => '发放失败:' . $reason, 'update_time' => time(), ]); if (!$affected) { return; } PartnersBalance::refund( (int) $order['partners_id'], (float) $order['total_price'], (string) $order['partners_order_no'], (int) $order['order_id'], (int) $order['store_id'] ); $qyt->saveLog("7.8 确认发放失败 order={$order['order_id']} 原因={$reason},已回补合作商余额 {$order['total_price']}"); } /** * 长期拿不到结果时告警。只写日志,不改状态、不退款 */ 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); } }