本文目录导读:

在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 [];
}
}
异步处理方案(生产推荐)
退款操作可能耗时较长(如网关调用慢),建议使用队列:
- 创建退款任务:用户点击退款后,控制器仅
INSERT一条记录到refund_requests(status = 0),然后将order_no、refund_no等推入 Redis 队列。 - Worker:独立进程从队列中消费,执行
RefundService::processRefund(),这样做的好处是:请求快速返回,用户体验好;如果退款失败,可以进行重试(设置队列重试次数)。 - 结果轮询:前端通过 AJAX 定时查询
refund_requests表中的status变化,或通过 WebSocket 推送退款结果。
安全与边界情况
- 重复请求防护:绝对不能依赖前端防重复,后端必须使用
refund_no的UNIQUE约束或 Redis 锁来保证幂等。 - 退款限制:
- 退款金额不得超过已付金额(含多次退款累计)。
- 已全额退款的订单不可再次退款。
- 某些商品(如虚拟卡券已核销)可能不支持退款,需要业务逻辑判断。
- 日志记录:记录完整的请求参数和网关响应,便于排查问题。
- 补偿机制:定时任务(Crontab)扫描
refund_requests中status = 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 中实现退款处理,关键不是代码量的多少,而是 事务安全、幂等性 和 异步处理 的设计,建议按照以下顺序落地:
- 设计好
orders和refund_requests表。 - 封装退款逻辑为独立服务类。
- 使用
SELECT ... FOR UPDATE防止并发修改。 - 对接支付网关 SDK。
- 迁移到队列异步处理(重要)。
- 补充定时任务用于故障恢复。
如果你有具体的支付场景(如支付宝或微信),我可以提供该通道的详细对接代码示例。