259 lines
9.7 KiB
PHP
259 lines
9.7 KiB
PHP
<?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);
|
||
}
|
||
}
|