1 退款中 -> 2 已退款 // | -> 3 退款失败(上游明确拒绝 / 凭证不足,可重新发起) // | -> 4 待人工确认(7.16 已发出但结果未知) // | // | ⚠️ 7.16 没有幂等键,同一请求发两次就调低两次。所以: // | · 一旦走到 7.16,任何「不确定」都进 4,绝不自动重试 // | · 4 只能通过 confirm()(查上游证据)或 resolve()(人工判定)离开 // | · 退款单号写进 7.16 的 operation_reason,confirm() 优先在 7.13 流水里找它 // | // | 并发:RedisLock 串行同一订单;状态跃迁是条件 UPDATE,affected=1 才退合作商余额。 // +---------------------------------------------------------------------- declare (strict_types=1); namespace app\common\service; use app\common\lock\RedisLock; use app\common\model\qyt\Enterprise as EnterpriseModel; use think\facade\Db; class QytRefund { const NONE = 0; const PROCESSING = 1; const SUCCESS = 2; const FAILED = 3; const CONFIRM = 4; const STATUS_TEXT = [ self::NONE => '未退款', self::PROCESSING => '退款中', self::SUCCESS => '已退款', self::FAILED => '退款失败', self::CONFIRM => '待人工确认', ]; // 失败分类,开放接口据此映射错误码 const REASON_NOT_FOUND = 'not_found'; const REASON_STATE = 'state'; const REASON_PROCESSING = 'processing'; const REASON_UPSTREAM = 'upstream'; /** 元金额比较容差 */ const EPS = 0.005; // ================================================================ // 发起退款 // ================================================================ /** * @param string $source admin / api * @param string $operator 后台账号 或 合作商 appKey */ public static function apply(int $orderId, string $source, string $operator, string $reason = ''): array { $lock = new RedisLock('qyt:refund:' . $orderId, 120); if (!$lock->acquireLock()) { return self::result(false, self::PROCESSING, '该订单退款正在处理中,请勿重复提交', self::REASON_PROCESSING); } $order = null; $refundId = 0; $refundNo = ''; // before_adjust:还没调 7.16,出任何问题都可以安全地判失败 // after_adjust :7.16 已发出,出问题只能进人工确认 $stage = 'before_adjust'; try { $order = Db::name('partners_order')->where('order_id', $orderId)->find(); if (!$order) { return self::result(false, self::NONE, '订单不存在', self::REASON_NOT_FOUND); } $current = (int) $order['refund_status']; if ($current === self::SUCCESS) { // 幂等:已退过直接返回成功 return self::result(true, self::SUCCESS, '该订单已退款', '', self::orderData($order)); } if ($current === self::PROCESSING || $current === self::CONFIRM) { return self::result(false, $current, $current === self::CONFIRM ? '该订单退款结果待人工确认,请勿重复发起' : '该订单退款正在处理中', self::REASON_PROCESSING, self::orderData($order)); } if (($err = self::checkRefundable($order)) !== '') { return self::result(false, $current, $err, self::REASON_STATE, self::orderData($order)); } $enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']); if (!$enterprise) { return self::result(false, $current, '本单绑定的企油通企业不存在,无法退款', self::REASON_STATE, self::orderData($order)); } // 退款金额 = 剩余未加油金额(锁内重读订单后计算) $amount = self::remainAmount($order); // 抢占:只有 0/3 能进入退款中 $affected = Db::name('partners_order') ->where('order_id', $orderId) ->whereIn('refund_status', [self::NONE, self::FAILED]) ->update(['refund_status' => self::PROCESSING, 'refund_text' => '退款中', 'update_time' => time()]); if (!$affected) { return self::result(false, $current, '订单状态已变化,请刷新后重试', self::REASON_PROCESSING, self::orderData($order)); } $refundNo = 'RF' . $orderId . 'T' . date('YmdHis') . mt_rand(10, 99); $refundId = (int) Db::name('qyt_refund')->insertGetId([ 'refund_no' => $refundNo, 'order_id' => $orderId, 'partners_id' => (int) $order['partners_id'], 'qyt_enterprise_id' => (int) $order['qyt_enterprise_id'], 'source' => $source, 'operator' => mb_substr($operator, 0, 64), 'reason' => mb_substr($reason, 0, 200), 'amount' => $amount, 'status' => self::PROCESSING, 'store_id' => (int) $order['store_id'], 'create_time' => time(), 'update_time' => time(), ]); $qyt = QytOil::forEnterprise($enterprise); // 1) 7.17 找凭证(只读接口,失败不影响上游,可以放心判失败) $vres = $qyt->getVoucherList((string) $order['mobile'], (string) $order['id_card'], 1, 50); if ($vres['code'] !== '00') { return self::finishFailed($order, $refundId, $refundNo, '查询支付凭证失败:' . ($vres['message'] ?: '未知错误'), $vres); } $pick = self::pickVoucher((array) ($vres['data']['list'] ?? []), $amount); if (!$pick['voucher']) { return self::finishFailed($order, $refundId, $refundNo, $pick['message'], $vres); } $voucher = $pick['voucher']; Db::name('qyt_refund')->where('refund_id', $refundId)->update([ 'quota_id' => (string) $voucher['quota_id'], 'total_before' => round((float) ($voucher['total_amount'] ?? 0), 2), 'available_before' => round((float) ($voucher['available_amount'] ?? 0), 2), 'update_time' => time(), ]); // 2) 7.16 调低额度 $stage = 'after_adjust'; $ares = $qyt->adjustVoucher( $voucher['quota_id'], '03', $amount, '订单退款 ' . $refundNo . ($reason !== '' ? ':' . $reason : ''), (string) $order['waybill_no'] ); if (!empty($ares['transport_error'])) { return self::finishConfirm($order, $refundId, $refundNo, '退款请求已发出但结果未知(网络超时或响应异常),已转人工确认,切勿重复发起', $ares); } $success = $ares['code'] === '00' && !(isset($ares['data']['success']) && $ares['data']['success'] === false); if (!$success) { $msg = (string) ($ares['data']['message'] ?? '') ?: $ares['message']; return self::finishFailed($order, $refundId, $refundNo, '企油通调额失败:' . ($msg ?: '未知错误'), $ares); } return self::finishSuccess($order, $refundId, $refundNo, $amount, '退款成功', $ares); } catch (\Throwable $e) { (new QytOil())->saveLog("退款异常 order={$orderId} stage={$stage}:" . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine()); if ($order && $refundId) { return $stage === 'after_adjust' ? self::finishConfirm($order, $refundId, $refundNo, '退款过程发生异常且 7.16 可能已生效,已转人工确认:' . $e->getMessage(), []) : self::finishFailed($order, $refundId, $refundNo, '退款过程发生异常:' . $e->getMessage(), []); } return self::result(false, self::NONE, '退款处理异常:' . $e->getMessage(), self::REASON_STATE); } finally { $lock->releaseLock(); } } // ================================================================ // 确认「待人工确认」的退款:向上游取证 // ================================================================ public static function confirm(int $orderId): array { $lock = new RedisLock('qyt:refund:' . $orderId, 120); if (!$lock->acquireLock()) { return self::result(false, self::CONFIRM, '该订单正在处理中,请稍后再试', self::REASON_PROCESSING); } try { [$order, $refund, $enterprise, $err] = self::loadConfirmContext($orderId); if ($err !== '') { return self::result(false, (int) ($order['refund_status'] ?? self::NONE), $err, self::REASON_STATE); } $qyt = QytOil::forEnterprise($enterprise); $amount = round((float) $refund['amount'], 2); // 证据 1:7.13 钱包流水里带有退款单号的调额记录 —— 最可靠 $inWallet = false; $wres = $qyt->driverWalletQuery((string) $order['mobile'], (string) $order['id_card'], 1, 50); if ($wres['code'] === '00') { foreach ((array) ($wres['data']['transactionDetails'] ?? []) as $t) { if (strpos((string) ($t['remark'] ?? ''), (string) $refund['refund_no']) !== false) { $inWallet = true; break; } } } // 证据 2:7.17 同一张凭证的总额度 / 可用余额变化 $vres = $qyt->getVoucherList((string) $order['mobile'], (string) $order['id_card'], 1, 50); $now = null; if ($vres['code'] === '00') { foreach ((array) ($vres['data']['list'] ?? []) as $v) { if ((string) ($v['quota_id'] ?? '') === (string) $refund['quota_id']) { $now = $v; break; } } } if (!$inWallet && $vres['code'] !== '00') { return self::result(false, self::CONFIRM, '暂时无法向企油通取证:' . ($vres['message'] ?: '查询失败') . ',请稍后再试', self::REASON_UPSTREAM); } $totalNow = $now ? round((float) ($now['total_amount'] ?? 0), 2) : null; $availNow = $now ? round((float) ($now['available_amount'] ?? 0), 2) : null; if ($now) { Db::name('qyt_refund')->where('refund_id', $refund['refund_id'])->update([ 'total_after' => $totalNow, 'available_after' => $availNow, 'update_time' => time(), ]); } // 退款发起前后 10 分钟起,该司机在本企业是否有其他单在发放 / 发放成功(会干扰额度比对) $grantedSince = Db::name('partners_order') ->where('id_card', $order['id_card']) ->where('qyt_enterprise_id', $order['qyt_enterprise_id']) ->where('order_id', '<>', $order['order_id']) ->whereIn('oil_status_zsy', [1, 2]) ->where('update_time', '>=', (int) $refund['create_time'] - 600) ->count() > 0; $verdict = self::judgeConfirm( $inWallet, (float) $refund['total_before'], $totalNow, (float) $refund['available_before'], $availNow, $amount, $grantedSince ); if ($verdict === 'applied') { return self::finishSuccess($order, (int) $refund['refund_id'], (string) $refund['refund_no'], $amount, '人工确认:企油通已调低额度,退款成功', $vres); } if ($verdict === 'not_applied') { return self::finishFailed($order, (int) $refund['refund_id'], (string) $refund['refund_no'], '确认结果:企油通未调低额度,退款未生效,可重新发起', $vres); } return self::result(false, self::CONFIRM, '上游数据无法自动判定(期间可能有新的发放或加油消费),请到企油通后台核对该司机凭证后使用「人工判定」', self::REASON_UPSTREAM, self::orderData($order, (string) $refund['refund_no'])); } catch (\Throwable $e) { return self::result(false, self::CONFIRM, '确认过程异常:' . $e->getMessage(), self::REASON_STATE); } finally { $lock->releaseLock(); } } /** * 人工判定「待人工确认」的退款(运营到企油通后台核对后操作) */ public static function resolve(int $orderId, bool $success, string $operator, string $remark): array { if (trim($remark) === '') { return self::result(false, self::CONFIRM, '请填写判定依据', self::REASON_STATE); } $lock = new RedisLock('qyt:refund:' . $orderId, 120); if (!$lock->acquireLock()) { return self::result(false, self::CONFIRM, '该订单正在处理中,请稍后再试', self::REASON_PROCESSING); } try { [$order, $refund, , $err] = self::loadConfirmContext($orderId); if ($err !== '') { return self::result(false, (int) ($order['refund_status'] ?? self::NONE), $err, self::REASON_STATE); } $text = "人工判定({$operator}):" . mb_substr($remark, 0, 100); return $success ? self::finishSuccess($order, (int) $refund['refund_id'], (string) $refund['refund_no'], (float) $refund['amount'], $text, []) : self::finishFailed($order, (int) $refund['refund_id'], (string) $refund['refund_no'], $text, []); } finally { $lock->releaseLock(); } } // ================================================================ // 纯函数(无副作用,便于单测) // ================================================================ /** * 退款前置条件,返回空串表示可以退 */ public static function checkRefundable(array $order): string { if ((int) $order['oil_status_zsy'] !== 2 || (int) $order['oil_status'] !== 1) { return '仅「发放成功」的订单可以退款'; } if (self::remainAmount($order) <= self::EPS) { return '订单金额已全部加油,没有可退金额'; } if (trim((string) $order['id_card']) === '') { return '订单缺少司机身份证号,无法查询支付凭证'; } return ''; } /** * 剩余未加油金额(元)= 订单金额 − 已加油金额,不小于 0。这就是可退金额 */ public static function remainAmount(array $order): float { return max(0.0, round((float) ($order['total_price'] ?? 0) - (float) ($order['used_amount'] ?? 0), 2)); } /** * 从 7.17 凭证列表里挑一张用于调低额度的凭证 * 规则:只看生效中的凭证,取可用余额最大的一张;它不够就拒绝(整单全额退,不拆多张凭证) * @return array ['voucher' => array|null, 'message' => string] */ public static function pickVoucher(array $list, float $amount): array { $active = array_values(array_filter($list, function ($v) { return is_array($v) && (string) ($v['status'] ?? '') === 'active'; })); if (!$active) { return ['voucher' => null, 'message' => '该司机在本企业下没有生效中的支付凭证,无法退款']; } usort($active, function ($a, $b) { return (float) ($b['available_amount'] ?? 0) <=> (float) ($a['available_amount'] ?? 0); }); $top = $active[0]; $avail = round((float) ($top['available_amount'] ?? 0), 2); if ($avail + self::EPS < $amount) { return ['voucher' => null, 'message' => sprintf( '司机凭证可用余额不足:可用 %.2f 元,需退 %.2f 元(凭证额度按司机累计,可能已被该司机其他订单加油用掉)', $avail, $amount)]; } return ['voucher' => $top, 'message' => '']; } /** * 根据上游证据判定 7.16 是否已生效 * · 7.13 流水里找到退款单号 -> applied * · 凭证总额度恰好减少了退款金额 -> applied * (加油消费只动可用/已用,不动总额度;调额才动总额度) * · 总额度没变 且 可用余额没有下降 -> not_applied * · 其余(凭证找不到、期间有新发放或消费导致对不上) -> unknown,交人工 * @return string applied / not_applied / unknown */ public static function judgeConfirm(bool $inWallet, float $totalBefore, ?float $totalNow, float $availBefore, ?float $availNow, float $amount, bool $grantedSince = false): string { if ($inWallet) { return 'applied'; } if ($totalNow === null || $availNow === null) { return 'unknown'; } // $grantedSince:退款发起前后该司机在本企业有新的发放。此时额度比对不可信—— // 「调低 100 + 新发 100」看起来就是没变,若据此判「未生效」,运营重新发起会重复扣减。 if (abs(($totalBefore - $totalNow) - $amount) <= self::EPS) { return $grantedSince ? 'unknown' : 'applied'; } if (abs($totalNow - $totalBefore) <= self::EPS && $availNow + self::EPS >= $availBefore) { return $grantedSince ? 'unknown' : 'not_applied'; } return 'unknown'; } // ================================================================ // 内部 // ================================================================ protected static function finishSuccess(array $order, int $refundId, string $refundNo, float $amount, string $text, array $res): array { $amount = round($amount, 2); $won = false; Db::transaction(function () use ($order, $refundId, $amount, $text, $res, &$won) { $won = (bool) Db::name('partners_order') ->where('order_id', $order['order_id']) ->whereIn('refund_status', [self::PROCESSING, self::CONFIRM]) ->update([ 'refund_status' => self::SUCCESS, 'refund_amount' => $amount, 'refund_time' => time(), 'refund_text' => mb_substr($text, 0, 250), 'update_time' => time(), ]); Db::name('qyt_refund')->where('refund_id', $refundId)->update([ 'status' => self::SUCCESS, 'message' => mb_substr($text, 0, 250), 'response' => self::dumpRes($res), 'update_time' => time(), ]); // 只有状态跃迁胜出者才回补合作商余额,保证只退一次 if ($won) { PartnersBalance::refund((int) $order['partners_id'], $amount, (string) $order['partners_order_no'], (int) $order['order_id'], (int) $order['store_id']); } }); if ($won) { // 退款计算与加油推送之间有时间差:退款期间若又来了加油,已加油 + 已退款可能超过订单金额,告警人工核对 $fresh = Db::name('partners_order')->where('order_id', $order['order_id'])->field('total_price,used_amount')->find(); if ($fresh && (float) $fresh['used_amount'] + $amount > (float) $fresh['total_price'] + self::EPS) { (new QytOil())->saveLog("【告警】退款后 已加油+已退款 超出订单金额 order={$order['order_id']}" . " total={$fresh['total_price']} used={$fresh['used_amount']} refund={$amount}"); } (new QueueService())->push('notify_queue', json_encode([ 'order_id' => $order['order_id'], 'optType' => 'refund', 'retry_count' => 0, ], 320)); (new QytOil())->saveLog("退款成功 order={$order['order_id']} refund_no={$refundNo} amount={$amount},已回补合作商余额"); } $order['refund_status'] = self::SUCCESS; $order['refund_amount'] = $amount; return self::result(true, self::SUCCESS, $text, '', self::orderData($order, $refundNo)); } protected static function finishFailed(array $order, int $refundId, string $refundNo, string $text, array $res): array { Db::name('partners_order') ->where('order_id', $order['order_id']) ->whereIn('refund_status', [self::PROCESSING, self::CONFIRM]) ->update(['refund_status' => self::FAILED, 'refund_text' => mb_substr($text, 0, 250), 'update_time' => time()]); Db::name('qyt_refund')->where('refund_id', $refundId)->update([ 'status' => self::FAILED, 'message' => mb_substr($text, 0, 250), 'response' => self::dumpRes($res), 'update_time' => time(), ]); (new QytOil())->saveLog("退款失败 order={$order['order_id']} refund_no={$refundNo}:{$text}"); $order['refund_status'] = self::FAILED; return self::result(false, self::FAILED, $text, self::REASON_UPSTREAM, self::orderData($order, $refundNo)); } protected static function finishConfirm(array $order, int $refundId, string $refundNo, string $text, array $res): array { Db::name('partners_order') ->where('order_id', $order['order_id']) ->where('refund_status', self::PROCESSING) ->update(['refund_status' => self::CONFIRM, 'refund_text' => mb_substr($text, 0, 250), 'update_time' => time()]); Db::name('qyt_refund')->where('refund_id', $refundId)->update([ 'status' => self::CONFIRM, 'message' => mb_substr($text, 0, 250), 'response' => self::dumpRes($res), 'update_time' => time(), ]); (new QytOil())->saveLog("【告警】退款结果未知 order={$order['order_id']} refund_no={$refundNo}:{$text}"); $order['refund_status'] = self::CONFIRM; return self::result(false, self::CONFIRM, $text, self::REASON_PROCESSING, self::orderData($order, $refundNo)); } /** * @return array [order, refund, enterprise, errorMessage] */ protected static function loadConfirmContext(int $orderId): array { $order = Db::name('partners_order')->where('order_id', $orderId)->find(); if (!$order) { return [[], [], null, '订单不存在']; } if ((int) $order['refund_status'] !== self::CONFIRM) { return [$order, [], null, '只有「待人工确认」的退款需要确认,当前状态:' . (self::STATUS_TEXT[(int) $order['refund_status']] ?? '')]; } $refund = Db::name('qyt_refund')->where('order_id', $orderId)->where('status', self::CONFIRM) ->order('refund_id', 'desc')->find(); if (!$refund) { return [$order, [], null, '未找到待确认的退款流水']; } $enterprise = EnterpriseModel::detail((int) $order['qyt_enterprise_id']); if (!$enterprise) { return [$order, $refund, null, '本单绑定的企油通企业不存在']; } return [$order, $refund, $enterprise, '']; } protected static function orderData(array $order, string $refundNo = ''): array { if ($refundNo === '' && !empty($order['order_id'])) { $refundNo = (string) Db::name('qyt_refund')->where('order_id', $order['order_id']) ->order('refund_id', 'desc')->value('refund_no'); } return [ 'oilCardNo' => (string) ($order['oilCardNo'] ?? ''), 'saleOrderNo' => (string) ($order['partners_order_no'] ?? ''), 'refundNo' => $refundNo, // 已退款:实际退款金额;其余:当前剩余未加油金额(即本次会退的金额) 'refundAmount' => number_format((int) ($order['refund_status'] ?? 0) === self::SUCCESS ? (float) ($order['refund_amount'] ?? 0) : self::remainAmount($order), 2, '.', ''), 'usedAmount' => number_format((float) ($order['used_amount'] ?? 0), 2, '.', ''), 'refundStatus' => (int) ($order['refund_status'] ?? 0), ]; } protected static function result(bool $ok, int $status, string $message, string $reason = '', array $data = []): array { return [ 'ok' => $ok, 'status' => $status, 'status_text' => self::STATUS_TEXT[$status] ?? '', 'message' => $message, 'reason' => $reason, 'data' => $data, ]; } protected static function dumpRes(array $res): string { if (!$res) { return ''; } $keep = ['code' => $res['code'] ?? '', 'message' => $res['message'] ?? '', 'transport_error' => !empty($res['transport_error']), 'data' => QytOil::maskDeep($res['data'] ?? [])]; return mb_substr((string) json_encode($keep, JSON_UNESCAPED_UNICODE), 0, 60000); } }