第二次更新
This commit is contained in:
@@ -5,12 +5,15 @@
|
||||
// | OilCard::share 创建 yoshop_partners_order 后 push 'partners_queue',本命令消费:
|
||||
// | 1. 每个 order_id 用 RedisLock 串行化,锁内重读订单状态
|
||||
// | 2. 生成企油通全平台唯一编号(确定性派生,可安全重试)
|
||||
// | 3. 调 7.7 createAndSendQuota;支付宝企业码通道下「受理成功即完成发放」
|
||||
// | 4. 发放成功 -> oil_status=1 / oil_status_zsy=2,投 notify_queue 回调下单平台
|
||||
// | 3. 调 7.7 createAndSendQuota
|
||||
// | 4. 受理成功 -> oil_status_zsy=1「发放中」,交给 qyt:status 轮询 7.8 定结果
|
||||
// |
|
||||
// | ⚠️ 7.7 是异步接口,返回成功只代表【受理】(文档 §5 第 7 条)。本命令不再直接
|
||||
// | 判定发放成功,也不再投 notify_queue —— 那是 QytStatusQueue 拿到 7.8 结果后的事。
|
||||
// |
|
||||
// | 并发/原子性:
|
||||
// | · oil_status_zsy 只在 7.7 结果确定后离开 0;空闲时 reconcile() 补投丢失的队列消息
|
||||
// | · 状态跃迁为条件 UPDATE(... WHERE oil_status_zsy=0),affected=1 才回调,天然幂等
|
||||
// | · 状态跃迁为条件 UPDATE(... WHERE oil_status_zsy=0),affected=1 才继续,天然幂等
|
||||
// | · batch_no 确定性派生 -> 崩溃重试用同一值,依赖企油通按 batch_no 幂等去重
|
||||
// | 启动:php think partners:queue(可多进程;RedisLock 保证同一订单串行)
|
||||
// +----------------------------------------------------------------------
|
||||
@@ -18,6 +21,7 @@ namespace app\console\command;
|
||||
|
||||
use app\common\lock\RedisLock;
|
||||
use app\common\model\partners\Order as OrderModel;
|
||||
use app\common\model\qyt\Enterprise as EnterpriseModel;
|
||||
use app\common\service\PartnersBalance;
|
||||
use app\common\service\QueueService;
|
||||
use app\common\service\QytOil;
|
||||
@@ -82,25 +86,57 @@ class PartnersQueue extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 确定性派生编号(含企业前缀),重试时保持一致
|
||||
$seed = $order['partners_order_no'] ?: (string) $order['order_id'];
|
||||
$batchNo = QytOil::genNo('B', $seed);
|
||||
$waybillNo = QytOil::genNo('W', $seed);
|
||||
$waybillNoCode = QytOil::genNo('C', $seed);
|
||||
// 用下单时固化在订单上的企业,而不是当前默认企业:
|
||||
// 之后改了合作商绑定或切了默认企业,在途订单仍按原企业发放
|
||||
$enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']);
|
||||
if (!$enterprise) {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update(['oil_status_zsy' => 3, 'oil_text' => '本单绑定的企油通企业不存在,无法发放', 'update_time' => time()]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
$qyt->useEnterprise($enterprise);
|
||||
|
||||
// 1. 编号:库里已生成过就直接沿用,绝不重算。
|
||||
// no_prefix 现在可在后台修改,若重试时重新派生,batch_no 会变成另一个值,
|
||||
// 上游会当成新批次 -> 同一订单被发放两次。
|
||||
// 种子里带上重发次数:后台「重新发放」会清空编号并把 qyt_retry_num +1,
|
||||
// 这样重发能拿到全新的 batch_no(否则企油通按老批次幂等,7.8 永远返回那条旧的失败记录)。
|
||||
$retryNum = (int) $order['qyt_retry_num'];
|
||||
$seed = ($order['partners_order_no'] ?: (string) $order['order_id'])
|
||||
. ($retryNum > 0 ? 'R' . $retryNum : '');
|
||||
$batchNo = $order['batch_no'] ?: $qyt->no('B', $seed);
|
||||
$waybillNo = $order['waybill_no'] ?: $qyt->no('W', $seed);
|
||||
$waybillNoCode = $order['waybill_no_code'] ?: $qyt->no('C', $seed);
|
||||
$name = trim((string) $order['driver_name']);
|
||||
$idCard = trim((string) $order['id_card']);
|
||||
if ($name === '' || $order['mobile'] === '') {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update(['oil_status_zsy' => 3, 'oil_text' => '缺司机姓名/手机号,无法发放', 'update_time' => time()]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
// 通道切换后,下单时不需要身份证的老单可能落到实名通道上:
|
||||
// 直接判失败退款,不做无意义重试(重试也永远补不到身份证号)
|
||||
if ($qyt->requireIdCard() && $idCard === '') {
|
||||
$failed = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)
|
||||
->update([
|
||||
'oil_status_zsy' => 3,
|
||||
'oil_text' => '当前通道(' . $qyt->channelName() . ')要求实名,该单缺司机身份证号',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$failed && $this->refundBalance($order);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 先落编号(状态不动,仍为 0)
|
||||
// 2. 先落编号 + 本单实际使用的支付通道(状态不动,仍为 0)
|
||||
// 通道随单落库:切换通道后老单仍能追溯当时走的是哪条通道
|
||||
Db::name('partners_order')->where('order_id', $orderId)->update([
|
||||
'batch_no' => $batchNo,
|
||||
'waybill_no' => $waybillNo,
|
||||
'waybill_no_code' => $waybillNoCode,
|
||||
'oil_text' => '发放中',
|
||||
'passageway' => $qyt->channelKey(),
|
||||
'oil_text' => '发放中(' . $qyt->channelName() . ')',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
@@ -111,6 +147,7 @@ class PartnersQueue extends Command
|
||||
'waybill_no_code' => $waybillNoCode,
|
||||
'mobile' => $order['mobile'],
|
||||
'name' => $name,
|
||||
'id_card' => $idCard,
|
||||
'amount' => $order['total_price'],
|
||||
]);
|
||||
|
||||
@@ -137,24 +174,20 @@ class PartnersQueue extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 受理成功 = 发放成功(支付宝企业码通道);条件跃迁 0 -> 2,胜出者回调下单平台
|
||||
// 4. 受理成功【不等于】发放成功:7.7 是异步接口,真实结果要用 7.8 按 batch_no 查。
|
||||
// 这里只把状态推进到「发放中」,由 qyt:status 轮询 7.8 后才决定成功/失败并回调下游。
|
||||
$affected = Db::name('partners_order')->where('order_id', $orderId)->where('oil_status_zsy', 0)->update([
|
||||
'oil_status' => 1,
|
||||
'oil_status_zsy' => 2,
|
||||
'oil_time' => time(),
|
||||
'oil_status_zsy' => 1,
|
||||
'shareNo' => $waybillNoCode,
|
||||
'oil_text' => $res['data']['msg'] ?? '发放成功',
|
||||
'oil_text' => '已受理,等待企油通发放结果'
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已受理处理)'),
|
||||
'qyt_query_time' => 0,
|
||||
'qyt_query_num' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
if ($affected) {
|
||||
$this->queueService->push('notify_queue', json_encode([
|
||||
'order_id' => $orderId,
|
||||
'type' => 'add',
|
||||
'optType' => 'share_oil',
|
||||
'retry_count' => 0,
|
||||
], 320));
|
||||
$qyt->saveLog("7.7 发放成功 order={$orderId} batch_no={$batchNo}"
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已发放处理)'));
|
||||
$qyt->saveLog("7.7 已受理 order={$orderId} batch_no={$batchNo} 通道={$qyt->channelName()}"
|
||||
. (empty($res['dup_batch']) ? '' : '(batch 已存在,按已受理处理)'));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 不改状态(仍为 0),交由 reconcile 兜底重投
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user