1. 维护代码健壮

2. 更新项目结构文档
This commit is contained in:
技术老胡
2026-04-27 16:20:41 +08:00
parent 9a16a88640
commit 0e5de50337
198 changed files with 21038 additions and 702 deletions

View File

@@ -10,6 +10,7 @@ use app\common\constant\TradeConstant;
use app\exception\BusinessStateException;
use app\exception\ConflictException;
use app\exception\ValidationException;
use app\model\merchant\Merchant;
use app\model\payment\BizOrder;
use app\model\payment\PayOrder;
use app\repository\payment\config\PaymentTypeRepository;
@@ -30,6 +31,7 @@ use app\service\payment\runtime\PaymentRouteService;
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
* @property PayOrderRepository $payOrderRepository 支付单仓库
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
* @property PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器
* @property PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务
*/
class PayOrderAttemptService extends BaseService
@@ -43,6 +45,7 @@ class PayOrderAttemptService extends BaseService
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
* @param PayOrderRepository $payOrderRepository 支付订单仓库
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
* @param PaymentOrderInputAssembler $orderInputAssembler 支付入参组装器
* @param PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务
*/
public function __construct(
@@ -52,6 +55,7 @@ class PayOrderAttemptService extends BaseService
protected BizOrderRepository $bizOrderRepository,
protected PayOrderRepository $payOrderRepository,
protected PaymentTypeRepository $paymentTypeRepository,
protected PaymentOrderInputAssembler $orderInputAssembler,
protected PayOrderChannelDispatchService $payOrderChannelDispatchService
) {
}
@@ -78,13 +82,7 @@ class PayOrderAttemptService extends BaseService
throw new ValidationException('支付入参不完整');
}
// 先校验商户和支付方式是否可用,避免进入事务后才发现前置条件不满足。
$merchant = $this->merchantService->ensureMerchantEnabled($merchantId);
$merchantGroupId = (int) $merchant->group_id;
if ($merchantGroupId <= 0) {
throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]);
}
$this->merchantService->ensureMerchantGroupEnabled($merchantGroupId);
[$merchant, $merchantGroupId] = $this->resolveMerchantContext($merchantId);
/** @var PaymentType|null $paymentType */
$paymentType = $this->paymentTypeRepository->find($payTypeId);
@@ -92,11 +90,12 @@ class PayOrderAttemptService extends BaseService
throw new BusinessStateException('支付方式不支持', ['pay_type_id' => $payTypeId]);
}
// 根据商户分组、支付金额和请求参数选择可用通道
// 已选支付方式的直连支付才会进入正式选路
$route = $this->paymentRouteService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $input);
$selected = $route['selected_channel'];
/** @var PaymentChannel $channel */
$channel = $selected['channel'];
$bizFields = $this->buildBizOrderFields($input);
$payNo = $this->generateNo('PAY');
$channelRequestNo = $this->generateNo('REQ');
@@ -112,7 +111,8 @@ class PayOrderAttemptService extends BaseService
$route,
$channel,
$payNo,
$channelRequestNo
$channelRequestNo,
$bizFields
) {
// 在事务中完成业务单和支付单的原子创建,保证幂等与状态一致。
$existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo);
@@ -148,12 +148,19 @@ class PayOrderAttemptService extends BaseService
}
}
// 业务单一旦生成,订单展示字段与回调地址就不能在后续支付尝试里漂移。
$this->assertBizOrderConsistency($existingBizOrder, $bizFields);
$bizOrder = $existingBizOrder;
$bizTraceNo = trim((string) ($bizOrder->trace_no ?? ''));
$dirty = false;
if ($bizTraceNo === '') {
// 旧单如果没有 trace_no就补成业务单号方便后续串起来查。
$bizTraceNo = (string) $bizOrder->biz_no;
$bizOrder->trace_no = $bizTraceNo;
$dirty = true;
}
if ($dirty) {
$bizOrder->save();
}
$attemptNo = (int) $bizOrder->attempt_count + 1;
} else {
@@ -161,22 +168,36 @@ class PayOrderAttemptService extends BaseService
'biz_no' => $this->generateNo('BIZ'),
'trace_no' => $this->generateNo('TRC'),
'merchant_id' => $merchantId,
'merchant_group_id' => $merchantGroupId,
'poll_group_id' => (int) $route['poll_group']->id,
'merchant_order_no' => $merchantOrderNo,
'subject' => (string) ($input['subject'] ?? ''),
'body' => (string) ($input['body'] ?? ''),
'subject' => $bizFields['subject'],
'body' => $bizFields['body'],
'notify_url' => $bizFields['notify_url'],
'return_url' => $bizFields['return_url'],
'client_ip' => $bizFields['client_ip'],
'device' => $bizFields['device'],
'order_amount' => $payAmount,
'paid_amount' => 0,
'refund_amount' => 0,
'status' => TradeConstant::ORDER_STATUS_CREATED,
'attempt_count' => 0,
'ext_json' => $input['ext_json'] ?? [],
'ext_json' => $bizFields['ext_json'],
]);
$bizTraceNo = (string) $bizOrder->trace_no;
$attemptNo = 1;
}
// 支付单快照要以“当前请求 + 已确认业务单”为准,避免复用旧业务单时把上下文写空。
$payOrderSeedExtJson = array_replace_recursive(
(array) ($bizOrder->ext_json ?? []),
(array) ($input['ext_json'] ?? [])
);
$payOrderFields = $this->orderInputAssembler->buildOrderFields(
$input,
null,
$bizOrder,
$payOrderSeedExtJson
);
$feeRateBp = (int) $channel->cost_rate_bp;
$splitRateBp = (int) $channel->split_rate_bp ?: 10000;
// 手续费和分账费率都按快照落库,后续配置变化不会影响这笔单的口径。
@@ -200,6 +221,7 @@ class PayOrderAttemptService extends BaseService
}
$payOrder = $this->payOrderRepository->create([
// 路由与通道快照只落在支付单里,业务单保持纯业务事实。
'pay_no' => $payNo,
'biz_no' => (string) $bizOrder->biz_no,
'trace_no' => $bizTraceNo,
@@ -213,6 +235,10 @@ class PayOrderAttemptService extends BaseService
'channel_type' => (int) $channel->channel_mode,
'channel_mode' => (int) $channel->channel_mode,
'pay_amount' => $payAmount,
'notify_url' => (string) $payOrderFields['notify_url'],
'return_url' => (string) $payOrderFields['return_url'],
'client_ip' => (string) $payOrderFields['client_ip'],
'device' => (string) $payOrderFields['device'],
'fee_rate_bp_snapshot' => $feeRateBp,
'split_rate_bp_snapshot' => $splitRateBp,
'fee_estimated_amount' => $feeEstimated,
@@ -224,21 +250,12 @@ class PayOrderAttemptService extends BaseService
'request_at' => $this->now(),
'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING,
'callback_times' => 0,
'ext_json' => array_merge($input['ext_json'] ?? [], [
'merchant_no' => (string) $merchant->merchant_no,
'merchant_group_id' => $merchantGroupId,
'poll_group_id' => (int) $route['poll_group']->id,
'channel_id' => (int) $channel->id,
'channel_mode' => (int) $channel->channel_mode,
'trace_no' => $bizTraceNo,
]),
'ext_json' => (array) $payOrderFields['ext_json'],
]);
$bizOrder->active_pay_no = (string) $payOrder->pay_no;
$bizOrder->attempt_count = (int) $attemptNo;
$bizOrder->status = TradeConstant::ORDER_STATUS_PAYING;
$bizOrder->merchant_group_id = $merchantGroupId;
$bizOrder->poll_group_id = (int) $route['poll_group']->id;
if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') {
// 把追踪号回写到业务单上,后续查单和对账能串到同一条链路。
$bizOrder->trace_no = $bizTraceNo;
@@ -261,7 +278,9 @@ class PayOrderAttemptService extends BaseService
$channel = $prepared['route']['selected_channel']['channel'];
// 支付单落库后立即拉起渠道订单,补全渠道返回的单号和参数快照。
$channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel);
/** @var \app\model\merchant\Merchant $merchant */
$merchant = $prepared['merchant'];
$channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel, $merchant);
$prepared['pay_order'] = $channelDispatchResult['pay_order'];
$prepared['payment_result'] = $channelDispatchResult['payment_result'];
@@ -270,6 +289,215 @@ class PayOrderAttemptService extends BaseService
return $prepared;
}
/**
* 预创建收银台业务单。
*
* 该方法只负责业务单创建或复用,不创建支付单,供 `type` 为空的收银台入口使用。
*
* @param array $input 收银台参数
* @return array 发起结果
* @throws ValidationException
* @throws BusinessStateException
* @throws ConflictException
*/
public function prepareCashierBizOrder(array $input): array
{
$merchantId = (int) ($input['merchant_id'] ?? 0);
$merchantOrderNo = trim((string) ($input['merchant_order_no'] ?? ''));
$payAmount = (int) ($input['pay_amount'] ?? 0);
if ($merchantId <= 0 || $merchantOrderNo === '' || $payAmount <= 0) {
throw new ValidationException('支付入参不完整');
}
[$merchant, $merchantGroupId] = $this->resolveMerchantContext($merchantId);
$bizFields = $this->buildBizOrderFields($input);
$prepared = $this->transactionRetry(function () use (
$merchant,
$merchantId,
$merchantGroupId,
$merchantOrderNo,
$payAmount,
$bizFields
) {
// 收银台预创建只关心业务单,不创建支付单,也不提前选通道。
$existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo);
if ($existingBizOrder) {
if ((int) $existingBizOrder->order_amount !== $payAmount) {
throw new ValidationException('同一商户订单号金额不一致', [
'merchant_id' => $merchantId,
'merchant_order_no' => $merchantOrderNo,
]);
}
if (in_array((int) $existingBizOrder->status, [
TradeConstant::ORDER_STATUS_SUCCESS,
TradeConstant::ORDER_STATUS_CLOSED,
TradeConstant::ORDER_STATUS_TIMEOUT,
], true)) {
throw new BusinessStateException('支付单状态不允许重复创建', [
'biz_no' => (string) $existingBizOrder->biz_no,
'status' => (int) $existingBizOrder->status,
]);
}
// 收银台预创建重复请求时,必须沿用首单快照,不能把订单文案或回调地址改掉。
$this->assertBizOrderConsistency($existingBizOrder, $bizFields);
$bizOrder = $existingBizOrder;
$dirty = false;
if ((string) ($bizOrder->trace_no ?? '') === '') {
// 老业务单如果没有追踪号,补成业务单号,方便后续串联查询。
$bizOrder->trace_no = (string) $bizOrder->biz_no;
$dirty = true;
}
if ($dirty) {
$bizOrder->save();
}
} else {
// 新收银台单直接作为业务锚点,支付单留到确认时再创建。
$bizOrder = $this->bizOrderRepository->create([
'biz_no' => $this->generateNo('BIZ'),
'trace_no' => $this->generateNo('TRC'),
'merchant_id' => $merchantId,
'merchant_order_no' => $merchantOrderNo,
'subject' => $bizFields['subject'],
'body' => $bizFields['body'],
'notify_url' => $bizFields['notify_url'],
'return_url' => $bizFields['return_url'],
'client_ip' => $bizFields['client_ip'],
'device' => $bizFields['device'],
'order_amount' => $payAmount,
'paid_amount' => 0,
'refund_amount' => 0,
'status' => TradeConstant::ORDER_STATUS_CREATED,
'active_pay_no' => '',
'attempt_count' => 0,
'ext_json' => $bizFields['ext_json'],
]);
}
return [
'merchant' => $merchant,
'biz_order' => $bizOrder->refresh(),
];
});
/** @var BizOrder $bizOrder */
$bizOrder = $prepared['biz_order'];
return [
'merchant' => $prepared['merchant'],
'biz_order' => $bizOrder,
'cashier_url' => $this->buildCashierPageUrl((string) $bizOrder->biz_no),
];
}
/**
* 解析商户和商户分组。
*
* @param int $merchantId 商户ID
* @return array{0: Merchant, 1: int} 商户和商户分组ID
* @throws ValidationException
*/
private function resolveMerchantContext(int $merchantId): array
{
$merchant = $this->merchantService->ensureMerchantPayEnabled($merchantId);
$merchantGroupId = (int) $merchant->group_id;
if ($merchantGroupId <= 0) {
throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]);
}
$this->merchantService->ensureMerchantGroupEnabled($merchantGroupId);
return [$merchant, $merchantGroupId];
}
/**
* 归一化业务单字段。
*
* @param array $input 统一入参
* @return array<string, mixed> 业务单字段
*/
private function buildBizOrderFields(array $input): array
{
// 业务单只保存商户业务上下文;支付载体上下文留给 PayOrder避免同一业务单多次尝试时互相污染。
$fields = $this->orderInputAssembler->buildOrderFields(
$input,
null,
null,
(array) ($input['ext_json'] ?? [])
);
unset($fields['ext_json']['payment'], $fields['ext_json']['presentation'], $fields['ext_json']['plugin']);
return $fields;
}
/**
* 校验业务单关键字段是否与首次写入保持一致。
*
* @param BizOrder $bizOrder 业务单
* @param array<string, mixed> $fields 当前请求整理后的字段
* @return void
* @throws ConflictException
*/
private function assertBizOrderConsistency(BizOrder $bizOrder, array $fields): void
{
foreach (['subject', 'body', 'notify_url', 'return_url', 'client_ip', 'device'] as $field) {
$current = trim((string) ($bizOrder->{$field} ?? ''));
$incoming = trim((string) ($fields[$field] ?? ''));
if ($current !== '' && $incoming !== '' && $current !== $incoming) {
throw new ConflictException('商户订单信息不一致', [
'biz_no' => (string) $bizOrder->biz_no,
'field' => $field,
]);
}
}
$currentExtJson = $this->stableBizExtJson((array) ($bizOrder->ext_json ?? []));
$incomingExtJson = $this->stableBizExtJson((array) ($fields['ext_json'] ?? []));
if (!empty($currentExtJson) && !empty($incomingExtJson) && $currentExtJson != $incomingExtJson) {
throw new ConflictException('商户订单扩展信息不一致', [
'biz_no' => (string) $bizOrder->biz_no,
]);
}
}
/**
* 只比较业务单真正稳定的扩展字段。
*
* `payment`、`presentation`、`plugin` 都属于支付尝试快照,不参与业务单幂等比较。
*
* @param array<string, mixed> $extJson 扩展字段
* @return array<string, mixed>
*/
private function stableBizExtJson(array $extJson): array
{
$stable = [];
foreach (['_protocol_version'] as $key) {
if (array_key_exists($key, $extJson)) {
$stable[$key] = $extJson[$key];
}
}
if (isset($extJson['merchant']) && is_array($extJson['merchant'])) {
$stable['merchant'] = $extJson['merchant'];
}
return $stable;
}
/**
* 构建收银台跳转地址。
*
* @param string $bizNo 业务单号
* @return string 收银台 URL
*/
private function buildCashierPageUrl(string $bizNo): string
{
return (string) sys_config('site_url') . '/cashier/' . rawurlencode($bizNo);
}
/**
* 计算手续费金额。
*

View File

@@ -4,8 +4,10 @@ namespace app\service\payment\order;
use app\common\base\BaseService;
use app\common\constant\NotifyConstant;
use app\common\constant\PaymentPluginStatusConstant;
use app\exception\PaymentException;
use app\exception\ResourceNotFoundException;
use app\exception\ValidationException;
use app\model\payment\PayOrder;
use app\repository\payment\trade\PayOrderRepository;
use app\service\payment\runtime\NotifyService;
@@ -46,13 +48,13 @@ class PayOrderCallbackService extends BaseService
*
* @param array $input 回调载荷
* @return PayOrder 支付订单模型
* @throws \InvalidArgumentException
* @throws ValidationException
*/
public function handleChannelCallback(array $input): PayOrder
{
$payNo = trim((string) ($input['pay_no'] ?? ''));
if ($payNo === '') {
throw new \InvalidArgumentException('pay_no 不能为空');
throw new ValidationException('pay_no 不能为空', ['pay_no' => $payNo]);
}
// 先落回调日志,后续无论成功还是失败,都可以从统一表里排查。
@@ -96,46 +98,48 @@ class PayOrderCallbackService extends BaseService
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
try {
// 插件自行解析请求并返回统一结构,控制器层不直接判断渠道格式
$result = $plugin->notify($request);
$status = (string) ($result['status'] ?? '');
// 老插件可能只返回 success / paid / failed 这类状态字符串,这里统一折算成布尔结果。
$success = array_key_exists('success', $result)
? (bool) $result['success']
: in_array($status, ['success', 'paid'], true);
// 插件必须直接返回标准结构,系统层只负责校验,不再兼容旧字段别名
$result = $this->validatePluginNotifyResult($plugin->notify($request));
$status = (string) $result['status'];
// 将插件返回值归一化为生命周期服务可消费的回调载荷。
/** @var array<string, mixed> $callbackPayload */
$callbackPayload = [
'pay_no' => $payNo,
'success' => $success,
'success' => $status === PaymentPluginStatusConstant::SUCCESS,
'channel_id' => (int) $payOrder->channel_id,
'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC,
'request_data' => array_merge($request->get(), $request->post()),
'request_data' => $request->all(),
'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS,
'process_status' => $success ? NotifyConstant::PROCESS_STATUS_SUCCESS : NotifyConstant::PROCESS_STATUS_FAILED,
'process_status' => $this->resolveProcessStatus($status),
'process_result' => $result,
'channel_trade_no' => (string) ($result['chan_trade_no'] ?? ''),
'channel_order_no' => (string) ($result['chan_order_no'] ?? ''),
'channel_trade_no' => (string) ($result['channel_trade_no'] ?? ''),
'channel_order_no' => (string) ($result['channel_order_no'] ?? ''),
'paid_at' => $result['paid_at'] ?? null,
'failed_at' => $result['failed_at'] ?? null,
'channel_error_code' => (string) ($result['channel_error_code'] ?? ''),
'channel_error_msg' => (string) ($result['channel_error_msg'] ?? ''),
'ext_json' => [
'plugin_code' => (string) $payOrder->plugin_code,
'notify_status' => $status,
],
// 回调原文和插件解析结果只进入 ma_pay_callback_log
// 支付单本身只更新状态、渠道单号和错误字段,避免 ext_json 变成通知历史桶。
'ext_json' => [],
];
// 部分渠道会返回实际手续费,补充进回调载荷,便于后续清算和对账。
if (isset($result['fee_actual_amount'])) {
if ($result['fee_actual_amount'] !== null) {
$callbackPayload['fee_actual_amount'] = (int) $result['fee_actual_amount'];
}
if ($status === PaymentPluginStatusConstant::PENDING) {
// 渠道通知已通过验签但尚未终态时,只记录日志,不提前推进支付单状态。
$this->notifyService->recordPayCallback($callbackPayload);
return $plugin->notifySuccess();
}
// 回调成功后统一交给生命周期服务落库,避免状态推进分散在不同分支里。
// 回调终态统一交给生命周期服务落库,避免状态推进分散在不同分支里。
$this->handleChannelCallback($callbackPayload);
return $success ? $plugin->notifySuccess() : $plugin->notifyFail();
// 只要验签通过且已被系统处理,统一回成功响应,避免渠道对失败终态反复重推。
return $plugin->notifySuccess();
} catch (PaymentException $e) {
// 插件已明确返回业务失败时,记录失败日志并失败响应收口
// 验签失败或插件解析失败时,记录失败日志并返回失败响应,允许渠道按自身策略重推
$this->notifyService->recordPayCallback([
'pay_no' => $payNo,
'channel_id' => (int) $payOrder->channel_id,
@@ -168,4 +172,89 @@ class PayOrderCallbackService extends BaseService
return $plugin->notifyFail();
}
}
/**
* 校验插件回调结果。
*
* 插件 `notify()` 必须直接返回当前系统约定的标准字段;
* 服务层不再做字段别名兼容或自动补齐。
*
* @param array<string, mixed> $result 插件返回值
* @return array<string, mixed>
* @throws PaymentException
*/
private function validatePluginNotifyResult(array $result): array
{
$requiredKeys = [
'status',
];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $result)) {
throw new PaymentException('插件回调返回缺少标准字段', 40200, [
'missing_key' => $key,
]);
}
}
$status = strtolower(trim((string) $result['status']));
if (!in_array($status, PaymentPluginStatusConstant::notifyStatuses(), true)) {
throw new PaymentException('插件回调返回的状态不合法', 40200, [
'status' => $status,
]);
}
$channelOrderNo = trim((string) ($result['channel_order_no'] ?? ''));
$channelTradeNo = trim((string) ($result['channel_trade_no'] ?? ''));
if ($channelOrderNo === '' && $channelTradeNo === '') {
throw new PaymentException('插件回调必须返回 channel_order_no 或 channel_trade_no', 40200);
}
if ($channelOrderNo === '') {
$channelOrderNo = $channelTradeNo;
}
if ($channelTradeNo === '') {
$channelTradeNo = $channelOrderNo;
}
if (array_key_exists('ext_json', $result) && !is_array($result['ext_json'])) {
throw new PaymentException('插件回调 ext_json 必须为数组', 40200);
}
$feeActualAmount = null;
if (array_key_exists('fee_actual_amount', $result) && $result['fee_actual_amount'] !== null) {
if (!is_numeric($result['fee_actual_amount'])) {
throw new PaymentException('插件回调 fee_actual_amount 必须为数字', 40200);
}
$feeActualAmount = (int) $result['fee_actual_amount'];
}
return [
'status' => $status,
'message' => trim((string) ($result['message'] ?? '')),
'channel_order_no' => $channelOrderNo,
'channel_trade_no' => $channelTradeNo,
'channel_status' => trim((string) ($result['channel_status'] ?? '')),
'channel_error_code' => trim((string) ($result['channel_error_code'] ?? '')),
'channel_error_msg' => trim((string) ($result['channel_error_msg'] ?? '')),
'paid_at' => $result['paid_at'] ?? null,
'failed_at' => $result['failed_at'] ?? null,
'fee_actual_amount' => $feeActualAmount,
'ext_json' => (array) ($result['ext_json'] ?? []),
];
}
/**
* 根据插件标准状态映射日志处理状态。
*
* @param string $status 标准状态
* @return int
*/
private function resolveProcessStatus(string $status): int
{
return match ($status) {
PaymentPluginStatusConstant::SUCCESS => NotifyConstant::PROCESS_STATUS_SUCCESS,
PaymentPluginStatusConstant::FAILED => NotifyConstant::PROCESS_STATUS_FAILED,
default => NotifyConstant::PROCESS_STATUS_PENDING,
};
}
}

View File

@@ -5,6 +5,7 @@ namespace app\service\payment\order;
use app\common\base\BaseService;
use app\exception\PaymentException;
use app\exception\ResourceNotFoundException;
use app\model\merchant\Merchant;
use app\model\payment\BizOrder;
use app\model\payment\PayOrder;
use app\model\payment\PaymentChannel;
@@ -48,11 +49,12 @@ class PayOrderChannelDispatchService extends BaseService
* @param PayOrder $payOrder 支付订单
* @param BizOrder $bizOrder 业务订单
* @param PaymentChannel $channel 渠道
* @param Merchant $merchant 商户
* @return array 拉起结果
* @throws ResourceNotFoundException
* @throws PaymentException
*/
public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel): array
public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel, Merchant $merchant): array
{
try {
// 先构造支付插件实例,由插件完成具体渠道下单。
@@ -60,31 +62,29 @@ class PayOrderChannelDispatchService extends BaseService
/** @var PaymentType|null $paymentType */
$paymentType = $this->paymentTypeRepository->find((int) $payOrder->pay_type_id);
$extJson = (array) ($payOrder->ext_json ?? []);
// 下单回调基址由支付单提前写入,这里拼出具体支付单回调地址交给插件使用。
$callbackBaseUrl = trim((string) ($extJson['channel_callback_base_url'] ?? ''));
$callbackUrl = $callbackBaseUrl === ''
? ''
: rtrim($callbackBaseUrl, '/') . '/' . $payOrder->pay_no . '/callback';
$callbackUrl = rtrim(sys_config('site_url'), '/') . '/api/pay/' . $payOrder->pay_no . '/callback';
// 插件下单参数里同时带业务单号、支付单号和扩展信息,方便渠道侧回调后能反查同一笔单。
$channelResult = $plugin->pay([
// 插件下单参数里同时带业务单号、支付单号和结构化扩展信息,方便渠道侧回调后能反查同一笔单。
$channelResult = $this->validatePluginPayResult($plugin->pay([
'pay_no' => (string) $payOrder->pay_no,
'order_id' => (string) $payOrder->pay_no,
'biz_no' => (string) $payOrder->biz_no,
'trace_no' => (string) $payOrder->trace_no,
'channel_request_no' => (string) $payOrder->channel_request_no,
'merchant_id' => (int) $payOrder->merchant_id,
'merchant_no' => (string) ($extJson['merchant_no'] ?? ''),
'merchant_no' => (string) $merchant->merchant_no,
'pay_type_id' => (int) $payOrder->pay_type_id,
'pay_type_code' => (string) ($paymentType->code ?? ''),
'amount' => (int) $payOrder->pay_amount,
'subject' => (string) ($bizOrder->subject ?? ''),
'body' => (string) ($bizOrder->body ?? ''),
'callback_url' => $callbackUrl,
'return_url' => (string) ($extJson['return_url'] ?? ''),
'_env' => (string) (($extJson['device'] ?? '') ?: 'pc'),
'notify_url' => (string) ($payOrder->notify_url ?? ''),
'return_url' => (string) ($payOrder->return_url ?? ''),
'client_ip' => (string) ($payOrder->client_ip ?? ''),
'_env' => (string) (($payOrder->device ?? '') ?: 'pc'),
'extra' => $extJson,
]);
]));
$payOrder = $this->transactionRetry(function () use ($payOrder, $channelResult) {
// 回写渠道订单号和支付参数快照,便于后续查询和回调排障。
@@ -95,11 +95,16 @@ class PayOrderChannelDispatchService extends BaseService
$latest->channel_order_no = (string) ($channelResult['chan_order_no'] ?? $latest->channel_order_no ?? '');
$latest->channel_trade_no = (string) ($channelResult['chan_trade_no'] ?? $latest->channel_trade_no ?? '');
$latest->ext_json = array_merge((array) $latest->ext_json, [
'pay_params_type' => (string) (($channelResult['pay_params']['type'] ?? '') ?: ''),
'pay_product' => (string) ($channelResult['pay_product'] ?? ''),
'pay_action' => (string) ($channelResult['pay_action'] ?? ''),
'pay_params_snapshot' => $this->normalizePayParamsSnapshot($channelResult['pay_params'] ?? []),
$latest->ext_json = array_replace_recursive((array) $latest->ext_json, [
'presentation' => [
'params_type' => (string) $channelResult['pay_params']['type'],
'product' => (string) ($channelResult['pay_product'] ?? ''),
'action' => (string) ($channelResult['pay_action'] ?? ''),
'params_snapshot' => $channelResult['pay_params'],
],
'plugin' => [
'pay_result' => (array) ($channelResult['ext_json'] ?? []),
],
]);
$latest->save();
@@ -111,7 +116,9 @@ class PayOrderChannelDispatchService extends BaseService
'channel_error_msg' => $e->getMessage(),
'channel_error_code' => (string) $e->getCode(),
'ext_json' => [
'plugin_code' => (string) $payOrder->plugin_code,
'plugin' => [
'code' => (string) $payOrder->plugin_code,
],
],
]);
@@ -122,20 +129,175 @@ class PayOrderChannelDispatchService extends BaseService
'channel_error_msg' => $e->getMessage(),
'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR',
'ext_json' => [
'plugin_code' => (string) $payOrder->plugin_code,
'plugin' => [
'code' => (string) $payOrder->plugin_code,
],
],
]);
throw new PaymentException('创建第三方支付订单失败' . $e->getMessage(), 40215);
throw new PaymentException('创建第三方支付订单失败', 40215, [
'error' => $e->getMessage(),
'plugin_code' => (string) $payOrder->plugin_code,
]);
}
return [
'pay_order' => $payOrder,
'payment_result' => $channelResult,
'pay_params' => $channelResult['pay_params'] ?? [],
'pay_params' => $channelResult['pay_params'],
];
}
/**
* 校验并归一化插件下单返回值。
*
* 插件返回值是支付页承接的唯一来源,必须在这里变成明确、可落库、可渲染的结构。
*
* @param array<string, mixed> $result 插件下单返回值
* @return array<string, mixed> 标准下单返回值
* @throws PaymentException
*/
private function validatePluginPayResult(array $result): array
{
foreach (['pay_product', 'pay_action', 'pay_params', 'chan_order_no'] as $key) {
if (!array_key_exists($key, $result)) {
throw new PaymentException('插件下单返回缺少标准字段', 40200, [
'missing_key' => $key,
]);
}
}
$payProduct = strtolower(trim((string) $result['pay_product']));
$payAction = strtolower(trim((string) $result['pay_action']));
$channelOrderNo = trim((string) $result['chan_order_no']);
$channelTradeNo = trim((string) ($result['chan_trade_no'] ?? ''));
if ($payProduct === '') {
throw new PaymentException('插件下单返回 pay_product 不能为空', 40200);
}
if ($payAction === '') {
throw new PaymentException('插件下单返回 pay_action 不能为空', 40200);
}
if ($channelOrderNo === '') {
throw new PaymentException('插件下单返回 chan_order_no 不能为空', 40200);
}
if (array_key_exists('ext_json', $result) && !is_array($result['ext_json'])) {
throw new PaymentException('插件下单返回 ext_json 必须为数组', 40200);
}
$payParams = $this->normalizePayParamsSnapshot($result['pay_params']);
$payParams = $this->validatePayParams($payParams);
return [
'pay_product' => $payProduct,
'pay_action' => $payAction,
'pay_params' => $payParams,
'chan_order_no' => $channelOrderNo,
'chan_trade_no' => $channelTradeNo,
'ext_json' => (array) ($result['ext_json'] ?? []),
];
}
/**
* 校验支付页承接参数。
*
* 每一种 `type` 都对应收银台的一种页面动作;必要载荷缺失时直接判定为插件异常。
*
* @param array<string, mixed> $payParams 支付参数
* @return array<string, mixed>
* @throws PaymentException
*/
private function validatePayParams(array $payParams): array
{
$type = strtolower(trim((string) ($payParams['type'] ?? '')));
if ($type === '') {
throw new PaymentException('插件下单返回 pay_params.type 不能为空', 40200);
}
$aliases = [
'scan' => 'qrcode',
'qr' => 'qrcode',
'code' => 'qrcode',
'redirect' => 'jump',
'url' => 'jump',
'wap' => 'h5',
'form' => 'html',
'app' => 'urlscheme',
'applet' => 'mini',
'wxplugin' => 'mini',
];
$type = $aliases[$type] ?? $type;
$allowed = [
'jump',
'web',
'h5',
'qrcode',
'html',
'jsapi',
'urlscheme',
'mini',
'pos',
'transfer',
'json',
'error',
];
if (!in_array($type, $allowed, true)) {
throw new PaymentException('插件下单返回 pay_params.type 不支持', 40200, [
'type' => $type,
]);
}
$payParams['type'] = $type;
if (in_array($type, ['jump', 'web', 'h5'], true)) {
$url = $this->firstText($payParams, ['redirect_url', 'payurl', 'pay_url', 'mweb_url', 'url']);
if ($url === '') {
throw new PaymentException('插件跳转支付缺少支付链接', 40200, [
'type' => $type,
]);
}
$payParams['redirect_url'] = $url;
$payParams['payurl'] = $url;
}
if ($type === 'qrcode') {
$qrcode = $this->firstText($payParams, ['qrcode_text', 'qrcode_data', 'qrcode_url', 'qrcode']);
if ($qrcode === '') {
throw new PaymentException('插件二维码支付缺少二维码内容', 40200);
}
$payParams['qrcode_text'] = $qrcode;
$payParams['qrcode'] = $qrcode;
}
if ($type === 'html' && $this->firstText($payParams, ['html', 'action']) === '') {
throw new PaymentException('插件表单支付缺少 html 或 action', 40200);
}
if ($type === 'urlscheme') {
$urlscheme = $this->firstText($payParams, ['urlscheme', 'redirect_url', 'order_str', 'order_string']);
if ($urlscheme === '') {
throw new PaymentException('插件 URL Scheme 支付缺少唤起参数', 40200);
}
$payParams['urlscheme'] = $urlscheme;
$payParams['redirect_url'] = $urlscheme;
}
if ($type === 'jsapi' && $this->firstText($payParams, ['order_str', 'order_string', 'app_id', 'appId']) === '' && empty($payParams['jsapi_params'])) {
throw new PaymentException('插件 JSAPI 支付缺少拉起参数', 40200);
}
if ($type === 'mini' && $this->firstText($payParams, ['path', 'scheme', 'urlscheme', 'trade_no']) === '' && empty($payParams['mini_params'])) {
throw new PaymentException('插件小程序支付缺少跳转参数', 40200);
}
if ($type === 'error' && $this->firstText($payParams, ['message', 'msg', 'error']) === '') {
throw new PaymentException('插件错误支付结果缺少错误信息', 40200);
}
return $payParams;
}
/**
* 归一化支付参数快照,便于后续页面渲染和排障。
*
@@ -156,9 +318,32 @@ class PayOrderChannelDispatchService extends BaseService
return [];
}
/**
* 从候选字段中取首个非空文本。
*
* @param array<string, mixed> $data 数据
* @param array<int, string> $keys 候选字段
* @return string
*/
private function firstText(array $data, array $keys): string
{
foreach ($keys as $key) {
$value = $data[$key] ?? null;
if ($value === null) {
continue;
}
if (is_scalar($value)) {
$text = trim((string) $value);
if ($text !== '') {
return $text;
}
}
}
return '';
}
}

View File

@@ -4,6 +4,7 @@ namespace app\service\payment\order;
use app\common\base\BaseService;
use app\common\constant\NotifyConstant;
use app\common\constant\EventConstant;
use app\common\constant\RouteConstant;
use app\common\constant\TradeConstant;
use app\exception\BusinessStateException;
@@ -11,6 +12,7 @@ use app\exception\ResourceNotFoundException;
use app\model\payment\PayOrder;
use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
use Webman\Event\Event;
/**
* 支付单生命周期服务。
@@ -48,9 +50,17 @@ class PayOrderLifecycleService extends BaseService
*/
public function markPaySuccess(string $payNo, array $input = []): PayOrder
{
return $this->transactionRetry(function () use ($payNo, $input) {
return $this->markPaySuccessInCurrentTransaction($payNo, $input);
$shouldNotifyMerchant = false;
$payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldNotifyMerchant) {
return $this->markPaySuccessInCurrentTransaction($payNo, $input, $shouldNotifyMerchant);
});
if ($shouldNotifyMerchant) {
$this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_SUCCEEDED, $payOrder);
}
return $payOrder;
}
/**
@@ -64,7 +74,7 @@ class PayOrderLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = [], bool &$shouldNotifyMerchant = false): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
@@ -112,11 +122,12 @@ class PayOrderLifecycleService extends BaseService
$payOrder->channel_error_code = '';
$payOrder->channel_error_msg = '';
$payOrder->callback_times = (int) $payOrder->callback_times + 1;
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
$payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []);
$payOrder->save();
// 业务单状态也要一起收口,保证支付单和业务单一致。
$this->syncBizOrderAfterSuccess($payOrder, $traceNo);
$shouldNotifyMerchant = true;
return $payOrder->refresh();
}
@@ -130,9 +141,17 @@ class PayOrderLifecycleService extends BaseService
*/
public function markPayFailed(string $payNo, array $input = []): PayOrder
{
return $this->transactionRetry(function () use ($payNo, $input) {
return $this->markPayFailedInCurrentTransaction($payNo, $input);
$shouldDispatchEvent = false;
$payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) {
return $this->markPayFailedInCurrentTransaction($payNo, $input, $shouldDispatchEvent);
});
if ($shouldDispatchEvent) {
$this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_FAILED, $payOrder);
}
return $payOrder;
}
/**
@@ -144,7 +163,7 @@ class PayOrderLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder
public function markPayFailedInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
@@ -177,15 +196,18 @@ class PayOrderLifecycleService extends BaseService
: TradeConstant::FEE_STATUS_NONE;
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE;
$payOrder->callback_status = NotifyConstant::PROCESS_STATUS_FAILED;
$payOrder->channel_trade_no = (string) ($input['channel_trade_no'] ?? $payOrder->channel_trade_no ?? '');
$payOrder->channel_order_no = (string) ($input['channel_order_no'] ?? $payOrder->channel_order_no ?? '');
$payOrder->channel_error_code = (string) ($input['channel_error_code'] ?? $payOrder->channel_error_code ?? '');
$payOrder->channel_error_msg = (string) ($input['channel_error_msg'] ?? $payOrder->channel_error_msg ?? '支付失败');
$payOrder->failed_at = $input['failed_at'] ?? $this->now();
$payOrder->callback_times = (int) $payOrder->callback_times + 1;
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
$payOrder->ext_json = array_replace_recursive((array) $payOrder->ext_json, $input['ext_json'] ?? []);
$payOrder->save();
// 支付单进入终态后,同步回业务单,避免上游只能依赖支付单判断结果。
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_FAILED, 'failed_at');
$shouldDispatchEvent = true;
return $payOrder->refresh();
}
@@ -199,9 +221,17 @@ class PayOrderLifecycleService extends BaseService
*/
public function closePayOrder(string $payNo, array $input = []): PayOrder
{
return $this->transactionRetry(function () use ($payNo, $input) {
return $this->closePayOrderInCurrentTransaction($payNo, $input);
$shouldDispatchEvent = false;
$payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) {
return $this->closePayOrderInCurrentTransaction($payNo, $input, $shouldDispatchEvent);
});
if ($shouldDispatchEvent) {
$this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_CLOSED, $payOrder);
}
return $payOrder;
}
/**
@@ -213,7 +243,7 @@ class PayOrderLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
public function closePayOrderInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
@@ -249,13 +279,16 @@ class PayOrderLifecycleService extends BaseService
$extJson = (array) $payOrder->ext_json;
$reason = trim((string) ($input['reason'] ?? ''));
if ($reason !== '') {
$extJson['close_reason'] = $reason;
$extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [
'close_reason' => $reason,
]);
}
$payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
$payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []);
$payOrder->save();
// 关闭态也要同步给业务单,避免后续继续拉起支付。
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_CLOSED, 'closed_at');
$shouldDispatchEvent = true;
return $payOrder->refresh();
}
@@ -269,9 +302,17 @@ class PayOrderLifecycleService extends BaseService
*/
public function timeoutPayOrder(string $payNo, array $input = []): PayOrder
{
return $this->transactionRetry(function () use ($payNo, $input) {
return $this->timeoutPayOrderInCurrentTransaction($payNo, $input);
$shouldDispatchEvent = false;
$payOrder = $this->transactionRetry(function () use ($payNo, $input, &$shouldDispatchEvent) {
return $this->timeoutPayOrderInCurrentTransaction($payNo, $input, $shouldDispatchEvent);
});
if ($shouldDispatchEvent) {
$this->dispatchPayOrderEvent(EventConstant::PAYMENT_PAY_ORDER_TIMEOUT, $payOrder);
}
return $payOrder;
}
/**
@@ -283,7 +324,7 @@ class PayOrderLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = [], bool &$shouldDispatchEvent = false): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
@@ -319,12 +360,15 @@ class PayOrderLifecycleService extends BaseService
$extJson = (array) $payOrder->ext_json;
$reason = trim((string) ($input['reason'] ?? ''));
if ($reason !== '') {
$extJson['timeout_reason'] = $reason;
$extJson['lifecycle'] = array_replace((array) ($extJson['lifecycle'] ?? []), [
'timeout_reason' => $reason,
]);
}
$payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
$payOrder->ext_json = array_replace_recursive($extJson, $input['ext_json'] ?? []);
$payOrder->save();
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_TIMEOUT, 'timeout_at');
$shouldDispatchEvent = true;
return $payOrder->refresh();
}
@@ -380,4 +424,20 @@ class PayOrderLifecycleService extends BaseService
$bizOrder->save();
}
/**
* 发送支付单事件。
*
* @param string $eventName 事件名称
* @param PayOrder $payOrder 支付订单
* @return void
*/
private function dispatchPayOrderEvent(string $eventName, PayOrder $payOrder): void
{
Event::dispatch($eventName, [
'pay_no' => (string) $payOrder->pay_no,
'biz_no' => (string) $payOrder->biz_no,
'pay_order' => $payOrder,
]);
}
}

View File

@@ -4,12 +4,14 @@ namespace app\service\payment\order;
use app\common\base\BaseService;
use app\common\constant\CommonConstant;
use app\common\constant\NotifyConstant;
use app\exception\ResourceNotFoundException;
use app\exception\ValidationException;
use app\model\payment\PayOrder;
use app\model\payment\PaymentType;
use app\repository\account\ledger\MerchantAccountLedgerRepository;
use app\repository\payment\config\PaymentTypeRepository;
use app\repository\payment\notify\NotifyTaskRepository;
use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
@@ -22,6 +24,7 @@ use app\repository\payment\trade\PayOrderRepository;
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
* @property NotifyTaskRepository $notifyTaskRepository 通知任务仓库
* @property PayOrderReportService $payOrderReportService 支付单报表服务
*/
class PayOrderQueryService extends BaseService
@@ -33,6 +36,7 @@ class PayOrderQueryService extends BaseService
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
* @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库
* @param PayOrderReportService $payOrderReportService 支付单报表服务
* @return void
*/
@@ -41,6 +45,7 @@ class PayOrderQueryService extends BaseService
protected BizOrderRepository $bizOrderRepository,
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository,
protected PaymentTypeRepository $paymentTypeRepository,
protected NotifyTaskRepository $notifyTaskRepository,
protected PayOrderReportService $payOrderReportService
) {
}
@@ -58,6 +63,148 @@ class PayOrderQueryService extends BaseService
* @return array{list: array<int, array<string, mixed>>, total: int, page: int, size: int, pay_types: array<int, array{label: string, value: int}>} 支付订单列表结构
*/
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
{
$query = $this->buildPayOrderQuery($merchantId);
$keyword = trim((string) ($filters['keyword'] ?? ''));
if ($keyword !== '') {
// 关键词同时命中支付单、业务单、商户、通道和支付方式,方便后台一把搜全链路。
$query->where(function ($builder) use ($keyword) {
$builder->where('po.pay_no', 'like', '%' . $keyword . '%')
->orWhere('po.biz_no', 'like', '%' . $keyword . '%')
->orWhere('po.trace_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%')
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
->orWhere('c.name', 'like', '%' . $keyword . '%')
->orWhere('t.name', 'like', '%' . $keyword . '%');
});
}
if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) {
$query->where('po.merchant_id', $merchantFilter);
}
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
$query->where('po.pay_type_id', $payTypeId);
}
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
$query->where('po.status', (int) $filters['status']);
}
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') {
$query->where('po.channel_mode', (int) $filters['channel_mode']);
}
if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') {
$query->where('po.callback_status', (int) $filters['callback_status']);
}
$paginator = $query
->orderByDesc('po.id')
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
$list = [];
foreach ($paginator->items() as $item) {
$list[] = $this->payOrderReportService->formatPayOrderRow($this->rowToArray($item));
}
return [
'list' => $list,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
'pay_types' => $this->payTypeOptions(),
];
}
/**
* 查询支付订单详情。
*
* 返回支付单、业务单、时间线和资金流水,供管理后台与商户后台共用。
*
* @param string $payNo 支付单号
* @param int|null $merchantId 商户ID
* @return array{pay_order: PayOrder, biz_order: \app\model\payment\BizOrder|null, pay_order_view: array<string, mixed>|null, timeline: array<int, array<string, mixed>>, account_ledgers: iterable, account_ledgers_view: array<int, array<string, mixed>>, notify_tasks: array<int, array<string, mixed>>} 支付详情结构
* @throws ValidationException
* @throws ResourceNotFoundException
*/
public function detail(string $payNo, ?int $merchantId = null): array
{
$payNo = trim($payNo);
if ($payNo === '') {
throw new ValidationException('pay_no 不能为空');
}
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
if (!$payOrder) {
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
}
if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) {
// 商户后台只允许看自己的单,归属不匹配时直接按不存在处理。
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
}
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
$detailRow = $this->buildPayOrderQuery($merchantId)
->where('po.pay_no', $payNo)
->first();
$timeline = $this->payOrderReportService->buildPayTimeline($payOrder);
$accountLedgers = $this->loadPayLedgers($payOrder);
$accountLedgerRows = [];
foreach ($accountLedgers as $ledger) {
$accountLedgerRows[] = $this->payOrderReportService->formatLedgerRow($this->rowToArray($ledger));
}
return [
'pay_order' => $payOrder,
'biz_order' => $bizOrder,
'pay_order_view' => $detailRow ? $this->payOrderReportService->formatPayOrderRow($this->rowToArray($detailRow)) : null,
'timeline' => $timeline,
'account_ledgers' => $accountLedgers,
'account_ledgers_view' => $accountLedgerRows,
'notify_tasks' => $this->loadNotifyTasks($payNo),
];
}
/**
* 加载支付相关资金流水。
*
* 优先按追踪号查询,追踪号为空时回退到业务单号,避免漏掉关联流水。
*
* @param PayOrder $payOrder 支付订单
* @return \Illuminate\Support\Collection 支付相关资金流水集合
*/
private function loadPayLedgers(PayOrder $payOrder)
{
$traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no));
$ledgers = $traceNo !== ''
? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo)
: collect();
if ($ledgers->isEmpty()) {
// 追踪号没有命中时,回到业务单号继续兜底,避免早期单据漏掉资金流水。
$ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->biz_no);
}
return $ledgers;
}
/**
* 查询支付单详情展示行,供列表与详情复用。
*
* @param int|null $merchantId 商户ID
* @return \Illuminate\Database\Eloquent\Builder
*/
private function buildPayOrderQuery(?int $merchantId = null)
{
$query = $this->payOrderRepository->query()
->from('ma_pay_order as po')
@@ -134,126 +281,38 @@ class PayOrderQueryService extends BaseService
$query->where('po.merchant_id', $merchantId);
}
$keyword = trim((string) ($filters['keyword'] ?? ''));
if ($keyword !== '') {
// 关键词同时命中支付单、业务单、商户、通道和支付方式,方便后台一把搜全链路。
$query->where(function ($builder) use ($keyword) {
$builder->where('po.pay_no', 'like', '%' . $keyword . '%')
->orWhere('po.biz_no', 'like', '%' . $keyword . '%')
->orWhere('po.trace_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%')
->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%')
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
->orWhere('c.name', 'like', '%' . $keyword . '%')
->orWhere('t.name', 'like', '%' . $keyword . '%');
});
}
if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) {
$query->where('po.merchant_id', $merchantFilter);
}
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
$query->where('po.pay_type_id', $payTypeId);
}
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
$query->where('po.status', (int) $filters['status']);
}
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') {
$query->where('po.channel_mode', (int) $filters['channel_mode']);
}
if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') {
$query->where('po.callback_status', (int) $filters['callback_status']);
}
$paginator = $query
->orderByDesc('po.id')
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
$list = [];
foreach ($paginator->items() as $item) {
$list[] = $this->payOrderReportService->formatPayOrderRow((array) $item);
}
return [
'list' => $list,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'size' => $paginator->perPage(),
'pay_types' => $this->payTypeOptions(),
];
return $query;
}
/**
* 查询支付订单详情
*
* 返回支付单、业务单、时间线和资金流水,供管理后台与商户后台共用。
* 加载并格式化通知任务列表
*
* @param string $payNo 支付单号
* @param int|null $merchantId 商户ID
* @return array{pay_order: PayOrder, biz_order: \app\model\payment\BizOrder|null, timeline: array<int, array<string, mixed>>, account_ledgers: \Illuminate\Support\Collection} 支付详情结构
* @throws ValidationException
* @throws ResourceNotFoundException
* @return array<int, array<string, mixed>>
*/
public function detail(string $payNo, ?int $merchantId = null): array
private function loadNotifyTasks(string $payNo): array
{
$payNo = trim($payNo);
if ($payNo === '') {
throw new ValidationException('pay_no 不能为空');
$rows = [];
foreach ($this->notifyTaskRepository->listByPayNo($payNo) as $task) {
$rows[] = [
'notify_no' => (string) $task->notify_no,
'event_type' => (string) ($task->event_type ?? ''),
'event_type_text' => (string) (NotifyConstant::eventTypeMap()[(string) ($task->event_type ?? '')] ?? ($task->event_type ?? '')),
'ref_no' => (string) ($task->ref_no ?? ''),
'notify_url' => (string) $task->notify_url,
'status' => (int) $task->status,
'status_text' => (string) (NotifyConstant::taskStatusMap()[(int) $task->status] ?? '未知'),
'retry_count' => (int) $task->retry_count,
'last_notify_at_text' => $this->formatDateTime($task->last_notify_at, '—'),
'next_retry_at_text' => $this->formatDateTime($task->next_retry_at, '—'),
'last_response' => (string) ($task->last_response ?? ''),
'notify_data' => (array) ($task->notify_data ?? []),
'created_at_text' => $this->formatDateTime($task->created_at, '—'),
'updated_at_text' => $this->formatDateTime($task->updated_at, '—'),
];
}
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
if (!$payOrder) {
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
}
if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) {
// 商户后台只允许看自己的单,归属不匹配时直接按不存在处理。
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
}
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
$timeline = $this->payOrderReportService->buildPayTimeline($payOrder);
$accountLedgers = $this->loadPayLedgers($payOrder);
return [
'pay_order' => $payOrder,
'biz_order' => $bizOrder,
'timeline' => $timeline,
'account_ledgers' => $accountLedgers,
];
}
/**
* 加载支付相关资金流水。
*
* 优先按追踪号查询,追踪号为空时回退到业务单号,避免漏掉关联流水。
*
* @param PayOrder $payOrder 支付订单
* @return \Illuminate\Support\Collection 支付相关资金流水集合
*/
private function loadPayLedgers(PayOrder $payOrder)
{
$traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no));
$ledgers = $traceNo !== ''
? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo)
: collect();
if ($ledgers->isEmpty()) {
// 追踪号没有命中时,回到业务单号继续兜底,避免早期单据漏掉资金流水。
$ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->biz_no);
}
return $ledgers;
return $rows;
}
/**
@@ -278,4 +337,27 @@ class PayOrderQueryService extends BaseService
->all();
}
/**
* 将查询结果统一转换为纯数组,避免直接强转模型对象时把内部属性泄漏出去。
*
* @param mixed $row 查询结果行
* @return array<string, mixed>
*/
private function rowToArray(mixed $row): array
{
if (is_array($row)) {
return $row;
}
if (is_object($row) && method_exists($row, 'toArray')) {
/** @var array<string, mixed> $data */
$data = $row->toArray();
return $data;
}
/** @var array<string, mixed> $data */
$data = (array) $row;
return $data;
}
}

View File

@@ -3,6 +3,7 @@
namespace app\service\payment\order;
use app\common\base\BaseService;
use app\common\constant\LedgerConstant;
use app\common\constant\NotifyConstant;
use app\common\constant\RouteConstant;
use app\common\constant\TradeConstant;
@@ -72,36 +73,61 @@ class PayOrderReportService extends BaseService
public function buildPayTimeline(PayOrder $payOrder): array
{
$extJson = (array) ($payOrder->ext_json ?? []);
$lifecycle = (array) ($extJson['lifecycle'] ?? []);
// 只保留真实发生过的节点,未触发的状态直接过滤掉,避免时间线里出现空占位。
return array_values(array_filter([
[
'status' => 'created',
'label' => '支付单创建',
'at' => $this->formatDateTime($payOrder->request_at ?? $payOrder->created_at ?? null, '—'),
],
$payOrder->paid_at ? [
'status' => 'success',
'label' => '支付成功',
'at' => $this->formatDateTime($payOrder->paid_at, '—'),
] : null,
$payOrder->closed_at ? [
'status' => 'closed',
'label' => '支付关闭',
'at' => $this->formatDateTime($payOrder->closed_at, '—'),
// 关闭原因优先取扩展信息里的记录,便于展示人工关单或自动关单原因。
'reason' => (string) ($extJson['close_reason'] ?? ''),
'reason' => (string) ($lifecycle['close_reason'] ?? ''),
] : null,
$payOrder->failed_at ? [
'status' => 'failed',
'label' => '支付失败',
'at' => $this->formatDateTime($payOrder->failed_at, '—'),
// 失败原因先看渠道返回,再回落到扩展信息里保存的统一原因字段。
'reason' => (string) ($payOrder->channel_error_msg ?: ($extJson['reason'] ?? '')),
] : null,
$payOrder->timeout_at ? [
'status' => 'timeout',
'label' => '支付超时',
'at' => $this->formatDateTime($payOrder->timeout_at, '—'),
'reason' => (string) ($extJson['timeout_reason'] ?? ''),
'reason' => (string) ($lifecycle['timeout_reason'] ?? ''),
] : null,
]));
}
/**
* 格式化支付相关资金流水。
*
* @param array<string, mixed> $row 原始流水数据
* @return array<string, mixed>
*/
public function formatLedgerRow(array $row): array
{
$row['biz_type_text'] = $this->textFromMap((int) ($row['biz_type'] ?? -1), LedgerConstant::bizTypeMap());
$row['event_type_text'] = $this->textFromMap((int) ($row['event_type'] ?? -1), LedgerConstant::eventTypeMap());
$row['direction_text'] = $this->textFromMap((int) ($row['direction'] ?? -1), LedgerConstant::directionMap());
$row['amount_text'] = $this->formatAmount((int) ($row['amount'] ?? 0));
$row['available_before_text'] = $this->formatAmount((int) ($row['available_before'] ?? 0));
$row['available_after_text'] = $this->formatAmount((int) ($row['available_after'] ?? 0));
$row['frozen_before_text'] = $this->formatAmount((int) ($row['frozen_before'] ?? 0));
$row['frozen_after_text'] = $this->formatAmount((int) ($row['frozen_after'] ?? 0));
$row['created_at_text'] = $this->formatDateTime($row['created_at'] ?? null, '—');
return $row;
}
}

View File

@@ -70,6 +70,17 @@ class PayOrderService extends BaseService
return $this->attemptService->preparePayAttempt($input);
}
/**
* 预创建收银台业务单。
*
* @param array $input 收银台数据
* @return array 发起结果
*/
public function prepareCashierBizOrder(array $input): array
{
return $this->attemptService->prepareCashierBizOrder($input);
}
/**
* 标记支付成功。
*
@@ -191,4 +202,3 @@ class PayOrderService extends BaseService
}

View File

@@ -0,0 +1,140 @@
<?php
namespace app\service\payment\order;
use app\common\base\BaseService;
use app\model\payment\BizOrder;
use support\Request;
/**
* 支付单入参组装器。
*
* 统一处理支付相关请求中的订单展示字段、客户端上下文和扩展参数。
*/
class PaymentOrderInputAssembler extends BaseService
{
/**
* 组装统一订单字段。
*
* @param array $payload 原始入参
* @param Request|null $request 请求对象
* @param BizOrder|null $bizOrder 业务单
* @param array<string, mixed> $seedExtJson 需要合并到扩展参数中的种子数据
* @return array<string, mixed>
*/
public function buildOrderFields(array $payload, ?Request $request = null, ?BizOrder $bizOrder = null, array $seedExtJson = []): array
{
// 商品标题优先用显式入参,缺失时回退到业务单快照,保证收银台恢复时展示一致。
$subject = trim((string) ($payload['subject'] ?? $payload['name'] ?? ($bizOrder?->subject ?? '')));
// 商品描述尽量沿用同一份展示文案,避免不同入口出现两套口径。
$body = trim((string) ($payload['body'] ?? $payload['subject'] ?? $payload['name'] ?? ($bizOrder?->body ?? '')));
if ($body === '') {
$body = $subject;
}
return [
'subject' => $subject,
'body' => $body !== '' ? $body : $subject,
'notify_url' => trim((string) ($payload['notify_url'] ?? ($bizOrder?->notify_url ?? ''))),
'return_url' => trim((string) ($payload['return_url'] ?? ($bizOrder?->return_url ?? ''))),
'client_ip' => $this->resolveClientIp($payload, $request, $bizOrder),
'device' => $this->resolveDevice($payload, $bizOrder),
'ext_json' => $this->buildExtJson($payload, $seedExtJson),
];
}
/**
* 组装扩展参数。
*
* 扩展参数按职责分区:
* - 顶层 `_protocol_version` 等强语义字段用于后台筛选和排障。
* - `merchant` 只放商户透传字段,后续会参与商户通知回传。
* - `payment` 只放本次支付载体需要的上下文,例如 JSAPI openid 或付款码。
*
* @param array $payload 原始入参
* @param array<string, mixed> $seedExtJson 需要保留的扩展参数
* @return array<string, mixed>
*/
public function buildExtJson(array $payload, array $seedExtJson = []): array
{
$extJson = $seedExtJson;
$merchant = array_filter([
'param' => $payload['param'] ?? null,
'buyer' => $payload['buyer'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
if ($merchant !== []) {
$extJson['merchant'] = array_replace((array) ($extJson['merchant'] ?? []), $merchant);
}
$payment = array_filter([
'method' => $payload['method'] ?? null,
'auth_code' => $payload['auth_code'] ?? null,
'sub_openid' => $payload['sub_openid'] ?? null,
'sub_appid' => $payload['sub_appid'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
if ($payment !== []) {
$extJson['payment'] = array_replace((array) ($extJson['payment'] ?? []), $payment);
}
return $extJson;
}
/**
* 解析客户端 IP。
*
* @param array $payload 原始入参
* @param Request|null $request 请求对象
* @param BizOrder|null $bizOrder 业务单
* @return string
*/
public function resolveClientIp(array $payload, ?Request $request = null, ?BizOrder $bizOrder = null): string
{
// 显式传入的 clientip / client_ip 优先级最高,兼容不同协议字段名。
$clientIp = trim((string) ($payload['clientip'] ?? ''));
if ($clientIp !== '') {
return $clientIp;
}
$clientIp = trim((string) ($payload['client_ip'] ?? ''));
if ($clientIp !== '') {
return $clientIp;
}
if ($bizOrder && trim((string) ($bizOrder->client_ip ?? '')) !== '') {
return trim((string) $bizOrder->client_ip);
}
// 最后才回退到请求源 IP避免把代理层或网关层地址误当成业务上下文。
if ($request) {
return trim((string) $request->getRealIp());
}
return '';
}
/**
* 解析设备类型。
*
* @param array $payload 原始入参
* @param BizOrder|null $bizOrder 业务单
* @param string $default 默认设备类型
* @return string
*/
public function resolveDevice(array $payload, ?BizOrder $bizOrder = null, string $default = 'pc'): string
{
// 设备类型先取请求参数,再用业务单快照兜底,最后才回落默认值。
$device = trim((string) ($payload['device'] ?? ''));
if ($device !== '') {
return $device;
}
if ($bizOrder && trim((string) ($bizOrder->device ?? '')) !== '') {
return trim((string) $bizOrder->device);
}
return $default;
}
}

View File

@@ -9,7 +9,9 @@ use app\exception\BusinessStateException;
use app\exception\ConflictException;
use app\exception\ResourceNotFoundException;
use app\exception\ValidationException;
use app\model\payment\BizOrder;
use app\model\payment\RefundOrder;
use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
use app\repository\payment\trade\RefundOrderRepository;
@@ -32,6 +34,7 @@ class RefundCreationService extends BaseService
*/
public function __construct(
protected PayOrderRepository $payOrderRepository,
protected BizOrderRepository $bizOrderRepository,
protected RefundOrderRepository $refundOrderRepository
) {
}
@@ -39,7 +42,7 @@ class RefundCreationService extends BaseService
/**
* 创建退款单。
*
* 当前支持整单全额退款,同一支付单只能创建张退款单。
* 当前支持整单或部分退款,同一支付单创建张退款单。
*
* @param array $input 退款参数
* @return RefundOrder 退款单记录
@@ -70,12 +73,27 @@ class RefundCreationService extends BaseService
]);
}
/** @var BizOrder|null $bizOrder */
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
if (!$bizOrder) {
throw new ResourceNotFoundException('业务单不存在', ['biz_no' => (string) $payOrder->biz_no]);
}
$refundAmount = array_key_exists('refund_amount', $input)
? (int) $input['refund_amount']
: (int) $payOrder->pay_amount;
if ($refundAmount <= 0) {
throw new ValidationException('退款金额不合法');
}
if ($refundAmount !== (int) $payOrder->pay_amount) {
throw new BusinessStateException('当前仅支持整单全额退款');
$alreadyRefunded = (int) $bizOrder->refund_amount;
$remainingRefundable = max(0, (int) $bizOrder->order_amount - $alreadyRefunded);
if ($refundAmount > $remainingRefundable) {
throw new BusinessStateException('退款金额超过可退余额', [
'pay_no' => $payNo,
'refund_amount' => $refundAmount,
'remaining' => $remainingRefundable,
]);
}
// 业务系统若传了商户退款单号,就优先按商户幂等键查重。
@@ -97,25 +115,13 @@ class RefundCreationService extends BaseService
}
}
// 没有商户退款单号时,用支付单号兜底,避免同一支付单重复创建退款单。
/** @var RefundOrder|null $existingByPayNo */
$existingByPayNo = $this->refundOrderRepository->findByPayNo($payNo);
if ($existingByPayNo) {
if ($merchantRefundNo !== '' && (string) $existingByPayNo->merchant_refund_no !== $merchantRefundNo) {
throw new ConflictException('重复退款', ['pay_no' => $payNo]);
}
return $existingByPayNo;
}
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
// 退款单落库时同步追踪号、渠道单号和反向手续费,方便后续退款推进与对账。
/** @var int $feeReverseAmount */
$feeReverseAmount = ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT)
? (int) $payOrder->fee_actual_amount
: 0;
// 代收场景下,退款需要把实际手续费作为反向金额记录下来,后续成功态才能正确冲正余额。
$feeReverseAmount = 0;
if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT && (int) $payOrder->pay_amount > 0) {
$feeReverseAmount = (int) floor(((int) $payOrder->fee_actual_amount) * $refundAmount / max(1, (int) $payOrder->pay_amount));
}
return $this->refundOrderRepository->create([
'refund_no' => $this->generateNo('RFD'),
'merchant_id' => (int) $payOrder->merchant_id,
@@ -134,9 +140,7 @@ class RefundCreationService extends BaseService
'processing_at' => null,
'retry_count' => 0,
'last_error' => '',
'ext_json' => array_merge($input['ext_json'] ?? [], [
'trace_no' => $traceNo,
]),
'ext_json' => (array) ($input['ext_json'] ?? []),
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace app\service\payment\order;
use app\common\base\BaseService;
use app\common\constant\EventConstant;
use app\common\constant\RouteConstant;
use app\common\constant\TradeConstant;
use app\exception\BusinessStateException;
@@ -12,6 +13,7 @@ use app\repository\payment\trade\BizOrderRepository;
use app\repository\payment\trade\PayOrderRepository;
use app\repository\payment\trade\RefundOrderRepository;
use app\service\account\funds\MerchantAccountService;
use Webman\Event\Event;
/**
* 退款单生命周期服务。
@@ -151,9 +153,17 @@ class RefundLifecycleService extends BaseService
*/
public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder
{
return $this->transactionRetry(function () use ($refundNo, $input) {
return $this->markRefundSuccessInCurrentTransaction($refundNo, $input);
$shouldDispatchEvent = false;
$refundOrder = $this->transactionRetry(function () use ($refundNo, $input, &$shouldDispatchEvent) {
return $this->markRefundSuccessInCurrentTransaction($refundNo, $input, $shouldDispatchEvent);
});
if ($shouldDispatchEvent) {
$this->dispatchRefundOrderEvent(EventConstant::REFUND_ORDER_SUCCEEDED, $refundOrder);
}
return $refundOrder;
}
/**
@@ -165,7 +175,7 @@ class RefundLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = [], bool &$shouldDispatchEvent = false): RefundOrder
{
$refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo);
if (!$refundOrder) {
@@ -196,25 +206,23 @@ class RefundLifecycleService extends BaseService
$traceNo = (string) ($refundOrder->trace_no ?: $refundOrder->biz_no);
if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT) {
// 平台代收退款在已结算时,需要同步冲减商户可提现余额,口径按实收净额处理。
$reverseAmount = max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount);
if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED && $reverseAmount > 0) {
$this->merchantAccountService->debitAvailableAmountInCurrentTransaction(
(int) $refundOrder->merchant_id,
$reverseAmount,
(string) $refundOrder->refund_no,
'REFUND_REVERSE:' . (string) $refundOrder->refund_no,
[
'pay_no' => (string) $refundOrder->pay_no,
'remark' => '平台代收退款冲减',
],
$traceNo
);
if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED) {
// 平台代收退款在已结算时,需要同步冲减商户可提现余额,口径按本次退款净额处理。
$reverseAmount = max(0, (int) $refundOrder->refund_amount - (int) $refundOrder->fee_reverse_amount);
if ($reverseAmount > 0) {
$this->merchantAccountService->debitAvailableAmountInCurrentTransaction(
(int) $refundOrder->merchant_id,
$reverseAmount,
(string) $refundOrder->refund_no,
'REFUND_REVERSE:' . (string) $refundOrder->refund_no,
[
'pay_no' => (string) $refundOrder->pay_no,
'remark' => '平台代收退款冲减',
],
$traceNo
);
}
}
// 已结算的代收单被退款后,状态要回写成 reversed表示结算已被抵消。
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_REVERSED;
$payOrder->save();
}
// 退款成功后,退款单和业务单都要同步收口到成功态。
@@ -227,14 +235,19 @@ class RefundLifecycleService extends BaseService
$bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $refundOrder->biz_no);
if ($bizOrder) {
// 业务单的退款金额直接收口到原支付金额,避免后续展示和统计再做推导
$bizOrder->refund_amount = (int) $bizOrder->order_amount;
// 业务单的退款金额按累计值收口,支持多笔部分退款
$bizOrder->refund_amount = min(
(int) $bizOrder->order_amount,
(int) $bizOrder->refund_amount + (int) $refundOrder->refund_amount
);
if (empty($bizOrder->trace_no)) {
$bizOrder->trace_no = $traceNo;
}
$bizOrder->save();
}
$shouldDispatchEvent = true;
return $refundOrder->refresh();
}
@@ -247,9 +260,17 @@ class RefundLifecycleService extends BaseService
*/
public function markRefundFailed(string $refundNo, array $input = []): RefundOrder
{
return $this->transactionRetry(function () use ($refundNo, $input) {
return $this->markRefundFailedInCurrentTransaction($refundNo, $input);
$shouldDispatchEvent = false;
$refundOrder = $this->transactionRetry(function () use ($refundNo, $input, &$shouldDispatchEvent) {
return $this->markRefundFailedInCurrentTransaction($refundNo, $input, $shouldDispatchEvent);
});
if ($shouldDispatchEvent) {
$this->dispatchRefundOrderEvent(EventConstant::REFUND_ORDER_FAILED, $refundOrder);
}
return $refundOrder;
}
/**
@@ -261,7 +282,7 @@ class RefundLifecycleService extends BaseService
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = [], bool &$shouldDispatchEvent = false): RefundOrder
{
$refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo);
if (!$refundOrder) {
@@ -297,7 +318,25 @@ class RefundLifecycleService extends BaseService
}
$refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
$refundOrder->save();
$shouldDispatchEvent = true;
return $refundOrder->refresh();
}
/**
* 发送退款单事件。
*
* @param string $eventName 事件名称
* @param RefundOrder $refundOrder 退款单
* @return void
*/
private function dispatchRefundOrderEvent(string $eventName, RefundOrder $refundOrder): void
{
Event::dispatch($eventName, [
'refund_no' => (string) $refundOrder->refund_no,
'pay_no' => (string) $refundOrder->pay_no,
'biz_no' => (string) $refundOrder->biz_no,
'refund_order' => $refundOrder,
]);
}
}