1、合作商余额查询

2、分油账户司机可用额度
This commit is contained in:
2026-10-20 23:21:10 +08:00
parent 45452c1799
commit fafd3e2c16
134 changed files with 2344 additions and 352 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ class IdingjuOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
//设置路径目录信息
$dir = runtime_path('storelog/').$years.'/'.$day;
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
$dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';
+390
View File
@@ -0,0 +1,390 @@
<?php
// +----------------------------------------------------------------------
// | 小程序(商城)订单 -> 企油通发放
// +----------------------------------------------------------------------
// | 设计:小程序订单不另起一套企油通链路,而是在支付成功后为每个油券商品行
// | 建一条 yoshop_partners_order(source='mall'),复用现有机器:
// | · partners:queue 7.7 运单油费下发
// | · qyt:status 7.8 轮询发放结果
// | · 7.10 消费回调 按 waybill_no_code 匹配,无需改动
// | · QytRefund 7.17 查凭证 + 7.16 调低额度
// | · QytInvoice 7.14 企业发票
// | · QytDriver 司机档案
// |
// | 两种来源的差异只有三处,全部收敛在本类:
// | 1. 额度:合作商单扣/补 partners.balance;商城单用户已付款,改退用户余额
// | 2. 通知:合作商单 HTTP 回调下单平台;商城单没有下游,改回写 order_goods
// | 3. 建单:合作商单由开放接口 OilCard::share 建;商城单由 PaySuccess 建
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use app\common\enum\user\balanceLog\Scene as SceneEnum;
use app\common\model\qyt\Enterprise as EnterpriseModel;
use think\facade\Db;
class MallOil
{
/** partners_order.source 取值 */
const SOURCE_MALL = 'mall';
const SOURCE_PARTNER = 'partner';
/**
* 是否小程序(商城)单
* @param array|\think\Model $order partners_order 记录
* @return bool
*/
public static function isMall($order): bool
{
return (string) ($order['source'] ?? self::SOURCE_PARTNER) === self::SOURCE_MALL;
}
// ================================================================
// 建单(支付成功后调用)
// ================================================================
/**
* 为一个油券商品行创建企油通发放单并投递 partners_queue
*
* 幂等:复用已有唯一键 uk_partner_saleorder(partners_id, partners_order_no),
* mall 行 partners_id=0、partners_order_no='M{订单号}-{order_goods_id}'。
* 支付回调重入时唯一键冲突 -> 当作「已建过」返回原单,绝不重复发放。
*
* @param array $order 商城订单(yoshop_order)
* @param array $goods 订单商品行(yoshop_order_goods)
* @param array $user 下单用户(含 real_name / id_card / plate_no)
* @return array ['ok' => bool, 'message' => string, 'order_id' => int]
*/
public static function create(array $order, array $goods, array $user): array
{
// 发放金额按油券面值(数量 x 单价),与改造前的口径一致;
// 用户实付(优惠券/积分抵扣后)另存 total_pay_price,退款回补余额时按它折算。
$amount = round((float) $goods['total_price'], 2);
$paid = round((float) $goods['total_pay_price'], 2);
$paid > $amount && $paid = $amount;
if ($amount <= 0) {
return self::fail('商品金额为 0,不发放');
}
$mobile = trim((string) ($user['mobile'] ?? ''));
$name = trim((string) ($user['real_name'] ?? ''));
$idCard = strtoupper(trim((string) ($user['id_card'] ?? '')));
$plateNo = strtoupper(trim((string) ($user['plate_no'] ?? '')));
// 两个支付通道都要求实名,缺一不可。结算时已拦截,这里是第二道闸。
$missing = [];
$mobile === '' && $missing[] = '手机号';
$name === '' && $missing[] = '姓名';
$idCard === '' && $missing[] = '身份证号';
if ($missing) {
return self::fail('缺' . implode('/', $missing) . ',无法发放');
}
// 本单使用的企油通企业:小程序没有合作商绑定关系,直接取默认企业
$enterprise = EnterpriseModel::resolve(0);
if (!$enterprise) {
return self::fail('系统未配置可用的企油通企业,无法发放');
}
$saleOrderNo = 'M' . $order['order_no'] . '-' . $goods['order_goods_id'];
$exist = Db::name('partners_order')
->where('partners_id', 0)
->where('partners_order_no', $saleOrderNo)
->find();
if ($exist) {
// 已建过(支付回调重入):直接复用,并补投一次队列(下发是条件跃迁,重复投递安全)
self::push((int) $exist['order_id']);
return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $exist['order_id']];
}
$now = time();
// 带上 order_goods_id(全局唯一)保证油卡编号不会撞号
$oilCardNo = 'OIL' . $now . 'G' . (int) $goods['order_goods_id'];
$row = [
'province' => (string) ($goods['province'] ?? ''),
'mobile' => $mobile,
'driver_name' => $name,
'id_card' => $idCard,
'plate_no' => $plateNo,
// 企业与通道随单固化:之后切默认企业,在途单仍按下单时的企业发放
'qyt_enterprise_id' => (int) $enterprise['enterprise_id'],
'passageway' => (string) $enterprise['passageway'],
'oilCardType' => 'zsy_ticket',
'oilCardNo' => $oilCardNo,
'partners_order_no' => $saleOrderNo,
'partners_id' => 0,
'source' => self::SOURCE_MALL,
'mall_order_id' => (int) $order['order_id'],
'mall_order_goods_id' => (int) $goods['order_goods_id'],
'user_id' => (int) $order['user_id'],
'goods_id' => (int) $goods['goods_id'],
'goods_name' => (string) $goods['goods_name'],
'goods_no' => (string) $goods['goods_no'],
'goods_price' => $amount,
'total_num' => 1,
'total_price' => $amount,
'total_pay_price' => $paid,
'order_status' => 10,
'store_id' => (int) $order['store_id'],
'create_time' => $now,
'update_time' => $now,
];
try {
$orderId = (int) Db::name('partners_order')->insertGetId($row);
} catch (\Throwable $e) {
if (strpos($e->getMessage(), 'Duplicate') !== false) {
$again = Db::name('partners_order')->where('partners_id', 0)
->where('partners_order_no', $saleOrderNo)->find();
if ($again) {
self::push((int) $again['order_id']);
return ['ok' => true, 'message' => '已存在发放单', 'order_id' => (int) $again['order_id']];
}
}
self::log('商城单建发放单失败 order_goods=' . $goods['order_goods_id'] . ':' . $e->getMessage());
return self::fail('建发放单失败:' . $e->getMessage());
}
// 把油卡编号写回订单商品行,小程序端与后台基础列继续按原字段展示
Db::name('order_goods')->where('order_goods_id', (int) $goods['order_goods_id'])->update([
'oilCardNo' => $oilCardNo,
'mobile' => $mobile,
'oil_text' => '已提交发放',
]);
// 司机档案(按身份证号归档并标记信息冲突;内部吞异常,不影响主流程)
QytDriver::touch([
'order_id' => $orderId,
'id_card' => $idCard,
'driver_name' => $name,
'mobile' => $mobile,
'store_id' => (int) $order['store_id'],
]);
self::push($orderId);
return ['ok' => true, 'message' => '已提交发放', 'order_id' => $orderId];
}
// ================================================================
// 额度回补(发放失败 / 退款成功)
// ================================================================
/**
* 按订单来源把钱退回原处:
* · 合作商单 -> 回补 partners.balance
* · 商城单 -> 回补下单用户余额并记一条余额流水
* 只在状态跃迁胜出后调用,天然只执行一次。
*
* @param array|\think\Model $order partners_order 记录
* @param float $amount 金额(元)
* @param string $scene 场景描述,写进流水
*/
public static function giveBack($order, float $amount, string $scene = '发放失败'): void
{
if ($amount <= 0) {
return;
}
if (!self::isMall($order)) {
PartnersBalance::refund(
(int) $order['partners_id'],
$amount,
(string) $order['partners_order_no'],
(int) $order['order_id'],
(int) $order['store_id']
);
self::log("{$scene}已回补合作商余额 order={$order['order_id']} amount={$amount}");
return;
}
$userId = (int) ($order['user_id'] ?? 0);
if ($userId <= 0) {
self::log("【告警】商城单缺少 user_id,无法退款 order={$order['order_id']} amount={$amount}");
return;
}
// 退回余额按「用户实付 / 油券面值」折算:用过优惠券或积分抵扣时,
// 面值大于实付,按面值原额退会白送钱。全额发放失败时折算结果正好等于实付。
$amount = self::toPaid($order, $amount);
if ($amount <= 0) {
return;
}
$money = self::money($amount);
try {
Db::transaction(function () use ($userId, $money, $amount, $order, $scene) {
Db::name('user')->where('user_id', $userId)
->update(['balance' => Db::raw('`balance` + ' . $money)]);
Db::name('user_balance_log')->insert([
'user_id' => $userId,
'scene' => SceneEnum::REFUND,
'money' => round($amount, 2),
'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
'remark' => '',
'store_id' => (int) $order['store_id'],
'create_time' => time(),
]);
});
self::log("{$scene}已退回用户余额 order={$order['order_id']} user={$userId} amount={$amount}");
} catch (\Throwable $e) {
// 退款失败必须人工介入:额度那边可能已经收回了
self::log("【告警】商城单退回用户余额失败 order={$order['order_id']} user={$userId}"
. " amount={$amount}:" . $e->getMessage());
}
}
/**
* 扣减额度(后台「重新发放」用)
* 发放失败时额度已原路退回,重发必须重新扣一次:
* · 合作商单 -> 扣 partners.balance
* · 商城单 -> 扣下单用户余额(按实付折算)
* 两者都是 UPDATE ... WHERE balance >= amount,天然防并发超扣。
*
* @return bool false=余额不足,未做任何变更
*/
public static function take($order, float $amount, string $scene = '重新发放'): bool
{
if ($amount <= 0) {
return true;
}
if (!self::isMall($order)) {
$logId = PartnersBalance::deduct(
(int) $order['partners_id'],
$amount,
(string) $order['partners_order_no'],
(int) $order['order_id'],
(int) $order['store_id']
);
return $logId !== false;
}
$userId = (int) ($order['user_id'] ?? 0);
if ($userId <= 0) {
return false;
}
$amount = self::toPaid($order, $amount);
if ($amount <= 0) {
return true;
}
$money = self::money($amount);
$affected = Db::name('user')->where('user_id', $userId)
->where('balance', '>=', $amount)
->update(['balance' => Db::raw('`balance` - ' . $money)]);
if (!$affected) {
return false;
}
try {
Db::name('user_balance_log')->insert([
'user_id' => $userId,
'scene' => SceneEnum::CONSUME,
'money' => -$amount,
'describe' => $scene . ':' . ($order['oilCardNo'] ?: $order['order_id']),
'remark' => '',
'store_id' => (int) $order['store_id'],
'create_time' => time(),
]);
} catch (\Throwable $e) {
self::log('商城单扣款流水写入失败 order=' . $order['order_id'] . ':' . $e->getMessage());
}
return true;
}
/** 额度不足时的提示文案 */
public static function notEnoughText($order): string
{
return self::isMall($order)
? '下单用户余额不足(发放失败时已退回其余额),无法重新发放'
: '合作商余额不足,请先充值后再重发';
}
// ================================================================
// 状态回写(代替合作商单的 HTTP 回调)
// ================================================================
/**
* 把发放单的状态同步回 yoshop_order_goods。
* 小程序端的订单列表/详情、后台订单页的基础列都读 order_goods,
* 所以商城单不走 HTTP 通知,改为在同样的时机回写这张表。
*
* @param array|\think\Model $order partners_order 记录
* @param string $optType share_oil / add_oil / refund
*/
public static function syncGoods($order, string $optType = ''): void
{
$goodsId = (int) ($order['mall_order_goods_id'] ?? 0);
if (!self::isMall($order) || $goodsId <= 0) {
return;
}
try {
// 锁内重读发放单:调用方可能持有的是跃迁前的旧快照
$fresh = Db::name('partners_order')->where('order_id', (int) $order['order_id'])->find();
if (!$fresh) {
return;
}
$data = [
'oil_status' => (int) $fresh['oil_status'],
'oil_status_zsy' => (int) $fresh['oil_status_zsy'],
'oil_time' => (int) $fresh['oil_time'],
'oil_text' => mb_substr((string) $fresh['oil_text'], 0, 190),
'shareNo' => (string) $fresh['shareNo'],
'use_status' => (int) $fresh['use_status'],
'use_time' => (int) $fresh['use_time'],
'use_text' => mb_substr((string) $fresh['use_text'], 0, 190),
// 一单可分多次加油:把累计值带到订单商品行,小程序端据此算剩余可加油金额
'used_amount' => (float) $fresh['used_amount'],
'used_num' => (int) $fresh['used_num'],
'refund_status' => (int) $fresh['refund_status'],
'refund_amount' => (float) $fresh['refund_amount'],
];
// 发放失败时额度已原路退回用户余额,把这句一起告诉用户,省掉一轮客服询问
if ((int) $fresh['oil_status_zsy'] === 3) {
$data['oil_text'] = mb_substr($fresh['oil_text'] . ',款项已退回账户余额', 0, 190);
}
// 已退款的单在小程序端不应再显示「可核销」
if ((int) $fresh['refund_status'] === 2) {
$data['oil_text'] = mb_substr('已退款:' . $fresh['refund_text'], 0, 190);
}
Db::name('order_goods')->where('order_goods_id', $goodsId)->update($data);
} catch (\Throwable $e) {
self::log('商城单回写 order_goods 失败 order=' . $order['order_id'] . ':' . $e->getMessage());
}
}
// ================================================================
// 内部
// ================================================================
/**
* 把「油券面值金额」折算成「用户实付金额」
* 面值 total_price 与实付 total_pay_price 在用过优惠券/积分时不相等。
*/
protected static function toPaid($order, float $amount): float
{
$face = (float) ($order['total_price'] ?? 0);
$paid = (float) ($order['total_pay_price'] ?? 0);
if ($face <= 0 || $paid >= $face) {
return round($amount, 2);
}
return round(min($amount * $paid / $face, $paid), 2);
}
/** 投递下发队列 */
protected static function push(int $orderId): void
{
(new QueueService())->push('partners_queue', json_encode(['order_id' => $orderId], 320));
}
protected static function fail(string $message): array
{
return ['ok' => false, 'message' => $message, 'order_id' => 0];
}
/** 金额安全格式化(仅数字与小数点,防拼接注入) */
protected static function money(float $amount): string
{
return number_format(round($amount, 2), 2, '.', '');
}
protected static function log(string $text): void
{
(new QytOil())->saveLog('[商城单] ' . $text);
}
}
@@ -35,8 +35,8 @@ class PartnersOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
//设置路径目录信息
$dir = runtime_path('storelog/').$years.'/'.$day;
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
$dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';
@@ -29,6 +29,31 @@ class QueueService
return $message ? json_decode($message, true) : null;
}
/**
* 阻塞式取出消息:队列空时挂在 Redis 上等,有消息立刻返回。
*
* 相比 pop() + sleep(1) 的轮询,取到消息的延迟从「最多 1 秒」降到接近 0,
* 对「下单 -> 调上游发放」这种链路首段能实打实省掉一截等待。
*
* @param string $queueName
* @param int $timeout 最长阻塞秒数(到点返回 null,便于外层做兜底巡检)
* @return array|string|null
*/
public function bpop($queueName, $timeout = 5)
{
// BLPOP 会把连接挂住 $timeout 秒,必须短于 Redis 连接的读超时
// (未显式配置时取 PHP default_socket_timeout,通常 60 秒),
// 否则会抛读超时异常而不是正常返回 null。这里夹到 30 秒以内。
$timeout = max(1, min(30, (int) $timeout));
// Predis 的 blpop 返回 [队列名, 消息],超时返回 null
$res = $this->redis->blpop([$queueName], $timeout);
if (empty($res[1])) {
return null;
}
$decoded = json_decode($res[1], true);
return $decoded === null ? $res[1] : $decoded;
}
/**
* 延迟推送(ZSET)
* @param string $queueName
+5 -2
View File
@@ -29,7 +29,7 @@ class QytConfig
/** 后台设置可覆盖的接入方级标量项 */
const OVERRIDABLE = [
'base_url', 'app_id', 'no_prefix', 'voucher_name',
'h5_base_url', 'h5_app_id', 'consume_notify_url',
'h5_base_url', 'h5_app_id', 'consume_notify_url', 'waybill_notify_url',
];
/** @var array|null */
@@ -55,10 +55,13 @@ class QytConfig
$conf[$k] = trim((string) $db[$k]);
}
}
// 日志开关是布尔,0 是合法值,不能用非空判断
// 布尔项 0 是合法值,不能用非空判断
if (isset($db['log'])) {
$conf['log'] = (bool) $db['log'];
}
if (isset($db['waybill_notify'])) {
$conf['waybill_notify'] = (bool) $db['waybill_notify'];
}
self::$cache = $conf;
self::$cacheAt = time();
+2 -2
View File
@@ -550,13 +550,13 @@ class QytOil extends BaseService
return bcdiv((string) (int) $fen, '100', 2);
}
/** 记录日志到 runtime/storelog */
/** 记录日志到 runtime/storelog(store_log_path 保证各进程写同一处,见 app/common.php) */
public function saveLog($data): void
{
if (empty($this->conf['log'])) {
return;
}
$dir = runtime_path('storelog/') . date('Ym') . '/' . date('Ymd');
$dir = store_log_path() . date('Ym') . '/' . date('Ymd');
is_dir($dir) || @mkdir($dir, 0777, true);
@file_put_contents(
$dir . '/qyt_' . date('YmdH') . '.txt',
+6 -4
View File
@@ -403,10 +403,11 @@ class QytRefund
'response' => self::dumpRes($res),
'update_time' => time(),
]);
// 只有状态跃迁胜出者才回补合作商余额,保证只退一次
// 只有状态跃迁胜出者才原路退款,保证只退一次
// · 合作商单 -> 回补 partners.balance
// · 商城单 -> 退回下单用户余额(按实付折算)
if ($won) {
PartnersBalance::refund((int) $order['partners_id'], $amount, (string) $order['partners_order_no'],
(int) $order['order_id'], (int) $order['store_id']);
MallOil::giveBack($order, $amount, '订单退款');
}
});
@@ -422,7 +423,8 @@ class QytRefund
'optType' => 'refund',
'retry_count' => 0,
], 320));
(new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已回补合作商余额");
MallOil::syncGoods($order, 'refund');
(new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已原路退款");
}
$order['refund_status'] = self::SUCCESS;
$order['refund_amount'] = $amount;
@@ -0,0 +1,207 @@
<?php
// +----------------------------------------------------------------------
// | 企油通 · 运单油费「发放结果」状态机
// +----------------------------------------------------------------------
// | 7.7 运单油费下发是【异步】接口,受理成功不等于发放成功。拿到最终结果有两条路:
// | · 推:3.10 发放结果通知(一个批次一条,整批处理完后企油通主动推给我们)
// | · 拉:7.8 waybillBatch 按 batch_no 查询(兜底,见下)
// |
// | 两条路得出的结论必须完全一致,所以判定与状态跃迁都收敛在本类,
// | QytNotify(推)与 QytStatusQueue(拉)只负责把数据喂进来。
// |
// | 为什么有了推送还要留着拉:
// | 1. 未在企油通后台配置接收地址 -> 根本不推(且不视为失败、不补推)
// | 2. 推送失败重试 5 次后放弃(文档建议此时用 7.8 查)
// | 3. 我们自己的接收端宕机 / 发版重启窗口期
// | 所以轮询保留为安全网,只是间隔放长(见 QytStatusQueue::intervalSql)
// |
// | 并发/幂等:
// | 状态跃迁是条件 UPDATE(... WHERE oil_status_zsy=1),affected=1 才退款/回调,
// | 因此「同一批次重复推送」「推送与轮询同时到达」都只会生效一次。
// +----------------------------------------------------------------------
declare (strict_types=1);
namespace app\common\service;
use think\facade\Db;
class QytWaybill
{
// 明细级 handle_status(7.8 出参 / 3.10 通知 data.list[].handle_status)
const HANDLE_PENDING = 1;
const HANDLE_SUCCESS = 2;
const HANDLE_FAILED = 3;
// 批次级 status(7.8 出参 data.status / 3.10 通知 data.status)
const BATCH_PENDING = 1; // 待处理
const BATCH_RUNNING = 2; // 处理中
const BATCH_ALL_OK = 3; // 全部成功
const BATCH_PART_OK = 4; // 部分成功
const BATCH_FAILED = 5; // 处理失败
// judge() 的结论
const R_SUCCESS = 'success';
const R_FAILED = 'failed';
const R_PENDING = 'pending';
/**
* 纯函数:根据批次级 status 与本条明细,判定这一单的最终发放结果。
*
* 文档 3.10 明确:**以批次级 status 为准**——批次因「企业信息不存在」之类
* 整体失败时,明细里可能仍然写着「待处理」。只看 handle_status 会让这种单
* 永远停在发放中,钱也永远不退。
*
* @param int $batchStatus 批次级 status
* @param array|null $row 本条明细 ['handle_status','error_msg','remark'],不存在传 null
* @return array ['result' => success|failed|pending, 'text' => string]
*/
public static function judge(int $batchStatus, ?array $row): array
{
$handle = $row ? (int) ($row['handle_status'] ?? 0) : 0;
// 1) 整批处理失败:不管明细写的是什么,这一单都拿不到钱
if ($batchStatus === self::BATCH_FAILED && $handle !== self::HANDLE_SUCCESS) {
$msg = $row ? trim((string) ($row['error_msg'] ?? '')) : '';
return ['result' => self::R_FAILED, 'text' => $msg !== '' ? $msg : '企油通返回整批处理失败'];
}
// 2) 明细里没有这一条:批次还在排队,继续等
if (!$row) {
return ['result' => self::R_PENDING, 'text' => ''];
}
if ($handle === self::HANDLE_SUCCESS) {
return ['result' => self::R_SUCCESS, 'text' => trim((string) ($row['remark'] ?? '')) ?: '发放成功'];
}
if ($handle === self::HANDLE_FAILED) {
return ['result' => self::R_FAILED, 'text' => trim((string) ($row['error_msg'] ?? '')) ?: '企油通发放失败'];
}
// 1 待处理,或未知值 -> 继续等
return ['result' => self::R_PENDING, 'text' => ''];
}
/**
* 从 3.10 通知或 7.8 响应的 data.list 里挑出某条运单流水
* @return array|null ['handle_status','error_msg','remark']
*/
public static function pick(array $data, string $waybillNoCode): ?array
{
return QytOil::pickWaybill($data, $waybillNoCode);
}
/**
* 发放成功:条件跃迁 1 -> 2,胜出者才回调下游 / 同步小程序端
*
* @param array $order partners_order 记录
* @param string $text 备注(上游 remark)
* @param string $from 结果来源,只用于日志:'3.10 通知' / '7.8 轮询'
* @return bool 是否由本次调用完成跃迁
*/
public static function markSuccess(array $order, string $text, string $from = ''): bool
{
$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' => mb_substr($text, 0, 190),
'update_time' => time(),
]);
if (!$affected) {
return false;
}
MallOil::syncGoods($order, 'share_oil');
(new QueueService())->push('notify_queue', json_encode([
'order_id' => $order['order_id'],
'type' => 'add',
'optType' => 'share_oil',
'retry_count' => 0,
], 320));
self::log("{$from} 确认发放成功 order={$order['order_id']} batch_no={$order['batch_no']}");
return true;
}
/**
* 发放失败:条件跃迁 1 -> 3,胜出者才退款(保证只退一次)
*
* @return bool 是否由本次调用完成跃迁
*/
public static function markFailed(array $order, string $reason, string $from = ''): bool
{
$affected = Db::name('partners_order')
->where('order_id', $order['order_id'])
->where('oil_status_zsy', 1)
->update([
'oil_status_zsy' => 3,
'oil_text' => mb_substr('发放失败:' . $reason, 0, 190),
'update_time' => time(),
]);
if (!$affected) {
return false;
}
// 原路退款:合作商单回补合作商余额,商城单退回下单用户余额
MallOil::giveBack($order, (float) $order['total_price'], '发放失败');
MallOil::syncGoods($order, 'share_oil');
self::log("{$from} 确认发放失败 order={$order['order_id']} 原因={$reason},已原路退款 {$order['total_price']}");
return true;
}
/**
* 按判定结果落状态。已经不在「发放中」的单会被安全忽略,
* 但「已判失败又被告知成功」属于资损风险,必须告警而不是静默翻转。
*
* @return string 实际发生的动作:success / failed / pending / skipped / conflict
*/
public static function apply(array $order, array $judged, string $from = ''): string
{
$current = (int) $order['oil_status_zsy'];
if ($judged['result'] === self::R_PENDING) {
return self::R_PENDING;
}
if ($current !== 1) {
// 终态与上游结论不一致 -> 一律只告警、不自动翻转:
// 3 -> 成功:我们已经退过款,钱却可能真发出去了(资损)
// 2 -> 失败:我们已经通知下游发放成功、小程序端也显示可加油了
// 两种都要人工到企油通后台核对,自动改状态只会把问题放大。
$conflict = ($current === 3 && $judged['result'] === self::R_SUCCESS)
|| ($current === 2 && $judged['result'] === self::R_FAILED);
if ($conflict) {
self::log("【告警】order={$order['order_id']} batch_no={$order['batch_no']} 本地发放态为「"
. ($current === 3 ? '发放失败(已退款)' : '发放成功(已通知下游)')
. "」,但{$from}返回「" . ($judged['result'] === self::R_SUCCESS ? '发放成功' : '发放失败')
. "」:{$judged['text']}。请人工到企油通后台核对,系统不会自动翻转状态。");
return 'conflict';
}
return 'skipped';
}
if ($judged['result'] === self::R_SUCCESS) {
return self::markSuccess($order, $judged['text'], $from) ? self::R_SUCCESS : 'skipped';
}
return self::markFailed($order, $judged['text'], $from) ? self::R_FAILED : 'skipped';
}
/**
* 记录本次通知时间与次数,便于排查「这单到底推没推过、推了几次」。
* 纯留痕,失败不能影响发放态跃迁(比如迁移脚本还没跑、少了这两列)。
*/
public static function touchNotify(int $orderId, int $notifyTime): void
{
try {
Db::name('partners_order')->where('order_id', $orderId)->update([
'qyt_notify_time' => $notifyTime ?: time(),
'qyt_notify_num' => Db::raw('qyt_notify_num + 1'),
]);
} catch (\Throwable $e) {
self::log('3.10 通知留痕失败 order=' . $orderId . ':' . $e->getMessage()
. '(不影响发放态,确认 qyt_migration_waybill_notify.sql 是否已执行)');
}
}
protected static function log(string $text): void
{
(new QytOil())->saveLog($text);
}
}
+2 -2
View File
@@ -35,8 +35,8 @@ class SybOil extends BaseService
public function saveLogStore($data){
$years = date('Ym');
$day = date('Ymd');
//设置路径目录信息
$dir = runtime_path('storelog/').$years.'/'.$day;
//设置路径目录信息(store_log_path 保证 CLI / api / store 各进程写同一处,见 app/common.php)
$dir = store_log_path().$years.'/'.$day;
//目录不存在就创建
$this->mk_dir_store($dir);
$url = $dir.'/'.date('YmdH').'.txt';