第二次更新

This commit is contained in:
2026-09-10 20:56:46 +08:00
parent 4d6a7e7aac
commit caeb4d6760
310 changed files with 2427 additions and 382 deletions
@@ -0,0 +1,258 @@
<?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);
}
}