怎样在PHP项目中实现用户退款处理?

wen java案例 2

本文目录导读:

怎样在PHP项目中实现用户退款处理?

  1. 核心架构原则
  2. 数据库表设计
  3. PHP 核心退款逻辑
  4. 对接支付网关示例(接口契约)
  5. 异步处理方案(生产推荐)
  6. 安全与边界情况
  7. 前端交互建议

在PHP项目中实现用户退款处理,通常涉及以下几个关键步骤和最佳实践,由于退款涉及资金安全,必须严格保证数据一致性和操作可追溯性

以下是一个完整的实现方案,包含数据库设计、核心逻辑和支付网关对接示例(以支付宝/微信支付为例)。


核心架构原则

  • 事务性:数据库操作必须使用事务。
  • 幂等性:相同的退款请求(使用相同的 refund_no)只能被执行一次。
  • 最终一致性:如果支付网关返回成功,但数据库写入失败,需要有补偿机制(如定时任务轮询)。
  • 异步处理:退款通常不是一个立即完成的操作,建议使用队列(如 Redis + 队列 Worker)。

数据库表设计

你需要至少两张核心表:orders(订单表)和 refund_requests(退款申请表)。

-- 订单表(简化)
CREATE TABLE `orders` (
    `id` INT PRIMARY KEY AUTO_INCREMENT,
    `order_no` VARCHAR(64) NOT NULL UNIQUE COMMENT '商户订单号',
    `user_id` INT NOT NULL,
    `total_fee` DECIMAL(10, 2) NOT NULL COMMENT '订单总金额',
    `paid_fee` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '已支付金额',
    `refunded_fee` DECIMAL(10, 2) DEFAULT 0.00 COMMENT '已退款金额',
    `status` TINYINT NOT NULL COMMENT '1:待支付, 2:已支付, 3:已退款(全部), 4:部分退款',
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 退款申请表
CREATE TABLE `refund_requests` (
    `id` INT PRIMARY KEY AUTO_INCREMENT,
    `refund_no` VARCHAR(64) NOT NULL UNIQUE COMMENT '商户退款单号(用于幂等)',
    `order_no` VARCHAR(64) NOT NULL COMMENT '关联订单号',
    `user_id` INT NOT NULL COMMENT '发起退款的用户',
    `refund_amount` DECIMAL(10, 2) NOT NULL COMMENT '本次退款金额',
    `reason` VARCHAR(255) DEFAULT '' COMMENT '退款原因',
    `gateway_refund_id` VARCHAR(128) DEFAULT '' COMMENT '支付网关返回的退款流水号',
    `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0:待处理, 1:退款成功, 2:退款失败',
    `gateway_response` TEXT DEFAULT NULL COMMENT '第三方返回的原始响应(JSON)',
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

PHP 核心退款逻辑

这是一个最简化的、同步模式的退款函数。生产环境强烈建议使用队列异步化

<?php
class RefundService
{
    private $db; // PDO 连接
    private $paymentGateway; // 支付宝/微信支付SDK
    public function __construct(\PDO $db, PaymentGatewayInterface $paymentGateway)
    {
        $this->db = $db;
        $this->paymentGateway = $paymentGateway;
    }
    /**
     * 发起退款
     * @param int    $userId
     * @param string $orderNo
     * @param float  $amount  退款金额
     * @param string $reason
     * @return array ['success' => bool, 'message' => string]
     * @throws \Exception
     */
    public function processRefund(int $userId, string $orderNo, float $amount, string $reason = ''): array
    {
        // 1. 生成唯一的退款单号(用于幂等)
        $refundNo = $this->generateRefundNo($orderNo);
        try {
            // 开启数据库事务
            $this->db->beginTransaction();
            // 2. 锁定订单行(悲观锁)
            $order = $this->getOrderForUpdate($orderNo);
            if (!$order) {
                throw new \Exception('订单不存在');
            }
            if ($order['user_id'] !== $userId) {
                throw new \Exception('无权操作此订单');
            }
            // 检查支付状态
            if ($order['status'] != 2 && $order['status'] != 4) {
                throw new \Exception('订单状态不允许退款');
            }
            // 检查可退金额
            $availableRefund = $order['paid_fee'] - $order['refunded_fee'];
            if ($amount > $availableRefund) {
                throw new \Exception('退款金额超过可退余额');
            }
            // 3. 创建退款申请记录(状态为待处理)
            $insertSql = "INSERT INTO refund_requests (refund_no, order_no, user_id, refund_amount, reason, status) 
                          VALUES (:refund_no, :order_no, :user_id, :amount, :reason, 0)";
            $stmt = $this->db->prepare($insertSql);
            $stmt->execute([
                ':refund_no' => $refundNo,
                ':order_no'  => $orderNo,
                ':user_id'   => $userId,
                ':amount'    => $amount,
                ':reason'    => $reason,
            ]);
            $refundRequestId = $this->db->lastInsertId();
            // 4. 调用第三方支付网关退款
            //    注意:这里假设 PaymentGateway 接口的 refund 方法包含幂等逻辑
            $gatewayResult = $this->paymentGateway->refund([
                'out_trade_no'   => $orderNo,       // 原支付订单号
                'out_refund_no'  => $refundNo,       // 商户退款单号
                'refund_amount'  => $amount,
                'refund_reason'  => $reason,
            ]);
            // 5. 根据网关返回更新状态
            if ($gatewayResult['success']) {
                // 更新订单表的已退款金额和状态
                $newRefundedFee = $order['refunded_fee'] + $amount;
                $newOrderStatus = ($newRefundedFee >= $order['paid_fee']) ? 3 : 4; // 3=全部退款, 4=部分退款
                $updateOrderSql = "UPDATE orders SET refunded_fee = :refunded_fee, status = :status WHERE id = :id";
                $this->db->prepare($updateOrderSql)->execute([
                    ':refunded_fee' => $newRefundedFee,
                    ':status'       => $newOrderStatus,
                    ':id'           => $order['id'],
                ]);
                // 更新退款申请记录状态为成功
                $updateRefundSql = "UPDATE refund_requests SET status = 1, gateway_refund_id = :gateway_id, gateway_response = :response WHERE id = :id";
                $this->db->prepare($updateRefundSql)->execute([
                    ':gateway_id' => $gatewayResult['gateway_refund_id'],
                    ':response'   => json_encode($gatewayResult['raw_response']),
                    ':id'         => $refundRequestId,
                ]);
                $this->db->commit();
                // 可在此发送退款成功通知(邮件、短信、站内信等)
                return ['success' => true, 'message' => '退款成功'];
            } else {
                // 网关返回失败(非网络原因)
                $updateRefundSql = "UPDATE refund_requests SET status = 2, gateway_response = :response WHERE id = :id";
                $this->db->prepare($updateRefundSql)->execute([
                    ':response' => json_encode($gatewayResult['error']),
                    ':id'       => $refundRequestId,
                ]);
                $this->db->commit();
                return ['success' => false, 'message' => '退款请求失败: ' . $gatewayResult['error']];
            }
        } catch (\Exception $e) {
            // 发生异常,回滚所有数据库操作
            if ($this->db->inTransaction()) {
                $this->db->rollBack();
            }
            // 记录日志
            // Logger::error('退款处理异常', ['order_no' => $orderNo, 'error' => $e->getMessage()]);
            return ['success' => false, 'message' => '系统错误: ' . $e->getMessage()];
        }
    }
    /**
     * 获取订单并加锁 (SELECT ... FOR UPDATE)
     */
    private function getOrderForUpdate(string $orderNo): ?array
    {
        $sql = "SELECT * FROM orders WHERE order_no = :order_no FOR UPDATE";
        $stmt = $this->db->prepare($sql);
        $stmt->execute([':order_no' => $orderNo]);
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
    }
    /**
     * 生成唯一退款单号
     */
    private function generateRefundNo(string $orderNo): string
    {
        return 'RF' . substr($orderNo, 2) . date('YmdHis') . rand(1000, 9999);
    }
}

对接支付网关示例(接口契约)

你需要一个 PaymentGatewayInterface 接口来解耦具体的支付通道,这里给出 Mock 实现:

<?php
interface PaymentGatewayInterface
{
    /**
     * 发起退款
     * @param array $params
     * @return array ['success' => bool, 'gateway_refund_id' => string, 'error' => string, 'raw_response' => mixed]
     */
    public function refund(array $params): array;
}
// 支付宝示例(SDK 版本)
class AlipayRefund implements PaymentGatewayInterface
{
    public function refund(array $params): array
    {
        try {
            $request = new \AlipayTradeRefundRequest();
            $request->setBizContent(json_encode([
                'out_trade_no'   => $params['out_trade_no'],
                'refund_amount'  => $params['refund_amount'],
                'out_request_no' => $params['out_refund_no'], // 关键:用于幂等
            ]));
            $response = $aop->execute($request);
            $result = $response->getBody(); // 解析结果
            if ($response->isSuccess()) {
                return [
                    'success'          => true,
                    'gateway_refund_id' => $result['trade_no'] ?? '', // 支付宝交易号
                    'raw_response'     => $result,
                    'error'            => '',
                ];
            } else {
                return [
                    'success'          => false,
                    'gateway_refund_id' => '',
                    'raw_response'     => $response->getBody(),
                    'error'            => $result['sub_msg'] ?? '支付宝退款失败',
                ];
            }
        } catch (\Exception $e) {
            return [
                'success'          => false,
                'gateway_refund_id' => '',
                'raw_response'     => [],
                'error'            => '网络异常: ' . $e->getMessage(),
            ];
        }
    }
}
// 微信支付示例(SDK 版本)
class WechatRefund implements PaymentGatewayInterface
{
    public function refund(array $params): array
    {
        // 微信退款需要商户证书
        $wxConfig = [
            'cert_path' => '/path/to/cert.pem',
            'key_path' => '/path/to/key.pem',
        ];
        $wechatPay = new \WeChatPay\WePay($wxConfig);
        $result = $wechatPay->refund->byOutTradeNumber(
            $params['out_trade_no'],
            $params['out_refund_no'],
            $params['refund_amount'],
            $params['refund_total_fee'] ?? $params['refund_amount'] // 微信要求传入原订单总金额
        );
        // ... 解析响应
        return [];
    }
}

异步处理方案(生产推荐)

退款操作可能耗时较长(如网关调用慢),建议使用队列:

  1. 创建退款任务:用户点击退款后,控制器仅 INSERT 一条记录到 refund_requestsstatus = 0),然后将 order_norefund_no 等推入 Redis 队列。
  2. Worker:独立进程从队列中消费,执行 RefundService::processRefund(),这样做的好处是:请求快速返回,用户体验好;如果退款失败,可以进行重试(设置队列重试次数)。
  3. 结果轮询:前端通过 AJAX 定时查询 refund_requests 表中的 status 变化,或通过 WebSocket 推送退款结果。

安全与边界情况

  • 重复请求防护绝对不能依赖前端防重复,后端必须使用 refund_noUNIQUE 约束或 Redis 锁来保证幂等。
  • 退款限制
    • 退款金额不得超过已付金额(含多次退款累计)。
    • 已全额退款的订单不可再次退款。
    • 某些商品(如虚拟卡券已核销)可能不支持退款,需要业务逻辑判断。
  • 日志记录:记录完整的请求参数和网关响应,便于排查问题。
  • 补偿机制:定时任务(Crontab)扫描 refund_requestsstatus = 0 且创建时间超过 30 分钟仍未处理的记录,主动查询网关状态或触发人工处理。
  • 权限校验:确保 user_id 与订单的归属一致($order['user_id'] === $userId)。

前端交互建议

// 发起退款请求
async function requestRefund(orderNo, amount, reason) {
    // 1. 发起请求(放入队列)
    const response = await fetch('/api/refund/apply', {
        method: 'POST',
        body: JSON.stringify({ order_no: orderNo, amount, reason }),
    });
    const { success, message, refund_no } = await response.json();
    if (success) {
        // 2. 启动轮询,查询退款最终状态(每秒查询一次,最多查询30秒)
        const checkInterval = setInterval(async () => {
            const statusResp = await fetch(`/api/refund/status?refund_no=${refund_no}`);
            const { status, response } = await statusResp.json();
            if (status === 1) {
                clearInterval(checkInterval);
                alert('退款成功');
                window.location.reload(); // 刷新页面
            } else if (status === 2) {
                clearInterval(checkInterval);
                alert('退款失败,请联系客服');
            }
        }, 1000);
    } else {
        alert('退款申请失败: ' + message);
    }
}

在 PHP 中实现退款处理,关键不是代码量的多少,而是 事务安全幂等性异步处理 的设计,建议按照以下顺序落地:

  1. 设计好 ordersrefund_requests 表。
  2. 封装退款逻辑为独立服务类。
  3. 使用 SELECT ... FOR UPDATE 防止并发修改。
  4. 对接支付网关 SDK。
  5. 迁移到队列异步处理(重要)。
  6. 补充定时任务用于故障恢复。

如果你有具体的支付场景(如支付宝或微信),我可以提供该通道的详细对接代码示例。

抱歉,评论功能暂时关闭!