410 行
17 KiB
PHP
410 行
17 KiB
PHP
<?php
|
||
|
||
namespace app\common\library;
|
||
|
||
use addons\epay\library\Service as EpayService;
|
||
use think\Db;
|
||
use think\Log;
|
||
|
||
/**
|
||
* 订单退款(微信原路退回 / 余额退回 + 业务回滚 + 佣金冲销)
|
||
*
|
||
* 规则:
|
||
* 1. 只有 status=paid 的订单可退;
|
||
* 2. 退款金额 > 0、≤ 订单实付金额,同一订单只退一次;
|
||
* 3. 微信订单先调微信退款接口,受理成功后才改库;余额订单直接退回余额;
|
||
* 4. 退款后按订单类型回滚权益:
|
||
* recharge → 扣回余额(余额不足则拒绝退款)
|
||
* payview → 删除解锁记录
|
||
* member → 按套餐天数/条数扣减会员权益
|
||
* post → 信息保留(由运营决定是否下架)
|
||
* 5. 无论哪种订单,退款都会冲销该订单产生的分销佣金(防止“退款后仍拿佣金”)。
|
||
*/
|
||
class OrderRefund
|
||
{
|
||
/**
|
||
* @param int $orderId
|
||
* @param float|null $amount 不传则全额退款
|
||
* @param string $reason
|
||
* @return array{refundNo:string,amount:float}
|
||
* @throws \Exception
|
||
*/
|
||
public static function refund($orderId, $amount = null, $reason = '')
|
||
{
|
||
$order = Db::name('fc_order')->where('id', (int)$orderId)->find();
|
||
if (!$order) {
|
||
throw new \Exception('订单不存在');
|
||
}
|
||
if ((string)$order['status'] !== 'paid') {
|
||
throw new \Exception('只有已支付订单可以退款');
|
||
}
|
||
|
||
$paid = round((float)$order['amount'], 2);
|
||
$amount = $amount === null ? $paid : round((float)$amount, 2);
|
||
if ($amount <= 0) {
|
||
throw new \Exception('退款金额需大于 0');
|
||
}
|
||
if ($amount > $paid) {
|
||
throw new \Exception('退款金额不能超过订单实付金额 ' . $paid . ' 元');
|
||
}
|
||
|
||
$payType = (string)$order['pay_type'];
|
||
if (!in_array($payType, ['wxpay', 'balance'], true)) {
|
||
throw new \Exception('该订单支付方式不支持退款');
|
||
}
|
||
|
||
// 充值订单:余额必须足够扣回,否则先拒绝(避免钱退了余额扣不回来)
|
||
if ((string)$order['type'] === 'recharge') {
|
||
$balance = (float)Db::name('user')->where('id', (int)$order['user_id'])->value('money');
|
||
if ($balance < $amount) {
|
||
throw new \Exception('用户当前余额不足 ' . $amount . ' 元,无法退回该笔充值(请先冻结或人工处理)');
|
||
}
|
||
}
|
||
|
||
$refundNo = $payType === 'wxpay' ? self::refundNo($order) : '';
|
||
|
||
// 0) 原子抢占:paid → refunding,避免并发重复退款(两个请求都调微信)
|
||
$claimed = Db::name('fc_order')
|
||
->where('id', (int)$order['id'])
|
||
->where('status', 'paid')
|
||
->update([
|
||
'status' => 'refunding',
|
||
'out_refund_no' => $refundNo,
|
||
'updatetime' => time(),
|
||
]);
|
||
if (!$claimed) {
|
||
throw new \Exception('订单正在退款或已退款,请勿重复操作');
|
||
}
|
||
|
||
// 1) 微信订单先退款(外部调用,放在事务外)
|
||
if ($payType === 'wxpay') {
|
||
try {
|
||
self::requestWechatRefund($order, $refundNo, $amount, $reason);
|
||
} catch (\Throwable $e) {
|
||
// 退款未受理:回退为已支付,允许重新操作
|
||
Db::name('fc_order')->where('id', (int)$order['id'])->where('status', 'refunding')->update([
|
||
'status' => 'paid',
|
||
'out_refund_no' => '',
|
||
'updatetime' => time(),
|
||
]);
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
$now = time();
|
||
Db::transaction(function () use ($order, $amount, $reason, $refundNo, $now, $payType) {
|
||
// 行锁 + 二次校验:此时应为 refunding
|
||
$locked = Db::name('fc_order')->where('id', (int)$order['id'])->lock(true)->find();
|
||
if (!$locked || (string)$locked['status'] !== 'refunding') {
|
||
throw new \Exception('订单状态已变化,退款未写入');
|
||
}
|
||
|
||
Db::name('fc_order')->where('id', (int)$order['id'])->update([
|
||
'status' => 'refunded',
|
||
'refund_amount' => $amount,
|
||
'refund_reason' => (string)$reason,
|
||
'out_refund_no' => $refundNo,
|
||
'updatetime' => $now,
|
||
]);
|
||
|
||
$userId = (int)$locked['user_id'];
|
||
|
||
// 2) 余额订单:直接退回余额
|
||
if ($payType === 'balance') {
|
||
Db::name('user')->where('id', $userId)->setInc('money', $amount);
|
||
$balance = round((float)Db::name('user')->where('id', $userId)->value('money'), 2);
|
||
|
||
self::addFlow('income', '订单退款(余额)', $amount, $balance, (int)$locked['id'], '余额退款', $userId);
|
||
// 钱包流水(fa_user.money 增)→ 余额中心「余额明细」
|
||
self::addWalletFlow($userId, 'income', '订单退款(余额)', $amount, $balance, (int)$locked['id'], '余额退款');
|
||
} else {
|
||
self::addFlow('expense', '订单退款', $amount, 0, (int)$locked['id'], '微信退款 ' . $refundNo, $userId);
|
||
}
|
||
|
||
// 3) 权益回滚
|
||
self::rollbackBenefits($locked, $amount, $now);
|
||
|
||
// 3.5) 退回优惠券(退款后券可再次使用)
|
||
CouponService::release((string)$locked['order_no']);
|
||
|
||
// 4) 分销佣金冲销(所有支付方式都要做)
|
||
self::reverseCommission($locked, $now);
|
||
|
||
Db::name('fc_message')->insert([
|
||
'user_id' => $userId,
|
||
'type' => 'system',
|
||
'title' => '订单已退款',
|
||
'content' => '您的订单「' . $locked['title'] . '」已退款 ' . $amount . ' 元'
|
||
. ($payType === 'balance' ? ',已退回账户余额。' : ',将按原支付路径退回。'),
|
||
'isread' => 0,
|
||
'createtime' => $now,
|
||
]);
|
||
});
|
||
|
||
Log::record('订单退款成功:订单 ' . $order['order_no'] . ',退款单号 ' . ($refundNo ?: '-') . ',金额 ' . $amount . ' 元,方式 ' . $payType, 'info');
|
||
|
||
return ['refundNo' => $refundNo, 'amount' => $amount];
|
||
}
|
||
|
||
/**
|
||
* 退款结果回调处理(微信 V3)
|
||
*
|
||
* @param array $data 解密后的回调数据
|
||
* @return string 处理结果
|
||
*/
|
||
public static function handleNotify(array $data)
|
||
{
|
||
$outRefundNo = isset($data['out_refund_no']) ? (string)$data['out_refund_no'] : '';
|
||
if ($outRefundNo === '') {
|
||
return 'ignored';
|
||
}
|
||
|
||
$order = Db::name('fc_order')->where('out_refund_no', $outRefundNo)->find();
|
||
if (!$order) {
|
||
Log::record('微信退款回调:未找到订单 out_refund_no=' . $outRefundNo, 'error');
|
||
return 'ignored';
|
||
}
|
||
|
||
$status = strtoupper((string)(isset($data['refund_status']) ? $data['refund_status'] : ''));
|
||
if ($status === 'SUCCESS') {
|
||
// 正常流程里已置 refunded,这里只做幂等确认
|
||
return 'refunded';
|
||
}
|
||
|
||
if (in_array($status, ['ABNORMAL', 'CLOSED'], true)) {
|
||
// 退款失败/关闭:把订单改回 paid,等待人工重试
|
||
Db::transaction(function () use ($order, $status) {
|
||
$locked = Db::name('fc_order')->where('id', (int)$order['id'])->lock(true)->find();
|
||
if (!$locked || (string)$locked['status'] !== 'refunded') {
|
||
return;
|
||
}
|
||
Db::name('fc_order')->where('id', (int)$locked['id'])->update([
|
||
'status' => 'paid',
|
||
'refund_amount' => 0,
|
||
'refund_reason' => '',
|
||
'updatetime' => time(),
|
||
]);
|
||
Db::name('fc_message')->insert([
|
||
'user_id' => (int)$locked['user_id'],
|
||
'type' => 'system',
|
||
'title' => '退款异常',
|
||
'content' => '您的订单「' . $locked['title'] . '」退款未成功(' . $status . '),客服将尽快处理。',
|
||
'isread' => 0,
|
||
'createtime' => time(),
|
||
]);
|
||
});
|
||
Log::record('微信退款异常:订单 ' . $order['order_no'] . ' 状态 ' . $status, 'error');
|
||
return 'failed';
|
||
}
|
||
|
||
return 'refunding';
|
||
}
|
||
|
||
/* ==================== 内部实现 ==================== */
|
||
|
||
/** 生成退款单号(≤ 64 字符,全局唯一) */
|
||
protected static function refundNo(array $order)
|
||
{
|
||
return 'RF' . (string)$order['order_no'] . bin2hex(random_bytes(2));
|
||
}
|
||
|
||
/**
|
||
* 调用微信退款(V2/V3 参数不同,交给插件 SDK 处理签名)
|
||
*
|
||
* @throws \Exception
|
||
*/
|
||
protected static function requestWechatRefund(array $order, $refundNo, $amount, $reason)
|
||
{
|
||
// 确保 epay 的 Yansongda 命名空间已注册(后台/CLI 场景下 action_begin 钩子可能未触发)
|
||
if (!class_exists('\Yansongda\Pay\Pay')) {
|
||
(new \addons\epay\Epay())->actionBegin();
|
||
}
|
||
|
||
$config = EpayService::getConfig('wechat', []);
|
||
$pay = \Yansongda\Pay\Pay::wechat($config);
|
||
|
||
$paidCents = (int)round((float)$order['amount'] * 100);
|
||
$refundCents = (int)round($amount * 100);
|
||
|
||
try {
|
||
if (EpayService::isVersionV3()) {
|
||
$result = $pay->refund([
|
||
'out_trade_no' => (string)$order['order_no'],
|
||
'out_refund_no' => $refundNo,
|
||
'reason' => mb_substr((string)$reason, 0, 80),
|
||
'amount' => [
|
||
'refund' => $refundCents,
|
||
'total' => $paidCents,
|
||
'currency' => 'CNY',
|
||
],
|
||
]);
|
||
} else {
|
||
$result = $pay->refund([
|
||
'out_trade_no' => (string)$order['order_no'],
|
||
'out_refund_no' => $refundNo,
|
||
'total_fee' => $paidCents,
|
||
'refund_fee' => $refundCents,
|
||
'refund_desc' => mb_substr((string)$reason, 0, 80),
|
||
]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::record('微信退款失败:订单 ' . $order['order_no'] . ',' . $e->getMessage(), 'error');
|
||
throw new \Exception('微信退款失败:' . $e->getMessage());
|
||
}
|
||
|
||
$res = $result instanceof \Yansongda\Supports\Collection ? $result->all() : (array)$result;
|
||
$status = isset($res['status']) ? (string)$res['status'] : '';
|
||
// V3 受理成功返回 PROCESSING / SUCCESS;V2 返回 result_code=SUCCESS
|
||
$ok = $status === 'SUCCESS' || $status === 'PROCESSING'
|
||
|| (isset($res['result_code']) && (string)$res['result_code'] === 'SUCCESS');
|
||
if (!$ok) {
|
||
$msg = isset($res['message']) ? (string)$res['message'] : (isset($res['err_code_des']) ? (string)$res['err_code_des'] : '未知错误');
|
||
Log::record('微信退款未受理:订单 ' . $order['order_no'] . ',' . json_encode($res, JSON_UNESCAPED_UNICODE), 'error');
|
||
throw new \Exception('微信退款未受理:' . $msg);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 退款后的权益回滚
|
||
*/
|
||
protected static function rollbackBenefits(array $order, $amount, $now)
|
||
{
|
||
$type = (string)$order['type'];
|
||
$userId = (int)$order['user_id'];
|
||
|
||
if ($type === 'recharge') {
|
||
// 扣回余额(前面已校验余额充足,这里用条件更新兜底防并发)
|
||
$affected = Db::name('user')
|
||
->where('id', $userId)
|
||
->where('money', '>=', $amount)
|
||
->setDec('money', $amount);
|
||
if (!$affected) {
|
||
Log::record('退款回滚余额失败(余额不足):订单 ' . $order['order_no'] . ',用户 ' . $userId, 'error');
|
||
} else {
|
||
$balance = round((float)Db::name('user')->where('id', $userId)->value('money'), 2);
|
||
self::addFlow('expense', '充值退款', $amount, $balance, (int)$order['id'], '充值订单退款扣回', $userId);
|
||
// 钱包流水(fa_user.money 减)→ 余额中心「余额明细」
|
||
self::addWalletFlow($userId, 'expense', '充值退款', $amount, $balance, (int)$order['id'], '充值订单退款扣回');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ($type === 'payview') {
|
||
Db::name('fc_pay_view')->where('order_id', (int)$order['id'])->delete();
|
||
return;
|
||
}
|
||
|
||
if ($type === 'member') {
|
||
self::revokeMembership($order, $now);
|
||
return;
|
||
}
|
||
|
||
// post:信息保留,由运营决定是否下架
|
||
}
|
||
|
||
/**
|
||
* 会员权益回滚:按该订单套餐的天数与赠送条数扣减(支持多次叠加购买)
|
||
*/
|
||
protected static function revokeMembership(array $order, $now)
|
||
{
|
||
$userId = (int)$order['user_id'];
|
||
$ext = Db::name('fc_user_ext')->where('user_id', $userId)->find();
|
||
if (!$ext) {
|
||
return;
|
||
}
|
||
|
||
$plan = null;
|
||
if (!empty($order['related_id'])) {
|
||
$plan = Db::name('fc_plan')->where('id', (int)$order['related_id'])->find();
|
||
}
|
||
$days = $plan ? max(0, (int)$plan['days']) : 0;
|
||
$quota = $plan && isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0;
|
||
|
||
$currentExpire = (int)$ext['member_expiretime'];
|
||
$expire = max($now, $currentExpire - $days * 86400);
|
||
$left = max(0, (int)$ext['post_quota_left'] - $quota);
|
||
|
||
$update = [
|
||
'post_quota_left' => $left,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($expire <= $now) {
|
||
$update['is_member'] = 0;
|
||
$update['member_level'] = '';
|
||
$update['member_expiretime'] = null;
|
||
$update['post_quota_left'] = 0;
|
||
} else {
|
||
$update['member_expiretime'] = $expire;
|
||
}
|
||
|
||
Db::name('fc_user_ext')->where('user_id', $userId)->update($update);
|
||
}
|
||
|
||
/**
|
||
* 冲销订单产生的分销佣金:已结算记录改为未结算(status=0),并通知分销员
|
||
*
|
||
* 已提现部分不回滚现金,但会体现在「可提现余额」中(可能为负,后续佣金先抵扣)。
|
||
*/
|
||
protected static function reverseCommission(array $order, $now)
|
||
{
|
||
$orderNo = (string)$order['order_no'];
|
||
if ($orderNo === '') {
|
||
return;
|
||
}
|
||
|
||
$rows = Db::name('fc_commission')
|
||
->where('order_no', $orderNo)
|
||
->where('status', 'in', [0, 1])
|
||
->select();
|
||
|
||
foreach ($rows as $row) {
|
||
// status=2 表示已冲销(不计入累计/待结算/可提现)
|
||
Db::name('fc_commission')->where('id', (int)$row['id'])->update([
|
||
'status' => 2,
|
||
'settletime' => null,
|
||
]);
|
||
|
||
Db::name('fc_message')->insert([
|
||
'user_id' => (int)$row['user_id'],
|
||
'type' => 'system',
|
||
'title' => '佣金已冲销',
|
||
'content' => '下线订单「' . $order['title'] . '」已退款,对应佣金 ' . $row['amount'] . ' 元已冲销。',
|
||
'isread' => 0,
|
||
'createtime' => $now,
|
||
]);
|
||
}
|
||
}
|
||
|
||
/** 资金流水 */
|
||
protected static function addFlow($type, $scene, $amount, $balance, $relatedId, $remark, $userId = 0)
|
||
{
|
||
Db::name('fc_finance_flow')->insert([
|
||
'user_id' => (int)$userId,
|
||
'flow_no' => 'F' . date('YmdHis') . bin2hex(random_bytes(3)),
|
||
'type' => $type,
|
||
'scene' => $scene,
|
||
'amount' => $amount,
|
||
'balance' => $balance,
|
||
'related_id' => (int)$relatedId,
|
||
'remark' => (string)$remark,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
|
||
/** 用户钱包流水(fa_user.money 增减)→ 余额中心「余额明细」 */
|
||
protected static function addWalletFlow($userId, $type, $scene, $amount, $balance, $relatedId, $remark)
|
||
{
|
||
Db::name('fc_wallet_flow')->insert([
|
||
'user_id' => (int)$userId,
|
||
'flow_no' => 'W' . date('YmdHis') . bin2hex(random_bytes(4)),
|
||
'type' => $type,
|
||
'scene' => $scene,
|
||
'amount' => $amount,
|
||
'balance' => $balance,
|
||
'related_id' => (int)$relatedId,
|
||
'remark' => (string)$remark,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
}
|