Files
oil/server-tp6/app/console/command/QytStatusQueue.php
T
2026-09-10 20:56:46 +08:00

259 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | 企油通「运单发放结果」轮询 (7.8 waybillBatch)
// +----------------------------------------------------------------------
// | 为什么需要它:7.7 运单油费下发是【异步】接口,返回成功只代表受理,
// | 真实发放结果必须按 batch_no 调 7.8 查(《企油通接口对接文档.md》§5 第 7 条)。
// |
// | 状态流转(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\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);
}
}