🧾 支付业务对接
业务模块通过 System 支付基础能力统一创建支付单、调起支付、接收已支付/已退款回调。本页讲业务侧对接代码。基础能力组成、驱动契约、回调分发详见 支付基础能力。
🎯 本页目标
读完本章节后,应能掌握:
- 业务模块如何创建支付单、把订单交给支付基础能力。
- 前端在小程序、H5、App 三端如何统一调起支付。
- 业务处理器怎么实现、怎么注册、为什么必须幂等。
- 退款单怎么创建、退款回调怎么接收。
- 主动查单的适用场景和限制。
▶️ 发起支付
业务模块创建支付单时要明确业务归属。通常不需要传入 driver,支付基础能力会读取后台“应用接入 / 支付接入”中选择的默认支付驱动:
use App\Core\Attributes\Inject;
use Modules\System\Application\Services\Pay\PayOrderService;
class CourseOrderPayAction
{
#[Inject]
protected PayOrderService $payOrderService;
public function pay(object $courseOrder, object $user): array
{
return $this->payOrderService->createAndPay([
'module' => 'Course',
'scene' => 'course_order',
'biz_type' => 'course',
'biz_no' => $courseOrder->order_no,
'title' => $courseOrder->title,
'user_id' => $user->id,
'channel' => 'mini',
'amount' => $courseOrder->amount,
'pay_amount' => $courseOrder->pay_amount,
]);
}
}上面的示例没有传入 driver,支付基础能力会自动使用后台配置的默认支付驱动。只有少数需要覆盖默认通道的场景才建议显式传入 driver,例如某个模块固定使用独立商户或特定支付渠道。
📱 前端调起支付
uni-app 端提供统一支付工具,业务页面只需要把后端返回的支付参数交给工具处理:
import { getPayChannel, requestPayment } from '@/utils/payment'
const result = await payOrder({
id: orderId,
channel: getPayChannel()
})
await requestPayment(result)getPayChannel() 根据当前端类型返回 mini、mp、h5 或 app;requestPayment() 处理小程序 uni.requestPayment 和 H5 支付链接。业务页面不需要重复判断端类型。
🧑🔧 业务处理器
业务模块通过 PayBusinessHandlerInterface 接收已支付和已退款事件:
namespace Modules\Course\Application\Services\Pay;
use App\Core\Attributes\Inject;
use Illuminate\Support\Facades\DB;
use Modules\Course\Dao\Order\CourseOrderDao;
use Modules\System\Application\Services\Pay\Contracts\PayBusinessHandlerInterface;
use Modules\System\Domain\Models\Pay\PayOrder;
class CourseOrderPayHandler implements PayBusinessHandlerInterface
{
#[Inject]
protected CourseOrderDao $courseOrderDao;
public function supports(PayOrder $order): bool
{
return $order->module === 'Course'
&& $order->scene === 'course_order'
&& $order->biz_type === 'course';
}
public function handlePaid(PayOrder $order, array $payload): void
{
DB::transaction(function () use ($order) {
$courseOrder = $this->courseOrderDao->query()
->where('order_no', $order->biz_no)
->lockForUpdate()
->firstOrFail();
if ((int) $courseOrder->pay_status === 1) {
return;
}
$courseOrder->pay_status = 1;
$courseOrder->pay_time = now();
$courseOrder->pay_order_no = $order->order_no;
$courseOrder->channel_order_no = $order->channel_order_no;
$courseOrder->save();
});
}
public function handleRefunded(PayOrder $order, array $payload): void
{
DB::transaction(function () use ($order, $payload) {
$courseOrder = $this->courseOrderDao->query()
->where('order_no', $order->biz_no)
->lockForUpdate()
->firstOrFail();
$courseOrder->refund_callback = $payload['resource'] ?? [];
$courseOrder->save();
});
}
}模块服务提供者负责注册业务处理器:
public function register(): void
{
$handlers = config('sunadmin.pay.biz_handlers', []);
$handlers[] = CourseOrderPayHandler::class;
config()->set('sunadmin.pay.biz_handlers', array_values(array_unique($handlers)));
}处理器必须保证幂等。渠道可能重复推送支付或退款回调,业务处理器应通过业务状态、唯一流水或行锁避免重复发放权益。
💸 退款中心
退款基础能力在 System 中提供,后台入口是:
财务管理 / 退款中心退款中心面向所有模块共用。业务模块只需要创建退款单,退款中心会根据支付单上的 driver 调用对应支付模块提交退款。退款成功后,回调仍按 PayBusinessHandlerInterface::handleRefunded() 分发给业务模块。
退款支持部分退款和多次退款。业务模块在创建退款前应计算订单剩余可退款金额,并把退款金额写入统一退款单。
🔎 主动查单
真实支付回调是支付状态流转的主入口。前端在拉起支付后,可以调用业务模块提供的查单接口,由后端使用 PayOrderService::syncPaidFromDriver() 主动向支付驱动确认支付状态。
主动查单用于处理前端页面返回、H5 支付跳转后用户回到页面、或渠道回调延迟等场景。查单确认支付成功后,会按与支付回调一致的统一支付单状态写入,并记录来源为驱动查单的回调日志。
主动查单不能替代渠道支付回调。支付回调必须保持可访问、可验签、可写入 pay_callback_logs,否则退款、对账和重复通知排查都会缺少渠道原始依据。
🧯 排查顺序
| 问题 | 排查 |
|---|---|
| 提示支付驱动未安装 | 检查对应支付模块是否安装、system_modules.status 是否为 1 |
| 前端无法拉起支付 | 看 pay_payload_json 是否为空、driver 和 channel 是否正确 |
| 渠道没有回调 | 检查回调 URL、证书、商户配置、服务器访问 |
| 回调验签失败 | 检查支付模块配置、平台证书、密钥 |
| 业务状态没更新 | 检查 pay_orders.status、回调日志、业务 handler 是否 supports() |
| 退款状态没更新 | 检查 pay_refunds.status、退款回调日志、业务 handler 是否实现 handleRefunded() |
| 重复回调 | 确认 handler 幂等,不要重复发放权益 |
