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(), ]); } }