mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-26 12:04:28 +08:00
更新统一使用 PHPDoc + PSR-19 标准注释
This commit is contained in:
@@ -22,10 +22,39 @@ use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 旧版 Epay 协议兼容服务。
|
||||
*
|
||||
* 负责将旧协议请求转换为当前支付、退款和查询流程。
|
||||
*
|
||||
* @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
|
||||
* @property PaymentTypeService $paymentTypeService 支付类型服务
|
||||
* @property PayOrderService $payOrderService 支付订单服务
|
||||
* @property PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @property RefundService $refundService 退款服务
|
||||
*/
|
||||
class EpayCompatService extends BaseService
|
||||
{
|
||||
private const API_ACTIONS = ['query', 'settle', 'order', 'orders', 'refund'];
|
||||
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
|
||||
* @param PaymentTypeService $paymentTypeService 支付类型服务
|
||||
* @param PayOrderService $payOrderService 支付订单服务
|
||||
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @param RefundService $refundService 退款服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantApiCredentialService $merchantApiCredentialService,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
@@ -39,6 +68,14 @@ class EpayCompatService extends BaseService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理页面跳转支付入口。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return Response 跳转响应或错误 JSON
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function submit(array $payload, Request $request): Response
|
||||
{
|
||||
try {
|
||||
@@ -58,6 +95,13 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 API 支付入口。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function mapi(array $payload, Request $request): array
|
||||
{
|
||||
try {
|
||||
@@ -68,6 +112,14 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理旧版兼容入口。
|
||||
*
|
||||
* 支持 `query`、`settle`、`order`、`orders` 和 `refund` 五种操作。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function api(array $payload): array
|
||||
{
|
||||
$act = strtolower(trim((string) ($payload['act'] ?? '')));
|
||||
@@ -84,6 +136,12 @@ class EpayCompatService extends BaseService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户信息,对应 `act=query`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryMerchantInfo(array $payload): array
|
||||
{
|
||||
try {
|
||||
@@ -93,6 +151,7 @@ class EpayCompatService extends BaseService
|
||||
$merchant = $auth['merchant'];
|
||||
$credential = $auth['credential'];
|
||||
$account = $this->merchantAccountRepository->findByMerchantId($merchantId);
|
||||
// 旧协议会同时返回总单量、今日单量和昨日单量,便于上游直接做商户概览。
|
||||
$todayDate = FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$lastDayDate = FormatHelper::timestamp(strtotime('-1 day'), 'Y-m-d');
|
||||
$totalOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->count();
|
||||
@@ -117,6 +176,12 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询结算记录列表,对应 `act=settle`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function querySettlementList(array $payload): array
|
||||
{
|
||||
try {
|
||||
@@ -125,6 +190,7 @@ class EpayCompatService extends BaseService
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get();
|
||||
|
||||
// 旧协议列表只需要基础字段和金额文本,这里直接整理成可展示数组。
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
@@ -147,6 +213,12 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个订单,对应 `act=order`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryOrder(array $payload): array
|
||||
{
|
||||
try {
|
||||
@@ -158,18 +230,26 @@ class EpayCompatService extends BaseService
|
||||
return ['code' => 0, 'msg' => '订单不存在'];
|
||||
}
|
||||
|
||||
// 旧协议查询单号时,要把支付单和业务单合并成同一份响应结构。
|
||||
return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']);
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询订单,对应 `act=orders`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function queryOrders(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
// 旧接口默认只允许一次拉少量订单,这里沿用上限 50 的兼容口径。
|
||||
$limit = min(50, max(1, (int) ($payload['limit'] ?? 20)));
|
||||
$page = max(1, (int) ($payload['page'] ?? 1));
|
||||
$paginator = $this->payOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->paginate($limit, ['*'], 'page', $page);
|
||||
@@ -177,6 +257,7 @@ class EpayCompatService extends BaseService
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
// 批量查询和单条查询共用同一套格式化器,避免字段口径不一致。
|
||||
'data' => array_map(function ($row): array {
|
||||
return $this->formatEpayOrderRow($row, $this->bizOrderRepository->findByBizNo((string) $row->biz_no));
|
||||
}, $paginator->items()),
|
||||
@@ -186,12 +267,19 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交退款申请,对应 `act=refund`。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
public function createRefund(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
// 先确认退款目标单据归属当前商户,避免旧协议拿着别人的单号误发退款。
|
||||
$context = $this->resolvePayOrderContext($merchantId, $payload);
|
||||
if (!$context) {
|
||||
return ['code' => 1, 'msg' => '订单不存在'];
|
||||
@@ -213,6 +301,7 @@ class EpayCompatService extends BaseService
|
||||
]);
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
// 不同插件返回的退款结果字段不完全一致,这里仍按旧协议的退款参数重新组织一次。
|
||||
$pluginResult = $plugin->refund([
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
@@ -227,6 +316,7 @@ class EpayCompatService extends BaseService
|
||||
]);
|
||||
|
||||
if (!$this->isPluginSuccess($pluginResult)) {
|
||||
// 渠道明确失败时,先把退款单推进失败态,再把旧协议响应收口成失败文案。
|
||||
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
|
||||
'failed_at' => $this->now(),
|
||||
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
|
||||
@@ -249,8 +339,18 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理支付提交请求。
|
||||
*
|
||||
* 这里负责把旧协议载荷转换为当前支付单创建所需的数据结构。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> 预处理数据
|
||||
*/
|
||||
private function prepareSubmitAttempt(array $payload, Request $request): array
|
||||
{
|
||||
// 先把旧协议载荷转换成当前系统的统一入参,再交给支付单主流程处理。
|
||||
$normalized = $this->normalizeSubmitPayload($payload, $request);
|
||||
$result = $this->payOrderService->preparePayAttempt($normalized);
|
||||
$payOrder = $result['pay_order'];
|
||||
@@ -265,8 +365,19 @@ class EpayCompatService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化提交支付参数。
|
||||
*
|
||||
* 这里会完成签名校验、金额转分、支付方式解析,并把旧协议字段写入扩展信息。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return array<string, mixed> 当前支付单创建参数
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function normalizeSubmitPayload(array $payload, Request $request): array
|
||||
{
|
||||
// 提交入口也必须先验签,避免旧协议请求绕过统一的身份校验。
|
||||
$this->merchantApiCredentialService->verifyMd5Sign($payload);
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
@@ -296,6 +407,7 @@ class EpayCompatService extends BaseService
|
||||
'submitted_type' => $typeCode,
|
||||
'submit_mode' => $typeCode === '' ? 'cashier' : 'direct',
|
||||
'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')),
|
||||
// 原始请求快照保留在扩展字段里,方便后续排查旧协议参数差异。
|
||||
'request_snapshot' => $this->normalizeRequestSnapshot($payload),
|
||||
'channel_callback_base_url' => (string) sys_config('site_url') . '/api/pay',
|
||||
];
|
||||
@@ -311,6 +423,15 @@ class EpayCompatService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析提交支付方式。
|
||||
*
|
||||
* 空支付方式时,沿用当前系统默认启用支付方式;显式传值时必须是启用中的支付方式。
|
||||
*
|
||||
* @param string $typeCode 支付方式编码
|
||||
* @return PaymentType 支付方式模型
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function resolveSubmitPaymentType(string $typeCode): PaymentType
|
||||
{
|
||||
$typeCode = trim($typeCode);
|
||||
@@ -326,6 +447,14 @@ class EpayCompatService extends BaseService
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建旧版 MAPI 返回结构。
|
||||
*
|
||||
* 根据当前支付尝试结果,输出 payurl、qrcode 或 urlscheme 等旧协议字段。
|
||||
*
|
||||
* @param array $attempt 支付尝试结果
|
||||
* @return array<string, mixed> ePay 风格响应
|
||||
*/
|
||||
private function buildMapiResponse(array $attempt): array
|
||||
{
|
||||
/** @var PayOrder $payOrder */
|
||||
@@ -336,6 +465,7 @@ class EpayCompatService extends BaseService
|
||||
$response = ['code' => 1, 'msg' => '提交成功', 'trade_no' => $payNo];
|
||||
$type = (string) ($payParams['type'] ?? '');
|
||||
|
||||
// 不同插件返回的支付承载形态不同,这里按旧协议常见字段逐个兼容。
|
||||
if ($type === 'qrcode') {
|
||||
$qrcode = $this->stringifyValue($payParams['qrcode_url'] ?? $payParams['qrcode_data'] ?? '');
|
||||
if ($qrcode !== '') {
|
||||
@@ -364,6 +494,7 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
|
||||
if ($type === 'form' && $this->stringifyValue($payParams['html'] ?? '') !== '') {
|
||||
// 表单类承载本身会把页面内容交给插件,这里仍然只回传收银台入口。
|
||||
$response['payurl'] = $cashierUrl;
|
||||
return $response;
|
||||
}
|
||||
@@ -385,6 +516,13 @@ class EpayCompatService extends BaseService
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前支付单格式化为旧版订单查询结构。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @return array<string, mixed> 旧版订单结构
|
||||
*/
|
||||
private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
@@ -406,6 +544,15 @@ class EpayCompatService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付订单上下文。
|
||||
*
|
||||
* 优先按 `trade_no` 查找,其次按 `out_trade_no` 回退,并校验订单归属当前商户。
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param array $payload 请求载荷
|
||||
* @return array{pay_order: PayOrder, biz_order: BizOrder|null}|null 上下文
|
||||
*/
|
||||
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
|
||||
{
|
||||
$payNo = trim((string) ($payload['trade_no'] ?? ''));
|
||||
@@ -414,6 +561,7 @@ class EpayCompatService extends BaseService
|
||||
$bizOrder = null;
|
||||
|
||||
if ($payNo !== '') {
|
||||
// 旧协议如果传了 trade_no,就优先按支付单号定位,命中率最高。
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if ($payOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
@@ -421,12 +569,15 @@ class EpayCompatService extends BaseService
|
||||
}
|
||||
|
||||
if (!$payOrder && $merchantOrderNo !== '') {
|
||||
// 没有 trade_no 时,再按商户单号反查业务单和最新支付单。
|
||||
$bizOrder = $this->bizOrderRepository->findByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
if ($bizOrder) {
|
||||
// 旧协议经常只传商户单号,这里拿业务单找到最新一笔支付单。
|
||||
$payOrder = $this->payOrderRepository->findLatestByBizNo((string) $bizOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
// 旧协议有时会传到别家商户的单号,这里必须再次校验归属,避免跨商户读取。
|
||||
if (!$payOrder || (int) $payOrder->merchant_id !== $merchantId) {
|
||||
return null;
|
||||
}
|
||||
@@ -438,13 +589,26 @@ class EpayCompatService extends BaseService
|
||||
return ['pay_order' => $payOrder, 'biz_order' => $bizOrder];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 解析支付方式编码。
|
||||
*
|
||||
* @param int $payTypeId 支付方式ID
|
||||
* @return string 支付方式编码
|
||||
*/
|
||||
private function resolvePaymentTypeCode(int $payTypeId): string
|
||||
{
|
||||
return $this->paymentTypeService->resolveCodeById($payTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析商户结算类型。
|
||||
*
|
||||
* @param object $merchant 商户对象
|
||||
* @return int 结算类型编码
|
||||
*/
|
||||
private function resolveMerchantSettlementType(mixed $merchant): int
|
||||
{
|
||||
// 旧 Epay 协议里结算类型是约定好的整数,这里用账户信息做一个兼容性映射。
|
||||
$bankName = strtolower(trim((string) ($merchant->settlement_bank_name ?? '')));
|
||||
$accountName = strtolower(trim((string) ($merchant->settlement_account_name ?? '')));
|
||||
$accountNo = strtolower(trim((string) ($merchant->settlement_account_no ?? '')));
|
||||
@@ -468,6 +632,12 @@ class EpayCompatService extends BaseService
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将元金额转成分。
|
||||
*
|
||||
* @param string $money 金额字符串
|
||||
* @return int 金额分值,非法时返回 0
|
||||
*/
|
||||
private function parseMoneyToAmount(string $money): int
|
||||
{
|
||||
$money = trim($money);
|
||||
@@ -475,9 +645,19 @@ class EpayCompatService extends BaseService
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 旧协议金额按“元”传入,内部统一转成“分”处理。
|
||||
return (int) round(((float) $money) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析客户端 IP。
|
||||
*
|
||||
* 优先使用旧协议中的 `clientip`,缺省时回退到请求真实 IP。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @param Request $request 请求对象
|
||||
* @return string 客户端 IP
|
||||
*/
|
||||
private function resolveClientIp(array $payload, Request $request): string
|
||||
{
|
||||
$clientIp = trim((string) ($payload['clientip'] ?? ''));
|
||||
@@ -485,15 +665,29 @@ class EpayCompatService extends BaseService
|
||||
return $clientIp;
|
||||
}
|
||||
|
||||
// 旧请求没传 clientip 时,退回到框架识别的真实 IP。
|
||||
return trim((string) $request->getRealIp());
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化设备类型。
|
||||
*
|
||||
* @param string $device 设备编码
|
||||
* @return string 归一化后的设备编码
|
||||
*/
|
||||
private function normalizeDeviceCode(string $device): string
|
||||
{
|
||||
$device = strtolower(trim($device));
|
||||
// 没传设备类型时默认按 pc 处理,兼容旧接口的页面跳转场景。
|
||||
return $device !== '' ? $device : 'pc';
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化旧协议扩展参数。
|
||||
*
|
||||
* @param array|object|bool|float|int|string|null $value 扩展参数
|
||||
* @return array|string|null 归一化后的值
|
||||
*/
|
||||
private function normalizePayloadValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -512,30 +706,67 @@ class EpayCompatService extends BaseService
|
||||
return is_scalar($value) ? (string) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成请求快照。
|
||||
*
|
||||
* 快照会移除敏感签名字段,便于落库排障。
|
||||
*
|
||||
* @param array $payload 请求载荷
|
||||
* @return array<string, mixed> 请求快照
|
||||
*/
|
||||
private function normalizeRequestSnapshot(array $payload): array
|
||||
{
|
||||
$snapshot = $payload;
|
||||
// 签名字段和内部 submit_mode 不参与快照展示,避免误导排障。
|
||||
unset($snapshot['sign'], $snapshot['key']);
|
||||
unset($snapshot['submit_mode']);
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建收银台跳转地址。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @return string 收银台 URL
|
||||
*/
|
||||
private function buildCashierUrl(string $payNo): string
|
||||
{
|
||||
return (string) sys_config('site_url') . '/pay/' . rawurlencode($payNo) . '/payment';
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化异常提示。
|
||||
*
|
||||
* @param Throwable $e 异常对象
|
||||
* @param string $fallback 默认文案
|
||||
* @return string 错误提示
|
||||
*/
|
||||
private function normalizeErrorMessage(Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim((string) $e->getMessage());
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件返回的 success 标记。
|
||||
*
|
||||
* 如果插件未显式返回 `success`,则默认视为成功。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @return bool 插件是否通过
|
||||
*/
|
||||
private function isPluginSuccess(array $pluginResult): bool
|
||||
{
|
||||
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析退款渠道单号。
|
||||
*
|
||||
* @param array $pluginResult 插件结果
|
||||
* @param string $default 默认值
|
||||
* @return string 渠道退款单号
|
||||
*/
|
||||
private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string
|
||||
{
|
||||
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
|
||||
@@ -550,6 +781,12 @@ class EpayCompatService extends BaseService
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意值规范化为字符串。
|
||||
*
|
||||
* @param array|object|bool|float|int|string|null $value 待转换值
|
||||
* @return string 规范化后的字符串
|
||||
*/
|
||||
private function stringifyValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
@@ -562,6 +799,7 @@ class EpayCompatService extends BaseService
|
||||
return (string) $value;
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
// 复杂结构直接 JSON 化,保证旧协议回显时仍然可读。
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
|
||||
@@ -13,9 +13,25 @@ use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 支付通道命令服务。
|
||||
*
|
||||
* 负责支付通道的新增、修改、删除以及写入前的商户、插件和支付方式约束校验。
|
||||
*
|
||||
* @property MerchantRepository $merchantRepository 商户仓库
|
||||
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
*/
|
||||
class PaymentChannelCommandService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantRepository $merchantRepository 商户仓库
|
||||
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
@@ -24,13 +40,27 @@ class PaymentChannelCommandService extends BaseService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @return PaymentChannel|null 支付通道模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增支付通道。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel 新增后的支付通道模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function create(array $data): PaymentChannel
|
||||
{
|
||||
// 新增通道前先校验名称、商户归属和插件支付方式兼容性。
|
||||
$this->assertChannelNameUnique((string) ($data['name'] ?? ''));
|
||||
$this->assertMerchantExists($data);
|
||||
$this->assertPluginSupportsPayType($data);
|
||||
@@ -38,8 +68,17 @@ class PaymentChannelCommandService extends BaseService
|
||||
return $this->paymentChannelRepository->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel|null 更新后的支付通道模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentChannel
|
||||
{
|
||||
// 更新通道时同样要先拦住冲突配置,避免保存后才发现路由不可用。
|
||||
$this->assertChannelNameUnique((string) ($data['name'] ?? ''), $id);
|
||||
$this->assertMerchantExists($data);
|
||||
$this->assertPluginSupportsPayType($data);
|
||||
@@ -51,11 +90,24 @@ class PaymentChannelCommandService extends BaseService
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentChannelRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验通道所属商户是否存在。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertMerchantExists(array $data): void
|
||||
{
|
||||
if (!array_key_exists('merchant_id', $data)) {
|
||||
@@ -63,6 +115,7 @@ class PaymentChannelCommandService extends BaseService
|
||||
}
|
||||
|
||||
$merchantId = (int) $data['merchant_id'];
|
||||
// merchant_id 为空或为 0 时通常表示通道草稿,这里不强制拦截。
|
||||
if ($merchantId === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -74,11 +127,19 @@ class PaymentChannelCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验支付插件是否支持当前支付方式。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertPluginSupportsPayType(array $data): void
|
||||
{
|
||||
$pluginCode = trim((string) ($data['plugin_code'] ?? ''));
|
||||
$payTypeId = (int) ($data['pay_type_id'] ?? 0);
|
||||
|
||||
// 草稿态允许只填一半字段,只有插件和支付方式都明确时才做交叉校验。
|
||||
if ($pluginCode === '' || $payTypeId <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -90,6 +151,7 @@ class PaymentChannelCommandService extends BaseService
|
||||
return;
|
||||
}
|
||||
|
||||
// 插件支持的支付方式可能来自 JSON 配置,先统一压成编码列表再比对。
|
||||
$payTypes = is_array($plugin->pay_types) ? $plugin->pay_types : [];
|
||||
$payTypeCodes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $payTypes)));
|
||||
$payTypeCode = trim((string) $paymentType->code);
|
||||
@@ -102,6 +164,14 @@ class PaymentChannelCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验通道名称唯一。
|
||||
*
|
||||
* @param string $name 通道名称
|
||||
* @param int $ignoreId 排除的通道ID
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertChannelNameUnique(string $name, int $ignoreId = 0): void
|
||||
{
|
||||
$name = trim($name);
|
||||
@@ -117,3 +187,5 @@ class PaymentChannelCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,15 +8,30 @@ use app\model\payment\PaymentChannel;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
|
||||
/**
|
||||
* 支付通道查询服务。
|
||||
* 支付通道查询与选项拼装服务。
|
||||
*
|
||||
* 负责支付通道列表、详情、下拉选项和路由候选数据的查询拼装。
|
||||
*
|
||||
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
*/
|
||||
class PaymentChannelQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $paymentChannelRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用支付通道选项。
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}> 启用通道选项
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->paymentChannelRepository->query()
|
||||
@@ -38,6 +53,14 @@ class PaymentChannelQueryService extends BaseService
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索支付通道选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return array{list: array<int, array{label: string, value: int, merchant_id: int, merchant_no: string, merchant_name: string, channel_mode: int, pay_type_id: int, pay_type_name: string, plugin_code: string}>, total: int, page: int, size: int} 通道搜索结果
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
@@ -59,12 +82,15 @@ class PaymentChannelQueryService extends BaseService
|
||||
|
||||
$ids = $this->normalizeIds($filters['ids'] ?? []);
|
||||
if (!empty($ids)) {
|
||||
// 显式传 ID 时,直接按 ID 集合返回,避免再叠加其他筛选条件影响回显。
|
||||
$query->whereIn('c.id', $ids);
|
||||
} else {
|
||||
// 选择器默认只给启用通道,避免把已停用的历史数据混进后台下拉框。
|
||||
$query->where('c.status', CommonConstant::STATUS_ENABLED);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时支持通道、插件和商户维度搜索,方便后台快速定位路由节点。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
@@ -87,6 +113,7 @@ class PaymentChannelQueryService extends BaseService
|
||||
|
||||
$excludeIds = $this->normalizeIds($filters['exclude_ids'] ?? []);
|
||||
if (!empty($excludeIds)) {
|
||||
// 编排时经常要排除当前已选项,这里提供反选列表避免重复挂载同一通道。
|
||||
$query->whereNotIn('c.id', $excludeIds);
|
||||
}
|
||||
}
|
||||
@@ -119,6 +146,12 @@ class PaymentChannelQueryService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付通道路由候选选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @return array<int, array{label: string, value: int, merchant_id: int, channel_mode: int, pay_type_id: int, plugin_code: string, pay_type_name: string}> 路由候选选项
|
||||
*/
|
||||
public function routeOptions(array $filters = []): array
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
@@ -140,6 +173,7 @@ class PaymentChannelQueryService extends BaseService
|
||||
}
|
||||
|
||||
if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '') {
|
||||
// 路由预览/编排时会按商户分组筛选通道,这里直接用商户 ID 限定范围。
|
||||
$query->where('c.merchant_id', (int) $filters['merchant_id']);
|
||||
}
|
||||
|
||||
@@ -162,6 +196,14 @@ class PaymentChannelQueryService extends BaseService
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付通道。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
@@ -175,6 +217,7 @@ class PaymentChannelQueryService extends BaseService
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 列表页的搜索同时覆盖通道名、插件编码和商户信息,便于运营一次性查到整条链路。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
@@ -210,11 +253,23 @@ class PaymentChannelQueryService extends BaseService
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @return PaymentChannel|null 支付通道模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 ID 列表。
|
||||
*
|
||||
* @param array|string|int $ids 通道ID或ID列表
|
||||
* @return array<int, int> ID 列表
|
||||
*/
|
||||
private function normalizeIds(array|string|int $ids): array
|
||||
{
|
||||
if (is_string($ids)) {
|
||||
@@ -223,6 +278,9 @@ class PaymentChannelQueryService extends BaseService
|
||||
$ids = [$ids];
|
||||
}
|
||||
|
||||
// 下拉/搜索参数有时是字符串、有时是数组,统一压成正整数列表后再查询。
|
||||
return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,53 +6,116 @@ use app\common\base\BaseService;
|
||||
use app\model\payment\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付通道门面服务。
|
||||
* 支付通道服务。
|
||||
*
|
||||
* @property PaymentChannelQueryService $queryService 查询服务
|
||||
* @property PaymentChannelCommandService $commandService 命令服务
|
||||
*/
|
||||
class PaymentChannelService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentChannelQueryService $queryService 查询服务
|
||||
* @param PaymentChannelCommandService $commandService 命令服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentChannelQueryService $queryService,
|
||||
protected PaymentChannelCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用支付通道选项。
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}> 启用通道选项
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->queryService->enabledOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索支付通道选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return array{list: array<int, array<string, mixed>>, total: int, page: int, size: int} 通道搜索结果
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
return $this->queryService->searchOptions($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付渠道路由选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @return array<int, array<string, mixed>> 路由候选选项
|
||||
*/
|
||||
public function routeOptions(array $filters = []): array
|
||||
{
|
||||
return $this->queryService->routeOptions($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付通道。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @return PaymentChannel|null 支付通道模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->queryService->findById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增支付通道。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel 新增后的支付通道模型
|
||||
*/
|
||||
public function create(array $data): PaymentChannel
|
||||
{
|
||||
return $this->commandService->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentChannel|null 更新后的支付通道模型
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentChannel
|
||||
{
|
||||
return $this->commandService->update($id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付通道。
|
||||
*
|
||||
* @param int $id 支付通道ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,20 @@ use app\repository\payment\config\PaymentPluginRepository;
|
||||
/**
|
||||
* 支付插件配置服务。
|
||||
*
|
||||
* 负责插件公共配置的增删改查和下拉选项输出。
|
||||
* 负责支付插件公共配置的增删改查、下拉选项输出以及插件存在性校验。
|
||||
*
|
||||
* @property PaymentPluginConfRepository $paymentPluginConfRepository 支付插件配置仓库
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
*/
|
||||
class PaymentPluginConfService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginConfRepository $paymentPluginConfRepository 支付插件配置仓库
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
protected PaymentPluginRepository $paymentPluginRepository
|
||||
@@ -23,6 +33,11 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询插件配置。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
@@ -43,6 +58,7 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 列表页关键词同时覆盖插件编码、备注和插件名称,方便后台快速定位配置记录。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.remark', 'like', '%' . $keyword . '%')
|
||||
@@ -62,6 +78,9 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 按 ID 查询插件配置。
|
||||
*
|
||||
* @param int $id 支付插件配置ID
|
||||
* @return PaymentPluginConf|null 插件配置模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPluginConf
|
||||
{
|
||||
@@ -70,6 +89,10 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 新增插件配置。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPluginConf 新增后的插件配置模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function create(array $data): PaymentPluginConf
|
||||
{
|
||||
@@ -81,6 +104,11 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 修改插件配置。
|
||||
*
|
||||
* @param int $id 支付插件配置ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPluginConf|null 更新后的插件配置模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPluginConf
|
||||
{
|
||||
@@ -96,6 +124,9 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 删除插件配置。
|
||||
*
|
||||
* @param int $id 支付插件配置ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
@@ -104,6 +135,9 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询插件配置下拉选项。
|
||||
*
|
||||
* @param string|null $pluginCode 插件编码
|
||||
* @return array<int, array{label: string, value: int, plugin_code: string, plugin_name: string}> 配置选项
|
||||
*/
|
||||
public function options(?string $pluginCode = null): array
|
||||
{
|
||||
@@ -120,6 +154,7 @@ class PaymentPluginConfService extends BaseService
|
||||
->orderByDesc('c.id');
|
||||
|
||||
if ($pluginCode !== '') {
|
||||
// 如果前端已经明确指定插件编码,就只回这个插件下的配置选项。
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
@@ -138,7 +173,12 @@ class PaymentPluginConfService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程查询插件配置选择项。
|
||||
* 搜索插件配置选择项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return array{list: array<int, array{label: string, value: int, plugin_code: string, plugin_name: string}>, total: int, page: int, size: int} 配置搜索结果
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
@@ -154,15 +194,18 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
$ids = $filters['ids'] ?? [];
|
||||
if (is_array($ids) && $ids !== []) {
|
||||
// 显式传 ID 时优先按配置主键回显,避免关键词过滤把已选项漏掉。
|
||||
$query->whereIn('c.id', array_map('intval', $ids));
|
||||
} else {
|
||||
$pluginCode = trim((string) ($filters['plugin_code'] ?? ''));
|
||||
if ($pluginCode !== '') {
|
||||
// 插件编码是配置项的一级过滤条件,先收窄到单个插件。
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 数字关键词既可以按配置 ID 查,也可以按编码或备注查。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.name', 'like', '%' . $keyword . '%')
|
||||
@@ -197,13 +240,18 @@ class PaymentPluginConfService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化写入数据。
|
||||
* 标准化插件配置写入数据。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return array<string, mixed> 标准化后的数据
|
||||
*/
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
return [
|
||||
'plugin_code' => trim((string) ($data['plugin_code'] ?? '')),
|
||||
// 配置内容统一按数组保存,外部传入非数组时直接回退为空数组。
|
||||
'config' => is_array($data['config'] ?? null) ? $data['config'] : [],
|
||||
// 默认结算周期按日配置,截止时间默认按当天 23:59:59 收口。
|
||||
'settlement_cycle_type' => (int) ($data['settlement_cycle_type'] ?? 1),
|
||||
'settlement_cutoff_time' => trim((string) ($data['settlement_cutoff_time'] ?? '23:59:59')) ?: '23:59:59',
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
@@ -212,6 +260,10 @@ class PaymentPluginConfService extends BaseService
|
||||
|
||||
/**
|
||||
* 校验插件是否存在。
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertPluginExists(string $pluginCode): void
|
||||
{
|
||||
@@ -219,6 +271,7 @@ class PaymentPluginConfService extends BaseService
|
||||
throw new PaymentException('插件编码不能为空', 40230);
|
||||
}
|
||||
|
||||
// 插件配置必须挂到已存在的插件定义上,避免配置和实际实现脱节。
|
||||
if (!$this->paymentPluginRepository->findByCode($pluginCode)) {
|
||||
throw new PaymentException('支付插件不存在', 40231, [
|
||||
'plugin_code' => $pluginCode,
|
||||
|
||||
@@ -11,11 +11,18 @@ use app\repository\payment\config\PaymentPluginRepository;
|
||||
* 支付插件管理服务。
|
||||
*
|
||||
* 负责插件目录同步、插件列表查询,以及 JSON 字段写入前的归一化。
|
||||
*
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @property PaymentPluginSyncService $paymentPluginSyncService 支付插件同步服务
|
||||
*/
|
||||
class PaymentPluginService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入支付插件仓库。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @param PaymentPluginSyncService $paymentPluginSyncService 支付插件同步服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
@@ -25,6 +32,11 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询支付插件。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
@@ -60,6 +72,8 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询启用中的支付插件选项。
|
||||
*
|
||||
* @return array<int, array{label: string, value: string, code: string, name: string}> 启用插件选项
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
@@ -77,7 +91,12 @@ class PaymentPluginService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程查询支付插件选择项。
|
||||
* 搜索支付插件选择项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return array{list: array<int, array{label: string, value: string, code: string, name: string, pay_types: array<int, string>}>, total: int, page: int, size: int} 插件搜索结果
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
@@ -88,6 +107,7 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
$ids = $filters['ids'] ?? [];
|
||||
if (is_array($ids) && $ids !== []) {
|
||||
// 显式传 ID 时优先按编码集合回显,避免关键词过滤把手工选择项漏掉。
|
||||
$query->whereIn('code', array_values(array_filter(array_map('strval', $ids))));
|
||||
} else {
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
@@ -100,6 +120,7 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
$payTypeCode = trim((string) ($filters['pay_type_code'] ?? ''));
|
||||
if ($payTypeCode !== '') {
|
||||
// 如果前端按支付方式筛选,就只保留 pay_types 中包含该编码的插件。
|
||||
$query->whereJsonContains('pay_types', $payTypeCode);
|
||||
}
|
||||
}
|
||||
@@ -124,9 +145,12 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询通道配置场景使用的支付插件选项。
|
||||
*
|
||||
* @return array<int, array{label: string, value: string, code: string, name: string, pay_types: array<int, string>}> 通道配置选项
|
||||
*/
|
||||
public function channelOptions(): array
|
||||
{
|
||||
// 通道配置场景只需要启用中的插件,并且要带上支付方式集合供前端联动展示。
|
||||
return $this->paymentPluginRepository->enabledList([
|
||||
'code',
|
||||
'name',
|
||||
@@ -147,6 +171,9 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 按插件编码查询插件。
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @return PaymentPlugin|null 插件模型
|
||||
*/
|
||||
public function findByCode(string $code): ?PaymentPlugin
|
||||
{
|
||||
@@ -156,7 +183,9 @@ class PaymentPluginService extends BaseService
|
||||
/**
|
||||
* 查询插件配置结构。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* @param string $code 插件编码
|
||||
* @return array{config_schema: array<int, mixed>} 配置结构
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function getSchema(string $code): array
|
||||
{
|
||||
@@ -174,10 +203,15 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 更新支付插件。
|
||||
*
|
||||
* @param string $code 插件编码
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPlugin|null 更新后的插件模型
|
||||
*/
|
||||
public function update(string $code, array $data): ?PaymentPlugin
|
||||
{
|
||||
$payload = [];
|
||||
// 插件元信息由文件同步维护,后台这里只允许调整状态和备注,避免人工改动覆盖同步结果。
|
||||
if (array_key_exists('status', $data)) {
|
||||
$payload['status'] = (int) $data['status'];
|
||||
}
|
||||
@@ -199,6 +233,8 @@ class PaymentPluginService extends BaseService
|
||||
|
||||
/**
|
||||
* 从插件目录刷新并同步支付插件定义。
|
||||
*
|
||||
* @return array{count: int, plugins: array<int, PaymentPlugin>} 同步结果
|
||||
*/
|
||||
public function refreshFromClasses(): array
|
||||
{
|
||||
|
||||
@@ -12,26 +12,40 @@ use app\repository\payment\config\PaymentPluginRepository;
|
||||
/**
|
||||
* 支付插件同步服务。
|
||||
*
|
||||
* 负责扫描插件目录、实例化插件类并同步数据库定义。
|
||||
* 负责扫描插件目录、实例化插件类并同步数据库中的插件定义。
|
||||
*
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
*/
|
||||
class PaymentPluginSyncService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 从插件目录刷新并同步支付插件定义。
|
||||
*
|
||||
* @return array{count: int, plugins: array<int, PaymentPlugin>} 同步结果
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function refreshFromClasses(): array
|
||||
{
|
||||
$directory = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'payment';
|
||||
// 扫描固定目录下的插件类文件,每个文件都可能对应一个可同步的插件定义。
|
||||
$files = glob($directory . DIRECTORY_SEPARATOR . '*.php') ?: [];
|
||||
|
||||
// 以插件 code 为键去重,避免同一个插件被多个类重复注册。
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
$shortClassName = pathinfo($file, PATHINFO_FILENAME);
|
||||
$className = 'app\\common\\payment\\' . $shortClassName;
|
||||
// 先实例化插件,再从实例上读取元信息作为同步源。
|
||||
$plugin = $this->instantiatePlugin($className);
|
||||
if (!$plugin) {
|
||||
continue;
|
||||
@@ -62,6 +76,7 @@ class PaymentPluginSyncService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
// 先固定排序,再和数据库现有记录逐条对比,保证同步过程稳定可复现。
|
||||
ksort($rows);
|
||||
|
||||
$existing = $this->paymentPluginRepository->query()
|
||||
@@ -79,6 +94,7 @@ class PaymentPluginSyncService extends BaseService
|
||||
]);
|
||||
|
||||
if ($current) {
|
||||
// 已存在的插件只覆盖元信息,不改动人工维护的状态和备注。
|
||||
$current->fill($payload);
|
||||
$current->save();
|
||||
unset($existing[$code]);
|
||||
@@ -88,6 +104,7 @@ class PaymentPluginSyncService extends BaseService
|
||||
$this->paymentPluginRepository->create($payload);
|
||||
}
|
||||
|
||||
// 数据库里还残留、但文件中已不存在的插件,直接删除避免配置漂移。
|
||||
foreach ($existing as $plugin) {
|
||||
$plugin->delete();
|
||||
}
|
||||
@@ -105,6 +122,9 @@ class PaymentPluginSyncService extends BaseService
|
||||
|
||||
/**
|
||||
* 实例化插件类并过滤非支付插件类。
|
||||
*
|
||||
* @param string $className 插件类名
|
||||
* @return null|(PaymentInterface&PayPluginInterface) 支付插件实例
|
||||
*/
|
||||
private function instantiatePlugin(string $className): null|(PaymentInterface & PayPluginInterface)
|
||||
{
|
||||
@@ -120,3 +140,5 @@ class PaymentPluginSyncService extends BaseService
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,23 @@ use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 商户分组路由绑定服务。
|
||||
*
|
||||
* 负责把商户分组和支付方式绑定到指定轮询组,并校验轮询组与支付方式的匹配关系。
|
||||
*
|
||||
* @property PaymentPollGroupBindRepository $paymentPollGroupBindRepository 支付轮询分组绑定仓库
|
||||
* @property MerchantGroupRepository $merchantGroupRepository 商户分组仓库
|
||||
* @property PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
*/
|
||||
class PaymentPollGroupBindService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupBindRepository $paymentPollGroupBindRepository 支付轮询分组绑定仓库
|
||||
* @param MerchantGroupRepository $merchantGroupRepository 商户分组仓库
|
||||
* @param PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupBindRepository $paymentPollGroupBindRepository,
|
||||
protected MerchantGroupRepository $merchantGroupRepository,
|
||||
@@ -23,6 +37,11 @@ class PaymentPollGroupBindService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询商户分组路由绑定。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
@@ -76,11 +95,24 @@ class PaymentPollGroupBindService extends BaseService
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询路由绑定。
|
||||
*
|
||||
* @param int $id 绑定ID
|
||||
* @return PaymentPollGroupBind|null 绑定模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPollGroupBind
|
||||
{
|
||||
return $this->paymentPollGroupBindRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由绑定。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroupBind 新增后的绑定模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function create(array $data): PaymentPollGroupBind
|
||||
{
|
||||
$this->assertBindingUnique((int) $data['merchant_group_id'], (int) $data['pay_type_id']);
|
||||
@@ -89,6 +121,14 @@ class PaymentPollGroupBindService extends BaseService
|
||||
return $this->paymentPollGroupBindRepository->create($this->normalizePayload($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新路由绑定。
|
||||
*
|
||||
* @param int $id 绑定ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroupBind|null 更新后的绑定模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPollGroupBind
|
||||
{
|
||||
$current = $this->paymentPollGroupBindRepository->find($id);
|
||||
@@ -96,6 +136,7 @@ class PaymentPollGroupBindService extends BaseService
|
||||
return null;
|
||||
}
|
||||
|
||||
// 更新时要以现有记录为底,把未传的分组和支付方式补齐后再做唯一性校验。
|
||||
$merchantGroupId = (int) ($data['merchant_group_id'] ?? $current->merchant_group_id);
|
||||
$payTypeId = (int) ($data['pay_type_id'] ?? $current->pay_type_id);
|
||||
$this->assertBindingUnique($merchantGroupId, $payTypeId, $id);
|
||||
@@ -108,11 +149,23 @@ class PaymentPollGroupBindService extends BaseService
|
||||
return $this->paymentPollGroupBindRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除路由绑定。
|
||||
*
|
||||
* @param int $id 绑定ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupBindRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路由绑定写入数据。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return array<string, mixed> 标准化后的数据
|
||||
*/
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
return [
|
||||
@@ -124,6 +177,15 @@ class PaymentPollGroupBindService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户分组与支付方式的绑定唯一性。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $payTypeId 支付方式ID
|
||||
* @param int $ignoreId 排除的绑定ID
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertBindingUnique(int $merchantGroupId, int $payTypeId, int $ignoreId = 0): void
|
||||
{
|
||||
$query = $this->paymentPollGroupBindRepository->query()
|
||||
@@ -142,11 +204,19 @@ class PaymentPollGroupBindService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验轮询组与支付方式是否一致。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertPollGroupMatchesPayType(array $data): void
|
||||
{
|
||||
$pollGroupId = (int) ($data['poll_group_id'] ?? 0);
|
||||
$payTypeId = (int) ($data['pay_type_id'] ?? 0);
|
||||
|
||||
// 轮询组和支付方式必须保持一致;轮询组缺失时交给上层必填校验处理。
|
||||
$pollGroup = $this->paymentPollGroupRepository->find($pollGroupId);
|
||||
if (!$pollGroup) {
|
||||
return;
|
||||
@@ -160,3 +230,5 @@ class PaymentPollGroupBindService extends BaseService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,9 +11,23 @@ use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 轮询组通道编排服务。
|
||||
*
|
||||
* 负责维护轮询组内通道的顺序、权重、默认通道以及支付方式一致性。
|
||||
*
|
||||
* @property PaymentPollGroupChannelRepository $paymentPollGroupChannelRepository 支付轮询分组渠道仓库
|
||||
* @property PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
*/
|
||||
class PaymentPollGroupChannelService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupChannelRepository $paymentPollGroupChannelRepository 支付轮询分组渠道仓库
|
||||
* @param PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupChannelRepository $paymentPollGroupChannelRepository,
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository,
|
||||
@@ -23,6 +37,11 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询轮询组通道编排。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
@@ -79,11 +98,24 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询轮询组通道编排。
|
||||
*
|
||||
* @param int $id 编排ID
|
||||
* @return PaymentPollGroupChannel|null 编排模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPollGroupChannel
|
||||
{
|
||||
return $this->paymentPollGroupChannelRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建轮询组通道编排。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroupChannel 新增后的编排模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function create(array $data): PaymentPollGroupChannel
|
||||
{
|
||||
$this->assertPairUnique((int) $data['poll_group_id'], (int) $data['channel_id']);
|
||||
@@ -91,6 +123,7 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
$payload = $this->normalizePayload($data);
|
||||
|
||||
return $this->transaction(function () use ($payload) {
|
||||
// 一个轮询组只能有一个默认通道,新增默认项前先清理掉其他默认标记。
|
||||
if ((int) ($payload['is_default'] ?? 0) === 1) {
|
||||
$this->paymentPollGroupChannelRepository->clearDefaultExcept((int) $payload['poll_group_id']);
|
||||
}
|
||||
@@ -99,6 +132,14 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新轮询组通道编排。
|
||||
*
|
||||
* @param int $id 编排ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroupChannel|null 更新后的编排模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPollGroupChannel
|
||||
{
|
||||
$current = $this->paymentPollGroupChannelRepository->find($id);
|
||||
@@ -114,6 +155,7 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
$payload = $this->normalizePayload($data);
|
||||
|
||||
return $this->transaction(function () use ($id, $payload) {
|
||||
// 更新成默认通道时,同样先把本轮询组的其他默认项清空。
|
||||
if ((int) ($payload['is_default'] ?? 0) === 1) {
|
||||
$this->paymentPollGroupChannelRepository->clearDefaultExcept((int) $payload['poll_group_id'], $id);
|
||||
}
|
||||
@@ -126,17 +168,30 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除轮询组通道编排。
|
||||
*
|
||||
* @param int $id 编排ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupChannelRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化编排写入数据。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return array<string, mixed> 标准化后的数据
|
||||
*/
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
return [
|
||||
'poll_group_id' => (int) $data['poll_group_id'],
|
||||
'channel_id' => (int) $data['channel_id'],
|
||||
'sort_no' => (int) ($data['sort_no'] ?? 0),
|
||||
// 权重至少为 1,避免轮询时出现 0 权重通道导致随机分配失真。
|
||||
'weight' => max(1, (int) ($data['weight'] ?? 100)),
|
||||
'is_default' => (int) ($data['is_default'] ?? 0),
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
@@ -144,6 +199,15 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验轮询组与通道的组合唯一性。
|
||||
*
|
||||
* @param int $pollGroupId 轮询组ID
|
||||
* @param int $channelId 通道ID
|
||||
* @param int $ignoreId 排除的编排ID
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertPairUnique(int $pollGroupId, int $channelId, int $ignoreId = 0): void
|
||||
{
|
||||
$query = $this->paymentPollGroupChannelRepository->query()
|
||||
@@ -162,6 +226,13 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验通道支付方式与轮询组支付方式一致。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertChannelMatchesPollGroup(array $data): void
|
||||
{
|
||||
$pollGroupId = (int) ($data['poll_group_id'] ?? 0);
|
||||
@@ -174,6 +245,7 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
return;
|
||||
}
|
||||
|
||||
// 轮询组和通道必须属于同一支付方式,否则排序再正确也会在运行时被路由规则拦下。
|
||||
if ((int) $pollGroup->pay_type_id !== (int) $channel->pay_type_id) {
|
||||
throw new PaymentException('轮询组与支付通道的支付方式不一致', 40231, [
|
||||
'poll_group_id' => $pollGroupId,
|
||||
@@ -182,3 +254,6 @@ class PaymentPollGroupChannelService extends BaseService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,22 +9,47 @@ use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 支付轮询组命令服务。
|
||||
*
|
||||
* @property PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
*/
|
||||
class PaymentPollGroupCommandService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付轮询组。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroup 新增后的轮询组模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function create(array $data): PaymentPollGroup
|
||||
{
|
||||
// 新增前先确保轮询组名称不冲突,避免后台同时出现两个同名配置。
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''));
|
||||
return $this->paymentPollGroupRepository->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroup|null 更新后的轮询组模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPollGroup
|
||||
{
|
||||
// 更新时同样要排除自身后再做唯一性判断,防止修改回原名时误报冲突。
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''), $id);
|
||||
if (!$this->paymentPollGroupRepository->updateById($id, $data)) {
|
||||
return null;
|
||||
@@ -33,11 +58,25 @@ class PaymentPollGroupCommandService extends BaseService
|
||||
return $this->paymentPollGroupRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验轮询组名称唯一。
|
||||
*
|
||||
* @param string $groupName 轮询组名称
|
||||
* @param int $ignoreId 排除的轮询组ID
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void
|
||||
{
|
||||
$groupName = trim($groupName);
|
||||
@@ -53,3 +92,6 @@ class PaymentPollGroupCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,21 +7,40 @@ use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 支付轮询组查询服务。
|
||||
* 支付轮询组查询与选项拼装服务。
|
||||
*
|
||||
* 负责轮询组列表、详情和启用选项输出。
|
||||
*
|
||||
* @property PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
*/
|
||||
class PaymentPollGroupQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupRepository $paymentPollGroupRepository 支付轮询分组仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付轮询组。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentPollGroupRepository->query();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 轮询组列表只按组名搜索,避免把支付方式或路由模式混进模糊搜索结果里。
|
||||
$query->where('group_name', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
@@ -47,12 +66,19 @@ class PaymentPollGroupQueryService extends BaseService
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用支付轮询组选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @return array<int, array{label: string, value: int, pay_type_id: int, route_mode: int}> 启用轮询组选项
|
||||
*/
|
||||
public function enabledOptions(array $filters = []): array
|
||||
{
|
||||
$query = $this->paymentPollGroupRepository->query()
|
||||
->where('status', 1);
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
// 轮询组选项通常要跟支付方式联动,因此启用项会先按支付方式收窄。
|
||||
$query->where('pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
@@ -72,8 +98,17 @@ class PaymentPollGroupQueryService extends BaseService
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @return PaymentPollGroup|null 轮询组模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPollGroup
|
||||
{
|
||||
return $this->paymentPollGroupRepository->find($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,43 +6,94 @@ use app\common\base\BaseService;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
|
||||
/**
|
||||
* 支付轮询组门面服务。
|
||||
* 支付轮询组服务。
|
||||
*
|
||||
* @property PaymentPollGroupQueryService $queryService 查询服务
|
||||
* @property PaymentPollGroupCommandService $commandService 命令服务
|
||||
*/
|
||||
class PaymentPollGroupService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupQueryService $queryService 查询服务
|
||||
* @param PaymentPollGroupCommandService $commandService 命令服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupQueryService $queryService,
|
||||
protected PaymentPollGroupCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付轮询组。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用支付轮询组选项。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @return array<int, array<string, mixed>> 启用轮询组选项
|
||||
*/
|
||||
public function enabledOptions(array $filters = []): array
|
||||
{
|
||||
return $this->queryService->enabledOptions($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @return PaymentPollGroup|null 轮询组模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPollGroup
|
||||
{
|
||||
return $this->queryService->findById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增支付轮询组。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroup 新增后的轮询组模型
|
||||
*/
|
||||
public function create(array $data): PaymentPollGroup
|
||||
{
|
||||
return $this->commandService->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentPollGroup|null 更新后的轮询组模型
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPollGroup
|
||||
{
|
||||
return $this->commandService->update($id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付轮询组。
|
||||
*
|
||||
* @param int $id 轮询组ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@ use app\repository\payment\config\PaymentTypeRepository;
|
||||
* 支付方式字典服务。
|
||||
*
|
||||
* 负责支付方式的基础列表查询、新增、修改、删除和下拉选项输出。
|
||||
*
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
*/
|
||||
class PaymentTypeService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入支付方式仓库。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
@@ -24,6 +29,11 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询支付方式。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
@@ -59,6 +69,8 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询启用中的支付方式选项。
|
||||
*
|
||||
* @return array<int, array{label: string, value: int, code: string}> 启用支付方式选项
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
@@ -76,6 +88,10 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 解析启用中的支付方式,优先按编码匹配,未命中则取首个启用项。
|
||||
*
|
||||
* @param string $code 支付方式编码
|
||||
* @return PaymentType 支付方式模型
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function resolveEnabledType(string $code = ''): PaymentType
|
||||
{
|
||||
@@ -87,6 +103,7 @@ class PaymentTypeService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
// 没有传编码或编码不可用时,直接回退到系统当前首个启用支付方式。
|
||||
$paymentType = $this->paymentTypeRepository->enabledList()->first();
|
||||
if (!$paymentType) {
|
||||
throw new ValidationException('未配置可用支付方式');
|
||||
@@ -97,6 +114,9 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 根据支付方式编码查询字典。
|
||||
*
|
||||
* @param string $code 支付方式编码
|
||||
* @return PaymentType|null 支付方式模型
|
||||
*/
|
||||
public function findByCode(string $code): ?PaymentType
|
||||
{
|
||||
@@ -105,6 +125,9 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 解析支付方式编码。
|
||||
*
|
||||
* @param int $id 支付方式ID
|
||||
* @return string 支付方式编码
|
||||
*/
|
||||
public function resolveCodeById(int $id): string
|
||||
{
|
||||
@@ -114,6 +137,9 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付方式。
|
||||
*
|
||||
* @param int $id 支付方式ID
|
||||
* @return PaymentType|null 支付方式模型
|
||||
*/
|
||||
public function findById(int $id): ?PaymentType
|
||||
{
|
||||
@@ -122,6 +148,9 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 新增支付方式。
|
||||
*
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentType 新增后的支付方式模型
|
||||
*/
|
||||
public function create(array $data): PaymentType
|
||||
{
|
||||
@@ -130,6 +159,10 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 更新支付方式。
|
||||
*
|
||||
* @param int $id 支付方式ID
|
||||
* @param array $data 写入数据
|
||||
* @return PaymentType|null 更新后的支付方式模型
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentType
|
||||
{
|
||||
@@ -142,9 +175,14 @@ class PaymentTypeService extends BaseService
|
||||
|
||||
/**
|
||||
* 删除支付方式。
|
||||
*
|
||||
* @param int $id 支付方式ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentTypeRepository->deleteById($id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,11 +23,27 @@ use app\service\payment\runtime\PaymentRouteService;
|
||||
* 支付单发起服务。
|
||||
*
|
||||
* 负责支付单预创建、通道路由选择、第三方装单和首轮状态落库。
|
||||
*
|
||||
* @property MerchantService $merchantService 商户服务
|
||||
* @property PaymentRouteService $paymentRouteService 支付路由服务
|
||||
* @property MerchantAccountService $merchantAccountService 商户账户服务
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @property PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务
|
||||
*/
|
||||
class PayOrderAttemptService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantService $merchantService 商户服务
|
||||
* @param PaymentRouteService $paymentRouteService 支付路由服务
|
||||
* @param MerchantAccountService $merchantAccountService 商户账户服务
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @param PayOrderChannelDispatchService $payOrderChannelDispatchService 支付单渠道派发服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantService $merchantService,
|
||||
@@ -45,8 +61,11 @@ class PayOrderAttemptService extends BaseService
|
||||
*
|
||||
* 该方法会完成商户、支付方式、路由、通道限额、串行尝试和自有通道手续费预占的完整预检查。
|
||||
*
|
||||
* @param array $input 支付请求参数
|
||||
* @return array{merchant:mixed,biz_order:mixed,pay_order:mixed,route:array,payment_result:array,pay_params:array}
|
||||
* @param array $input 支付预创建参数
|
||||
* @return array 发起结果
|
||||
* @throws ValidationException
|
||||
* @throws BusinessStateException
|
||||
* @throws ConflictException
|
||||
*/
|
||||
public function preparePayAttempt(array $input): array
|
||||
{
|
||||
@@ -59,6 +78,7 @@ class PayOrderAttemptService extends BaseService
|
||||
throw new ValidationException('支付入参不完整');
|
||||
}
|
||||
|
||||
// 先校验商户和支付方式是否可用,避免进入事务后才发现前置条件不满足。
|
||||
$merchant = $this->merchantService->ensureMerchantEnabled($merchantId);
|
||||
$merchantGroupId = (int) $merchant->group_id;
|
||||
if ($merchantGroupId <= 0) {
|
||||
@@ -72,6 +92,7 @@ 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 */
|
||||
@@ -93,10 +114,12 @@ class PayOrderAttemptService extends BaseService
|
||||
$payNo,
|
||||
$channelRequestNo
|
||||
) {
|
||||
// 在事务中完成业务单和支付单的原子创建,保证幂等与状态一致。
|
||||
$existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
$bizTraceNo = '';
|
||||
|
||||
if ($existingBizOrder) {
|
||||
// 同一商户订单号只能复用原业务单,且金额必须完全一致。
|
||||
if ((int) $existingBizOrder->order_amount !== $payAmount) {
|
||||
throw new ValidationException('同一商户订单号金额不一致', [
|
||||
'merchant_id' => $merchantId,
|
||||
@@ -128,6 +151,7 @@ class PayOrderAttemptService extends BaseService
|
||||
$bizOrder = $existingBizOrder;
|
||||
$bizTraceNo = trim((string) ($bizOrder->trace_no ?? ''));
|
||||
if ($bizTraceNo === '') {
|
||||
// 旧单如果没有 trace_no,就补成业务单号,方便后续串起来查。
|
||||
$bizTraceNo = (string) $bizOrder->biz_no;
|
||||
$bizOrder->trace_no = $bizTraceNo;
|
||||
}
|
||||
@@ -155,9 +179,11 @@ class PayOrderAttemptService extends BaseService
|
||||
|
||||
$feeRateBp = (int) $channel->cost_rate_bp;
|
||||
$splitRateBp = (int) $channel->split_rate_bp ?: 10000;
|
||||
// 手续费和分账费率都按快照落库,后续配置变化不会影响这笔单的口径。
|
||||
$feeEstimated = $this->calculateAmountByBp($payAmount, $feeRateBp);
|
||||
|
||||
if ((int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF && $feeEstimated > 0) {
|
||||
// 自有通道先冻结预估手续费,避免后续余额不足。
|
||||
$this->merchantAccountService->freezeAmountInCurrentTransaction(
|
||||
$merchantId,
|
||||
$feeEstimated,
|
||||
@@ -214,6 +240,7 @@ class PayOrderAttemptService extends BaseService
|
||||
$bizOrder->merchant_group_id = $merchantGroupId;
|
||||
$bizOrder->poll_group_id = (int) $route['poll_group']->id;
|
||||
if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') {
|
||||
// 把追踪号回写到业务单上,后续查单和对账能串到同一条链路。
|
||||
$bizOrder->trace_no = $bizTraceNo;
|
||||
}
|
||||
$bizOrder->save();
|
||||
@@ -233,6 +260,7 @@ class PayOrderAttemptService extends BaseService
|
||||
/** @var \app\model\payment\PaymentChannel $channel */
|
||||
$channel = $prepared['route']['selected_channel']['channel'];
|
||||
|
||||
// 支付单落库后立即拉起渠道订单,补全渠道返回的单号和参数快照。
|
||||
$channelDispatchResult = $this->payOrderChannelDispatchService->dispatch($payOrder, $bizOrder, $channel);
|
||||
|
||||
$prepared['pay_order'] = $channelDispatchResult['pay_order'];
|
||||
@@ -244,6 +272,10 @@ class PayOrderAttemptService extends BaseService
|
||||
|
||||
/**
|
||||
* 计算手续费金额。
|
||||
*
|
||||
* @param int $amount 金额(分)
|
||||
* @param int $bp 费率基点,`10000` 表示 100%
|
||||
* @return int 手续费金额(分)
|
||||
*/
|
||||
private function calculateAmountByBp(int $amount, int $bp): int
|
||||
{
|
||||
@@ -251,6 +283,7 @@ class PayOrderAttemptService extends BaseService
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 基点换算统一向下取整,避免手续费计算时出现超扣。
|
||||
return (int) floor($amount * $bp / 10000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,21 @@ use support\Response;
|
||||
* 支付单回调服务。
|
||||
*
|
||||
* 负责渠道回调日志记录、插件回调解析和支付状态分发。
|
||||
*
|
||||
* @property NotifyService $notifyService 通知服务
|
||||
* @property PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务
|
||||
*/
|
||||
class PayOrderCallbackService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param NotifyService $notifyService 通知服务
|
||||
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @param PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @param PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected NotifyService $notifyService,
|
||||
@@ -32,7 +42,11 @@ class PayOrderCallbackService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理渠道回调。
|
||||
* 处理渠道回调载荷并推进支付状态。
|
||||
*
|
||||
* @param array $input 回调载荷
|
||||
* @return PayOrder 支付订单模型
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handleChannelCallback(array $input): PayOrder
|
||||
{
|
||||
@@ -41,6 +55,7 @@ class PayOrderCallbackService extends BaseService
|
||||
throw new \InvalidArgumentException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
// 先落回调日志,后续无论成功还是失败,都可以从统一表里排查。
|
||||
$this->notifyService->recordPayCallback([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) ($input['channel_id'] ?? 0),
|
||||
@@ -52,6 +67,7 @@ class PayOrderCallbackService extends BaseService
|
||||
]);
|
||||
|
||||
$success = (bool) ($input['success'] ?? false);
|
||||
// 回调链路只根据插件/渠道给出的结果收口支付单状态。
|
||||
if ($success) {
|
||||
return $this->payOrderLifecycleService->markPaySuccess($payNo, $input);
|
||||
}
|
||||
@@ -61,9 +77,17 @@ class PayOrderCallbackService extends BaseService
|
||||
|
||||
/**
|
||||
* 按支付单号处理真实第三方回调。
|
||||
*
|
||||
* 该方法先定位支付单,再由插件解析原始请求,最后统一交给生命周期服务推进状态。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param Request $request 请求对象
|
||||
* @return string|Response 插件要求返回的响应内容
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function handlePluginCallback(string $payNo, Request $request): string|Response
|
||||
{
|
||||
// 回调必须能定位到具体支付单,找不到就直接终止。
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
@@ -72,12 +96,16 @@ 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);
|
||||
|
||||
// 将插件返回值归一化为生命周期服务可消费的回调载荷。
|
||||
/** @var array<string, mixed> $callbackPayload */
|
||||
$callbackPayload = [
|
||||
'pay_no' => $payNo,
|
||||
'success' => $success,
|
||||
@@ -97,14 +125,17 @@ class PayOrderCallbackService extends BaseService
|
||||
'notify_status' => $status,
|
||||
],
|
||||
];
|
||||
// 部分渠道会返回实际手续费,补充进回调载荷,便于后续清算和对账。
|
||||
if (isset($result['fee_actual_amount'])) {
|
||||
$callbackPayload['fee_actual_amount'] = (int) $result['fee_actual_amount'];
|
||||
}
|
||||
|
||||
// 回调成功后统一交给生命周期服务落库,避免状态推进分散在不同分支里。
|
||||
$this->handleChannelCallback($callbackPayload);
|
||||
|
||||
return $success ? $plugin->notifySuccess() : $plugin->notifyFail();
|
||||
} catch (PaymentException $e) {
|
||||
// 插件已明确返回业务失败时,记录失败日志并按失败响应收口。
|
||||
$this->notifyService->recordPayCallback([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
@@ -120,6 +151,7 @@ class PayOrderCallbackService extends BaseService
|
||||
|
||||
return $plugin->notifyFail();
|
||||
} catch (\Throwable $e) {
|
||||
// 非业务异常同样记为失败,避免渠道重复推送造成状态抖动。
|
||||
$this->notifyService->recordPayCallback([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
|
||||
@@ -18,11 +18,21 @@ use Throwable;
|
||||
* 支付渠道单据拉起服务。
|
||||
*
|
||||
* 负责调用第三方插件、写回渠道订单号,并在失败时推进支付失败状态。
|
||||
*
|
||||
* @property PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务
|
||||
*/
|
||||
class PayOrderChannelDispatchService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginManager $paymentPluginManager 支付插件管理器
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @param PayOrderLifecycleService $payOrderLifecycleService 支付单生命周期服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
@@ -35,20 +45,28 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
/**
|
||||
* 拉起第三方支付单并回写渠道响应。
|
||||
*
|
||||
* @return array{pay_order:PayOrder,payment_result:array,pay_params:array}
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param BizOrder $bizOrder 业务订单
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @return array 拉起结果
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function dispatch(PayOrder $payOrder, BizOrder $bizOrder, PaymentChannel $channel): array
|
||||
{
|
||||
try {
|
||||
// 先构造支付插件实例,由插件完成具体渠道下单。
|
||||
$plugin = $this->paymentPluginManager->createByChannel($channel, (int) $payOrder->pay_type_id);
|
||||
/** @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';
|
||||
|
||||
// 插件下单参数里同时带业务单号、支付单号和扩展信息,方便渠道侧回调后能反查同一笔单。
|
||||
$channelResult = $plugin->pay([
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
@@ -69,6 +87,7 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
]);
|
||||
|
||||
$payOrder = $this->transactionRetry(function () use ($payOrder, $channelResult) {
|
||||
// 回写渠道订单号和支付参数快照,便于后续查询和回调排障。
|
||||
$latest = $this->payOrderRepository->findForUpdateByPayNo((string) $payOrder->pay_no);
|
||||
if (!$latest) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => (string) $payOrder->pay_no]);
|
||||
@@ -87,6 +106,7 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
return $latest->refresh();
|
||||
});
|
||||
} catch (PaymentException $e) {
|
||||
// 插件层异常统一收口为支付失败,避免订单长时间停留在处理中。
|
||||
$this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [
|
||||
'channel_error_msg' => $e->getMessage(),
|
||||
'channel_error_code' => (string) $e->getCode(),
|
||||
@@ -97,6 +117,7 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
// 非业务异常同样收口为失败态,并保留原始错误信息。
|
||||
$this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [
|
||||
'channel_error_msg' => $e->getMessage(),
|
||||
'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR',
|
||||
@@ -117,6 +138,9 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
|
||||
/**
|
||||
* 归一化支付参数快照,便于后续页面渲染和排障。
|
||||
*
|
||||
* @param array|object|null $payParams 支付参数数组或对象
|
||||
* @return array<string, mixed> 参数快照
|
||||
*/
|
||||
private function normalizePayParamsSnapshot(mixed $payParams): array
|
||||
{
|
||||
@@ -125,6 +149,7 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
}
|
||||
|
||||
if (is_object($payParams) && method_exists($payParams, 'toArray')) {
|
||||
// 有些插件会返回对象,这里统一转成数组,方便后续落库和页面回显。
|
||||
$data = $payParams->toArray();
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
@@ -132,3 +157,8 @@ class PayOrderChannelDispatchService extends BaseService
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,16 @@ use app\service\account\funds\MerchantAccountService;
|
||||
* 支付单手续费处理服务。
|
||||
*
|
||||
* 负责支付成功时的手续费结算,以及终态时的冻结手续费释放。
|
||||
*
|
||||
* @property MerchantAccountService $merchantAccountService 商户账户服务
|
||||
*/
|
||||
class PayOrderFeeService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param MerchantAccountService $merchantAccountService 商户账户服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantAccountService $merchantAccountService
|
||||
@@ -25,6 +30,12 @@ class PayOrderFeeService extends BaseService
|
||||
|
||||
/**
|
||||
* 处理支付成功后的手续费结算。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param int $actualFee actual手续费
|
||||
* @param string $payNo 支付单号
|
||||
* @param string $traceNo 追踪号
|
||||
* @return void
|
||||
*/
|
||||
public function settleSuccessFee(PayOrder $payOrder, int $actualFee, string $payNo, string $traceNo): void
|
||||
{
|
||||
@@ -34,6 +45,7 @@ class PayOrderFeeService extends BaseService
|
||||
|
||||
$estimated = (int) $payOrder->fee_estimated_amount;
|
||||
if ($actualFee > $estimated) {
|
||||
// 实际手续费高于预估值时,先扣掉预冻结部分,再把差额从可用余额里补扣。
|
||||
if ($estimated > 0) {
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
@@ -66,6 +78,7 @@ class PayOrderFeeService extends BaseService
|
||||
}
|
||||
|
||||
if ($actualFee < $estimated) {
|
||||
// 实际手续费低于预估值时,先按实际值扣减冻结金额,再把多冻结部分释放回可用余额。
|
||||
if ($actualFee > 0) {
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
@@ -98,6 +111,7 @@ class PayOrderFeeService extends BaseService
|
||||
}
|
||||
|
||||
if ($actualFee > 0) {
|
||||
// 实际值和预估值一致时,直接把冻结金额一次性扣减掉即可。
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$actualFee,
|
||||
@@ -114,6 +128,12 @@ class PayOrderFeeService extends BaseService
|
||||
|
||||
/**
|
||||
* 释放支付单已冻结的手续费。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param string $payNo 支付单号
|
||||
* @param string $traceNo 追踪号
|
||||
* @param string $remark 备注
|
||||
* @return void
|
||||
*/
|
||||
public function releaseFrozenFeeIfNeeded(PayOrder $payOrder, string $payNo, string $traceNo, string $remark): void
|
||||
{
|
||||
@@ -121,6 +141,7 @@ class PayOrderFeeService extends BaseService
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有真正处于冻结态的手续费才需要释放,已经扣减或已释放的单子直接跳过。
|
||||
if ((int) $payOrder->fee_status !== TradeConstant::FEE_STATUS_FROZEN) {
|
||||
return;
|
||||
}
|
||||
@@ -138,3 +159,7 @@ class PayOrderFeeService extends BaseService
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,11 +16,19 @@ use app\repository\payment\trade\PayOrderRepository;
|
||||
* 支付单生命周期服务。
|
||||
*
|
||||
* 负责支付单状态推进、关闭、超时和手续费处理。
|
||||
*
|
||||
* @property PayOrderFeeService $payOrderFeeService 支付单手续费服务
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
*/
|
||||
class PayOrderLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PayOrderFeeService $payOrderFeeService 支付单手续费服务
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderFeeService $payOrderFeeService,
|
||||
@@ -35,8 +43,8 @@ class PayOrderLifecycleService extends BaseService
|
||||
* 用于支付回调或主动查单成功后的状态推进;自有通道在这里完成手续费正式扣减。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return PayOrder
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPaySuccess(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -51,8 +59,10 @@ class PayOrderLifecycleService extends BaseService
|
||||
* 该方法只处理状态推进和资金动作,不负责外部通道请求。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return PayOrder
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -77,16 +87,19 @@ class PayOrderLifecycleService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 成功态优先使用插件回传的实际手续费,没有则沿用预估值。
|
||||
$actualFee = array_key_exists('fee_actual_amount', $input)
|
||||
? (int) $input['fee_actual_amount']
|
||||
: (int) $payOrder->fee_estimated_amount;
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
|
||||
// 成功后正式结算手续费,避免自有通道只冻结不扣减。
|
||||
$this->payOrderFeeService->settleSuccessFee($payOrder, $actualFee, $payNo, $traceNo);
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_SUCCESS;
|
||||
$payOrder->paid_at = $input['paid_at'] ?? $this->now();
|
||||
$payOrder->fee_actual_amount = $actualFee;
|
||||
// 平台代收和自有通道的手续费、结算状态规则不同,这里统一收口。
|
||||
$payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF
|
||||
? TradeConstant::FEE_STATUS_DEDUCTED
|
||||
: TradeConstant::FEE_STATUS_NONE;
|
||||
@@ -102,6 +115,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
// 业务单状态也要一起收口,保证支付单和业务单一致。
|
||||
$this->syncBizOrderAfterSuccess($payOrder, $traceNo);
|
||||
|
||||
return $payOrder->refresh();
|
||||
@@ -109,6 +123,10 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记支付失败。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPayFailed(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -119,6 +137,12 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付失败。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -144,6 +168,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
// 失败时只释放需要冻结的手续费,避免重复扣减或重复释放。
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付失败释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_FAILED;
|
||||
@@ -159,6 +184,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
// 支付单进入终态后,同步回业务单,避免上游只能依赖支付单判断结果。
|
||||
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_FAILED, 'failed_at');
|
||||
|
||||
return $payOrder->refresh();
|
||||
@@ -166,6 +192,10 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 关闭支付单。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function closePayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -176,6 +206,12 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中关闭支付单。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -201,6 +237,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
// 关闭单据时同样要处理冻结手续费,防止资金一直占用。
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付关闭释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_CLOSED;
|
||||
@@ -217,6 +254,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
$payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
// 关闭态也要同步给业务单,避免后续继续拉起支付。
|
||||
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_CLOSED, 'closed_at');
|
||||
|
||||
return $payOrder->refresh();
|
||||
@@ -224,6 +262,10 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记支付超时。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function timeoutPayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -234,6 +276,12 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付超时。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -259,6 +307,7 @@ class PayOrderLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
// 超时单同样释放冻结手续费,确保后续可以重新发起支付。
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付超时释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_TIMEOUT;
|
||||
@@ -282,6 +331,10 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 同步支付成功后的业务单状态。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param string $traceNo 追踪号
|
||||
* @return void
|
||||
*/
|
||||
private function syncBizOrderAfterSuccess(PayOrder $payOrder, string $traceNo): void
|
||||
{
|
||||
@@ -302,11 +355,19 @@ class PayOrderLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 同步支付终态后的业务单状态。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param string $payNo 支付单号
|
||||
* @param string $traceNo 追踪号
|
||||
* @param int $status 状态
|
||||
* @param string $timestampField 时间字段名
|
||||
* @return void
|
||||
*/
|
||||
private function syncBizOrderAfterTerminalStatus(PayOrder $payOrder, string $payNo, string $traceNo, int $status, string $timestampField): void
|
||||
{
|
||||
$bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no);
|
||||
if (!$bizOrder || (string) $bizOrder->active_pay_no !== $payNo) {
|
||||
// 只有当前生效的支付单才允许回写业务单,避免旧重试单覆盖新单状态。
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,27 @@ use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
|
||||
/**
|
||||
* 支付单查询服务。
|
||||
* 支付单查询与展示拼装服务。
|
||||
*
|
||||
* 只负责支付单列表类查询与展示格式化,不承载状态推进逻辑。
|
||||
* 负责支付单列表、详情和筛选辅助数据的查询,不承载状态推进逻辑。
|
||||
*
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @property PayOrderReportService $payOrderReportService 支付单报表服务
|
||||
*/
|
||||
class PayOrderQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @param PayOrderReportService $payOrderReportService 支付单报表服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
@@ -36,12 +49,13 @@ class PayOrderQueryService extends BaseService
|
||||
* 分页查询支付订单列表。
|
||||
*
|
||||
* 后台和商户后台共用同一套查询逻辑,商户侧会额外限制当前商户 ID。
|
||||
* 返回值会同时带上支付方式选项,方便列表页直接渲染筛选器。
|
||||
*
|
||||
* @param array $filters 查询条件
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{list:array,total:int,page:int,size:int,pay_types:array}
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @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
|
||||
{
|
||||
@@ -122,6 +136,7 @@ class PayOrderQueryService extends BaseService
|
||||
|
||||
$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 . '%')
|
||||
@@ -181,9 +196,13 @@ class PayOrderQueryService extends BaseService
|
||||
/**
|
||||
* 查询支付订单详情。
|
||||
*
|
||||
* 返回支付单、业务单、时间线和资金流水,供管理后台与商户后台共用。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{pay_order:mixed,biz_order:mixed,timeline:array,account_ledgers:mixed}
|
||||
* @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
|
||||
*/
|
||||
public function detail(string $payNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -198,6 +217,7 @@ class PayOrderQueryService extends BaseService
|
||||
}
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) {
|
||||
// 商户后台只允许看自己的单,归属不匹配时直接按不存在处理。
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
@@ -215,6 +235,11 @@ class PayOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载支付相关资金流水。
|
||||
*
|
||||
* 优先按追踪号查询,追踪号为空时回退到业务单号,避免漏掉关联流水。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @return \Illuminate\Support\Collection 支付相关资金流水集合
|
||||
*/
|
||||
private function loadPayLedgers(PayOrder $payOrder)
|
||||
{
|
||||
@@ -224,7 +249,8 @@ class PayOrderQueryService extends BaseService
|
||||
: collect();
|
||||
|
||||
if ($ledgers->isEmpty()) {
|
||||
$ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->pay_no);
|
||||
// 追踪号没有命中时,回到业务单号继续兜底,避免早期单据漏掉资金流水。
|
||||
$ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
|
||||
return $ledgers;
|
||||
@@ -232,6 +258,8 @@ class PayOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 返回启用的支付方式选项,供列表筛选使用。
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}> 支付方式选项
|
||||
*/
|
||||
private function payTypeOptions(): array
|
||||
{
|
||||
|
||||
@@ -11,12 +11,17 @@ use app\model\payment\PayOrder;
|
||||
/**
|
||||
* 支付单结果组装服务。
|
||||
*
|
||||
* 负责支付单列表和详情页的展示字段格式化。
|
||||
* 负责支付单列表、详情页和时间线的展示字段格式化。
|
||||
*/
|
||||
class PayOrderReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 格式化支付订单行,统一输出前端需要的中文字段。
|
||||
*
|
||||
* 该方法只做展示层字段补齐,不修改原始业务语义。
|
||||
*
|
||||
* @param array<string, mixed> $row 原始查询行
|
||||
* @return array<string, mixed> 格式化后的支付单行
|
||||
*/
|
||||
public function formatPayOrderRow(array $row): array
|
||||
{
|
||||
@@ -58,11 +63,17 @@ class PayOrderReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 构造支付时间线。
|
||||
*
|
||||
* 按创建、成功、关闭、失败、超时的顺序输出,方便前端直接渲染状态流转。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @return array<int, array<string, mixed>> 支付时间线
|
||||
*/
|
||||
public function buildPayTimeline(PayOrder $payOrder): array
|
||||
{
|
||||
$extJson = (array) ($payOrder->ext_json ?? []);
|
||||
|
||||
// 只保留真实发生过的节点,未触发的状态直接过滤掉,避免时间线里出现空占位。
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'status' => 'created',
|
||||
@@ -75,11 +86,13 @@ class PayOrderReportService extends BaseService
|
||||
$payOrder->closed_at ? [
|
||||
'status' => 'closed',
|
||||
'at' => $this->formatDateTime($payOrder->closed_at, '—'),
|
||||
// 关闭原因优先取扩展信息里的记录,便于展示人工关单或自动关单原因。
|
||||
'reason' => (string) ($extJson['close_reason'] ?? ''),
|
||||
] : null,
|
||||
$payOrder->failed_at ? [
|
||||
'status' => 'failed',
|
||||
'at' => $this->formatDateTime($payOrder->failed_at, '—'),
|
||||
// 失败原因先看渠道返回,再回落到扩展信息里保存的统一原因字段。
|
||||
'reason' => (string) ($payOrder->channel_error_msg ?: ($extJson['reason'] ?? '')),
|
||||
] : null,
|
||||
$payOrder->timeout_at ? [
|
||||
@@ -90,3 +103,5 @@ class PayOrderReportService extends BaseService
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,14 +8,22 @@ use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付单门面服务。
|
||||
* 支付单服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给查询、发起、生命周期和回调四个子服务。
|
||||
* @property PayOrderQueryService $queryService 查询服务
|
||||
* @property PayOrderAttemptService $attemptService 发起服务
|
||||
* @property PayOrderLifecycleService $lifecycleService 生命周期服务
|
||||
* @property PayOrderCallbackService $callbackService 回调服务
|
||||
*/
|
||||
class PayOrderService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PayOrderQueryService $queryService 查询服务
|
||||
* @param PayOrderAttemptService $attemptService 发起服务
|
||||
* @param PayOrderLifecycleService $lifecycleService 生命周期服务
|
||||
* @param PayOrderCallbackService $callbackService 回调服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderQueryService $queryService,
|
||||
@@ -27,6 +35,12 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询支付订单列表。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array 分页数据
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -35,6 +49,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询支付订单详情。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array 订单详情
|
||||
*/
|
||||
public function detail(string $payNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -43,6 +61,9 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 预创建支付尝试。
|
||||
*
|
||||
* @param array $input 下单数据
|
||||
* @return array 发起结果
|
||||
*/
|
||||
public function preparePayAttempt(array $input): array
|
||||
{
|
||||
@@ -51,6 +72,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记支付成功。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPaySuccess(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -59,6 +84,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付成功。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -67,6 +96,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记支付失败。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPayFailed(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -75,6 +108,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付失败。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -83,6 +120,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 关闭支付单。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function closePayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -91,6 +132,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中关闭支付单。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -99,6 +144,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记支付超时。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function timeoutPayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -107,6 +156,10 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付超时。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 状态数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
@@ -115,6 +168,9 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 处理渠道回调。
|
||||
*
|
||||
* @param array $input 回调数据
|
||||
* @return PayOrder 支付订单模型
|
||||
*/
|
||||
public function handleChannelCallback(array $input): PayOrder
|
||||
{
|
||||
@@ -123,9 +179,16 @@ class PayOrderService extends BaseService
|
||||
|
||||
/**
|
||||
* 按支付单号处理真实第三方回调。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param Request $request 请求对象
|
||||
* @return string|Response 字符串或响应对象
|
||||
*/
|
||||
public function handlePluginCallback(string $payNo, Request $request): string|Response
|
||||
{
|
||||
return $this->callbackService->handlePluginCallback($payNo, $request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,18 @@ use app\repository\payment\trade\RefundOrderRepository;
|
||||
* 退款单创建服务。
|
||||
*
|
||||
* 负责退款单创建和幂等校验,不承载状态推进逻辑。
|
||||
*
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
*/
|
||||
class RefundCreationService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
@@ -34,8 +41,12 @@ class RefundCreationService extends BaseService
|
||||
*
|
||||
* 当前仅支持整单全额退款,且同一支付单只能创建一张退款单。
|
||||
*
|
||||
* @param array $input 退款请求参数
|
||||
* @return RefundOrder
|
||||
* @param array $input 退款参数
|
||||
* @return RefundOrder 退款单记录
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
* @throws ConflictException
|
||||
*/
|
||||
public function createRefund(array $input): RefundOrder
|
||||
{
|
||||
@@ -44,11 +55,14 @@ class RefundCreationService extends BaseService
|
||||
throw new ValidationException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
// 退款必须先锁定原支付单,确保状态和金额都满足退款前置条件。
|
||||
/** @var \app\model\payment\PayOrder|null $payOrder */
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
// 只有已支付订单才允许发起退款,其他状态直接拒绝。
|
||||
if ((int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
throw new BusinessStateException('订单状态不允许退款', [
|
||||
'pay_no' => $payNo,
|
||||
@@ -64,8 +78,11 @@ class RefundCreationService extends BaseService
|
||||
throw new BusinessStateException('当前仅支持整单全额退款');
|
||||
}
|
||||
|
||||
// 业务系统若传了商户退款单号,就优先按商户幂等键查重。
|
||||
$merchantRefundNo = trim((string) ($input['merchant_refund_no'] ?? ''));
|
||||
if ($merchantRefundNo !== '') {
|
||||
// 商户退款单号是第一层幂等键,优先用它判断是否重复提交。
|
||||
/** @var RefundOrder|null $existingByMerchantNo */
|
||||
$existingByMerchantNo = $this->refundOrderRepository->findByMerchantRefundNo((int) $payOrder->merchant_id, $merchantRefundNo);
|
||||
if ($existingByMerchantNo) {
|
||||
if ((string) $existingByMerchantNo->pay_no !== $payNo || (int) $existingByMerchantNo->refund_amount !== $refundAmount) {
|
||||
@@ -80,7 +97,10 @@ class RefundCreationService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
if ($existingByPayNo = $this->refundOrderRepository->findByPayNo($payNo)) {
|
||||
// 没有商户退款单号时,用支付单号兜底,避免同一支付单重复创建退款单。
|
||||
/** @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]);
|
||||
}
|
||||
@@ -90,6 +110,12 @@ class RefundCreationService extends BaseService
|
||||
|
||||
$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;
|
||||
// 代收场景下,退款需要把实际手续费作为反向金额记录下来,后续成功态才能正确冲正余额。
|
||||
return $this->refundOrderRepository->create([
|
||||
'refund_no' => $this->generateNo('RFD'),
|
||||
'merchant_id' => (int) $payOrder->merchant_id,
|
||||
@@ -100,7 +126,7 @@ class RefundCreationService extends BaseService
|
||||
'merchant_refund_no' => $merchantRefundNo !== '' ? $merchantRefundNo : $this->generateNo('MRF'),
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
'refund_amount' => $refundAmount,
|
||||
'fee_reverse_amount' => (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT ? (int) $payOrder->fee_actual_amount : 0,
|
||||
'fee_reverse_amount' => $feeReverseAmount,
|
||||
'status' => TradeConstant::REFUND_STATUS_CREATED,
|
||||
'channel_request_no' => $this->generateNo('RQR'),
|
||||
'reason' => (string) ($input['reason'] ?? ''),
|
||||
|
||||
@@ -17,11 +17,22 @@ use app\service\account\funds\MerchantAccountService;
|
||||
* 退款单生命周期服务。
|
||||
*
|
||||
* 负责退款单创建、处理中、成功、失败和重试等状态推进。
|
||||
*
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @property MerchantAccountService $merchantAccountService 商户账户服务
|
||||
*/
|
||||
class RefundLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @param MerchantAccountService $merchantAccountService 商户账户服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
@@ -33,6 +44,12 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记退款处理中。
|
||||
*
|
||||
* 由渠道受理后推进到处理中态,幂等地处理重复请求。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundProcessing(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -43,6 +60,12 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 退款重试。
|
||||
*
|
||||
* 仅允许失败态退款单重新推进到处理中。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function retryRefund(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -53,6 +76,13 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款处理中或重试。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @param bool $isRetry 是否来自重试流程
|
||||
* @return RefundOrder 退款单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder
|
||||
{
|
||||
@@ -77,6 +107,7 @@ class RefundLifecycleService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 退款失败后再重试时,只有失败态才允许重新推进到处理中。
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED && !$isRetry) {
|
||||
return $refundOrder;
|
||||
}
|
||||
@@ -92,6 +123,7 @@ class RefundLifecycleService extends BaseService
|
||||
}
|
||||
$refundOrder->last_error = (string) ($input['last_error'] ?? $refundOrder->last_error ?? '');
|
||||
if ($isRetry) {
|
||||
// 重试时生成新的渠道请求号,避免和上一轮失败请求混在一起。
|
||||
$refundOrder->retry_count = (int) $refundOrder->retry_count + 1;
|
||||
$refundOrder->channel_request_no = $this->generateNo('RQR');
|
||||
}
|
||||
@@ -99,6 +131,7 @@ class RefundLifecycleService extends BaseService
|
||||
$extJson = (array) $refundOrder->ext_json;
|
||||
$reason = trim((string) ($input['reason'] ?? ''));
|
||||
if ($reason !== '') {
|
||||
// 把处理/重试原因单独保留到扩展字段里,便于后台排查。
|
||||
$extJson[$isRetry ? 'retry_reason' : 'processing_reason'] = $reason;
|
||||
}
|
||||
$refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
|
||||
@@ -113,8 +146,8 @@ class RefundLifecycleService extends BaseService
|
||||
* 成功后会推进退款单状态,并在平台代收场景下做余额冲减或结算逆向处理。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return RefundOrder
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -127,8 +160,10 @@ class RefundLifecycleService extends BaseService
|
||||
* 在当前事务中标记退款成功。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return RefundOrder
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -150,6 +185,7 @@ class RefundLifecycleService extends BaseService
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
// 先锁定原支付单,避免退款推进时原单状态被并发修改。
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $refundOrder->pay_no);
|
||||
if (!$payOrder || (int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
throw new BusinessStateException('原支付单状态不允许退款', [
|
||||
@@ -160,6 +196,7 @@ 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(
|
||||
@@ -175,10 +212,12 @@ class RefundLifecycleService extends BaseService
|
||||
);
|
||||
}
|
||||
|
||||
// 已结算的代收单被退款后,状态要回写成 reversed,表示结算已被抵消。
|
||||
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_REVERSED;
|
||||
$payOrder->save();
|
||||
}
|
||||
|
||||
// 退款成功后,退款单和业务单都要同步收口到成功态。
|
||||
$refundOrder->status = TradeConstant::REFUND_STATUS_SUCCESS;
|
||||
$refundOrder->succeeded_at = $input['succeeded_at'] ?? $this->now();
|
||||
$refundOrder->channel_refund_no = (string) ($input['channel_refund_no'] ?? $refundOrder->channel_refund_no ?? '');
|
||||
@@ -188,6 +227,7 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
$bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $refundOrder->biz_no);
|
||||
if ($bizOrder) {
|
||||
// 业务单的退款金额直接收口到原支付金额,避免后续展示和统计再做推导。
|
||||
$bizOrder->refund_amount = (int) $bizOrder->order_amount;
|
||||
if (empty($bizOrder->trace_no)) {
|
||||
$bizOrder->trace_no = $traceNo;
|
||||
@@ -200,6 +240,10 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 退款失败。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundFailed(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -210,6 +254,12 @@ class RefundLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款失败。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -234,6 +284,7 @@ class RefundLifecycleService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 失败状态只更新失败信息,不再改动原支付单和业务单。
|
||||
$refundOrder->status = TradeConstant::REFUND_STATUS_FAILED;
|
||||
$refundOrder->failed_at = $input['failed_at'] ?? $this->now();
|
||||
$refundOrder->channel_refund_no = (string) ($input['channel_refund_no'] ?? $refundOrder->channel_refund_no ?? '');
|
||||
@@ -241,6 +292,7 @@ class RefundLifecycleService extends BaseService
|
||||
$extJson = (array) $refundOrder->ext_json;
|
||||
$reason = trim((string) ($input['reason'] ?? ''));
|
||||
if ($reason !== '') {
|
||||
// 失败原因也放进扩展字段,方便后台对比渠道返回和内部处理结果。
|
||||
$extJson['fail_reason'] = $reason;
|
||||
}
|
||||
$refundOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
|
||||
|
||||
@@ -10,14 +10,25 @@ use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
|
||||
/**
|
||||
* 退款单查询服务。
|
||||
* 退款单查询与展示拼装服务。
|
||||
*
|
||||
* 只负责退款列表、详情和数据查询,不承载退款状态推进逻辑。
|
||||
* 负责退款列表、详情和展示辅助数据查询,不承载退款状态推进逻辑。
|
||||
*
|
||||
* @property RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @property RefundReportService $refundReportService 退款报表服务
|
||||
*/
|
||||
class RefundQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @param RefundReportService $refundReportService 退款报表服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
@@ -30,11 +41,13 @@ class RefundQueryService extends BaseService
|
||||
/**
|
||||
* 分页查询退款订单列表。
|
||||
*
|
||||
* @param array $filters 查询条件
|
||||
* 返回列表、总数、分页信息和支付方式选项,供后台和商户后台直接复用。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{list:array,total:int,page:int,size:int,pay_types:array}
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @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
|
||||
{
|
||||
@@ -42,6 +55,7 @@ class RefundQueryService extends BaseService
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时命中退款单、支付单、业务单、商户和通道,方便后台按任一线索快速定位。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('ro.refund_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.pay_no', 'like', '%' . $keyword . '%')
|
||||
@@ -83,6 +97,7 @@ class RefundQueryService extends BaseService
|
||||
->orderByDesc('ro.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
// 列表页需要直接显示文案和金额格式,所以在查询层统一做一次格式化。
|
||||
$list = [];
|
||||
foreach ($paginator->items() as $item) {
|
||||
$list[] = $this->refundReportService->formatRefundOrderRow((array) $item);
|
||||
@@ -100,9 +115,13 @@ class RefundQueryService extends BaseService
|
||||
/**
|
||||
* 查询退款订单详情。
|
||||
*
|
||||
* 返回退款单、时间线和资金流水,供列表钻取和详情页展示。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{refund_order:array,timeline:array,account_ledgers:array}
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array{refund_order: array<string, mixed>, timeline: array<int, array<string, mixed>>, account_ledgers: array<int, array<string, mixed>>} 退款详情结构
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function detail(string $refundNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -118,6 +137,7 @@ class RefundQueryService extends BaseService
|
||||
}
|
||||
|
||||
$refundOrder = $this->refundReportService->formatRefundOrderRow((array) $row);
|
||||
// 详情页把原始行再转成展示数组,便于前端直接渲染各类状态和金额字段。
|
||||
$timeline = $this->refundReportService->buildRefundTimeline($row);
|
||||
$accountLedgers = $this->loadRefundLedgers($row);
|
||||
|
||||
@@ -130,6 +150,11 @@ class RefundQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 按退款单号查询退款单,可按商户限制。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return \app\model\payment\RefundOrder|null 退款单模型
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function findByRefundNo(string $refundNo, ?int $merchantId = null): ?\app\model\payment\RefundOrder
|
||||
{
|
||||
@@ -157,9 +182,13 @@ class RefundQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 构建退款订单基础查询,列表与详情共用。
|
||||
*
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
|
||||
*/
|
||||
private function buildRefundOrderQuery(?int $merchantId = null)
|
||||
{
|
||||
// 退款单详情需要同时展示支付、业务、商户和通道信息,所以一次性把相关表都 join 进来。
|
||||
$query = $this->refundOrderRepository->query()
|
||||
->from('ma_refund_order as ro')
|
||||
->leftJoin('ma_pay_order as po', 'po.pay_no', '=', 'ro.pay_no')
|
||||
@@ -226,8 +255,13 @@ class RefundQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载退款相关资金流水。
|
||||
*
|
||||
* 按追踪号、业务单号、退款单号依次回退查找,尽量把相关流水补齐。
|
||||
*
|
||||
* @param object|null $refundOrder 退款订单或查询行
|
||||
* @return array<int, array<string, mixed>> 退款流水展示结构
|
||||
*/
|
||||
private function loadRefundLedgers(mixed $refundOrder): array
|
||||
private function loadRefundLedgers(object|null $refundOrder): array
|
||||
{
|
||||
$traceNo = trim((string) ($refundOrder->trace_no ?? ''));
|
||||
$bizNo = trim((string) ($refundOrder->biz_no ?? ''));
|
||||
@@ -239,10 +273,12 @@ class RefundQueryService extends BaseService
|
||||
}
|
||||
|
||||
if (empty($ledgers) && $bizNo !== '') {
|
||||
// 退款流水优先按追踪号查,查不到再回到业务单号兜底。
|
||||
$ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo));
|
||||
}
|
||||
|
||||
if (empty($ledgers) && $refundNo !== '') {
|
||||
// 最后再用退款单号补查,尽量避免详情页缺少资金流水。
|
||||
$ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($refundNo));
|
||||
}
|
||||
|
||||
@@ -256,6 +292,9 @@ class RefundQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 将查询结果转换成普通数组。
|
||||
*
|
||||
* @param iterable $items 查询结果
|
||||
* @return array<int, mixed> 查询结果列表
|
||||
*/
|
||||
private function collectionToArray(iterable $items): array
|
||||
{
|
||||
@@ -269,6 +308,8 @@ class RefundQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 返回启用的支付方式选项,供筛选使用。
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}> 支付方式选项
|
||||
*/
|
||||
private function payTypeOptions(): array
|
||||
{
|
||||
|
||||
@@ -10,12 +10,15 @@ use app\common\constant\TradeConstant;
|
||||
/**
|
||||
* 退款单结果组装服务。
|
||||
*
|
||||
* 负责退款详情页和列表页的展示字段格式化。
|
||||
* 负责退款列表、详情页和资金流水的展示字段格式化。
|
||||
*/
|
||||
class RefundReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 格式化退款订单行,统一输出前端展示字段。
|
||||
*
|
||||
* @param array<string, mixed> $row 原始查询行
|
||||
* @return array<string, mixed> 格式化后的退款单行
|
||||
*/
|
||||
public function formatRefundOrderRow(array $row): array
|
||||
{
|
||||
@@ -48,11 +51,17 @@ class RefundReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 构造退款时间线。
|
||||
*
|
||||
* 依次输出创建、处理中、成功和失败节点,便于前端直接展示进度。
|
||||
*
|
||||
* @param object|null $refundOrder 退款订单或查询行
|
||||
* @return array<int, array<string, mixed>> 退款时间线
|
||||
*/
|
||||
public function buildRefundTimeline(mixed $refundOrder): array
|
||||
public function buildRefundTimeline(object|null $refundOrder): array
|
||||
{
|
||||
$extJson = (array) ($refundOrder->ext_json ?? []);
|
||||
|
||||
// 退款时间线同样只展示已经发生的节点,并尽量用扩展信息补全原因字段。
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'status' => 'created',
|
||||
@@ -64,6 +73,7 @@ class RefundReportService extends BaseService
|
||||
'label' => '退款处理中',
|
||||
'at' => $this->formatDateTime($refundOrder->processing_at, '—'),
|
||||
'retry_count' => (int) ($refundOrder->retry_count ?? 0),
|
||||
// 处理中原因优先按重试原因、处理中原因、最后错误的顺序回退。
|
||||
'reason' => (string) ($extJson['retry_reason'] ?? $extJson['processing_reason'] ?? $refundOrder->last_error ?? ''),
|
||||
] : null,
|
||||
$refundOrder->succeeded_at ? [
|
||||
@@ -75,6 +85,7 @@ class RefundReportService extends BaseService
|
||||
'status' => 'failed',
|
||||
'label' => '退款失败',
|
||||
'at' => $this->formatDateTime($refundOrder->failed_at, '—'),
|
||||
// 失败原因先看最后错误,再回退到扩展信息和退款单原始原因。
|
||||
'reason' => (string) ($refundOrder->last_error ?: ($extJson['fail_reason'] ?? $refundOrder->reason ?? '')),
|
||||
] : null,
|
||||
]));
|
||||
@@ -82,6 +93,9 @@ class RefundReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 格式化退款相关资金流水。
|
||||
*
|
||||
* @param array<string, mixed> $row 原始查询行
|
||||
* @return array<string, mixed> 格式化后的流水行
|
||||
*/
|
||||
public function formatLedgerRow(array $row): array
|
||||
{
|
||||
@@ -98,3 +112,4 @@ class RefundReportService extends BaseService
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,21 @@ use app\common\base\BaseService;
|
||||
use app\model\payment\RefundOrder;
|
||||
|
||||
/**
|
||||
* 退款单门面服务。
|
||||
* 退款单服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给查询、创建和生命周期三个子服务。
|
||||
* @property RefundQueryService $queryService 查询服务
|
||||
* @property RefundCreationService $creationService 创建服务
|
||||
* @property RefundLifecycleService $lifecycleService 生命周期服务
|
||||
*/
|
||||
class RefundService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param RefundQueryService $queryService 查询服务
|
||||
* @param RefundCreationService $creationService 创建服务
|
||||
* @param RefundLifecycleService $lifecycleService 生命周期服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected RefundQueryService $queryService,
|
||||
@@ -24,6 +31,12 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询退款订单列表。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @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
|
||||
{
|
||||
@@ -32,6 +45,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询退款订单详情。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array{refund_order: array<string, mixed>, timeline: array<int, array<string, mixed>>, account_ledgers: array<int, array<string, mixed>>} 退款详情结构
|
||||
*/
|
||||
public function detail(string $refundNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -40,6 +57,9 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 创建退款单。
|
||||
*
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function createRefund(array $input): RefundOrder
|
||||
{
|
||||
@@ -48,6 +68,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 标记退款处理中。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundProcessing(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -56,10 +80,17 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 退款重试。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return RefundOrder 退款单模型
|
||||
* @throws app\exception\ResourceNotFoundException
|
||||
*/
|
||||
public function retryRefund(string $refundNo, array $input = [], ?int $merchantId = null): RefundOrder
|
||||
{
|
||||
if ($merchantId !== null && $merchantId > 0) {
|
||||
// 商户后台重试前先确认退款单归属,避免跨商户误操作。
|
||||
$refundOrder = $this->queryService->findByRefundNo($refundNo, $merchantId);
|
||||
if (!$refundOrder) {
|
||||
throw new \app\exception\ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
@@ -71,6 +102,11 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款处理中或重试。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @param bool $isRetry 是否来自重试流程
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder
|
||||
{
|
||||
@@ -79,6 +115,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 退款成功。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -87,6 +127,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款成功。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -95,6 +139,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 退款失败。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundFailed(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
@@ -103,6 +151,10 @@ class RefundService extends BaseService
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款失败。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 输入参数
|
||||
* @return RefundOrder 退款单模型
|
||||
*/
|
||||
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
|
||||
@@ -16,11 +16,20 @@ use app\repository\ops\log\PayCallbackLogRepository;
|
||||
* 通知服务。
|
||||
*
|
||||
* 负责渠道通知日志、支付回调日志和商户通知任务的统一管理,核心目标是去重、留痕和可重试。
|
||||
*
|
||||
* @property ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库
|
||||
* @property PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
|
||||
* @property NotifyTaskRepository $notifyTaskRepository 通知任务仓库
|
||||
*/
|
||||
class NotifyService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库
|
||||
* @param PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
|
||||
* @param NotifyTaskRepository $notifyTaskRepository 通知任务仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelNotifyLogRepository $channelNotifyLogRepository,
|
||||
@@ -33,6 +42,10 @@ class NotifyService extends BaseService
|
||||
* 记录渠道通知日志。
|
||||
*
|
||||
* 同一通道、通知类型和业务单号只保留一条重复记录。
|
||||
*
|
||||
* @param array $input 通知数据
|
||||
* @return ChannelNotifyLog 渠道通知日志
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function recordChannelNotify(array $input): ChannelNotifyLog
|
||||
{
|
||||
@@ -44,6 +57,7 @@ class NotifyService extends BaseService
|
||||
throw new \InvalidArgumentException('渠道通知入参不完整');
|
||||
}
|
||||
|
||||
// 同一业务单如果已经记录过相同类型的通知,就直接复用旧日志,避免重复落库。
|
||||
if ($duplicate = $this->channelNotifyLogRepository->findDuplicate($channelId, $notifyType, $bizNo)) {
|
||||
return $duplicate;
|
||||
}
|
||||
@@ -69,6 +83,10 @@ class NotifyService extends BaseService
|
||||
* 记录支付回调日志。
|
||||
*
|
||||
* 以支付单号 + 回调类型作为去重依据。
|
||||
*
|
||||
* @param array $input 回调数据
|
||||
* @return PayCallbackLog 支付回调日志
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function recordPayCallback(array $input): PayCallbackLog
|
||||
{
|
||||
@@ -80,6 +98,7 @@ class NotifyService extends BaseService
|
||||
$callbackType = (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC);
|
||||
$logs = $this->payCallbackLogRepository->listByPayNo($payNo);
|
||||
foreach ($logs as $log) {
|
||||
// 同一支付单的同一类型回调只保留一条,后续重复请求直接返回已有日志。
|
||||
if ((int) $log->callback_type === $callbackType) {
|
||||
return $log;
|
||||
}
|
||||
@@ -100,6 +119,9 @@ class NotifyService extends BaseService
|
||||
* 创建商户通知任务。
|
||||
*
|
||||
* 通常用于支付成功、退款成功或清算完成后的商户异步通知。
|
||||
*
|
||||
* @param array $input 通知任务数据
|
||||
* @return NotifyTask 通知任务
|
||||
*/
|
||||
public function enqueueMerchantNotify(array $input): NotifyTask
|
||||
{
|
||||
@@ -123,6 +145,11 @@ class NotifyService extends BaseService
|
||||
* 标记商户通知成功。
|
||||
*
|
||||
* 成功后会刷新最后通知时间和响应内容。
|
||||
*
|
||||
* @param string $notifyNo 通知号
|
||||
* @param array $input 附加数据
|
||||
* @return NotifyTask 通知任务
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
@@ -143,6 +170,11 @@ class NotifyService extends BaseService
|
||||
* 标记商户通知失败并计算下次重试时间。
|
||||
*
|
||||
* 失败后会累计重试次数,并根据退避策略生成下一次重试时间。
|
||||
*
|
||||
* @param string $notifyNo 通知号
|
||||
* @param array $input 附加数据
|
||||
* @return NotifyTask 通知任务
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
@@ -151,6 +183,7 @@ class NotifyService extends BaseService
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
}
|
||||
|
||||
// 每次失败都累计一次重试,并根据新的次数重新计算下一次触发时间。
|
||||
$retryCount = (int) $task->retry_count + 1;
|
||||
$task->status = NotifyConstant::TASK_STATUS_FAILED;
|
||||
$task->retry_count = $retryCount;
|
||||
@@ -164,6 +197,8 @@ class NotifyService extends BaseService
|
||||
|
||||
/**
|
||||
* 获取待重试任务。
|
||||
*
|
||||
* @return iterable 待重试任务集合
|
||||
*/
|
||||
public function listRetryableTasks(): iterable
|
||||
{
|
||||
@@ -174,6 +209,9 @@ class NotifyService extends BaseService
|
||||
* 根据重试次数计算下次重试时间。
|
||||
*
|
||||
* 使用简单的指数退避思路控制重试频率。
|
||||
*
|
||||
* @param int $retryCount 重试次数
|
||||
* @return string 下次重试时间
|
||||
*/
|
||||
private function nextRetryAt(int $retryCount): string
|
||||
{
|
||||
@@ -189,3 +227,8 @@ class NotifyService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,23 @@ use app\repository\payment\config\PaymentTypeRepository;
|
||||
* 支付插件工厂服务。
|
||||
*
|
||||
* 负责解析插件定义、装配配置并实例化插件。
|
||||
*
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @property PaymentPluginConfRepository $paymentPluginConfRepository 支付插件配置仓库
|
||||
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
*/
|
||||
class PaymentPluginFactoryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @param PaymentPluginConfRepository $paymentPluginConfRepository 支付插件配置仓库
|
||||
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
@@ -28,6 +42,15 @@ class PaymentPluginFactoryService extends BaseService
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据渠道创建支付插件实例。
|
||||
*
|
||||
* @param PaymentChannel|int $channel 渠道对象或渠道ID
|
||||
* @param int|null $payTypeId 支付类型ID
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return PaymentInterface&PayPluginInterface 插件实例
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$channelModel = $channel instanceof PaymentChannel
|
||||
@@ -39,6 +62,7 @@ class PaymentPluginFactoryService extends BaseService
|
||||
}
|
||||
|
||||
$plugin = $this->resolvePlugin((string) $channelModel->plugin_code, $allowDisabled);
|
||||
// 如果外部没有额外指定支付方式,就沿用通道自身绑定的支付方式,确保插件校验口径一致。
|
||||
$payTypeCode = $this->resolvePayTypeCode((int) ($payTypeId ?: $channelModel->pay_type_id));
|
||||
if (!$allowDisabled && !$this->pluginSupportsPayType($plugin, $payTypeCode)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40210, [
|
||||
@@ -54,13 +78,30 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付订单创建支付插件实例。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return PaymentInterface&PayPluginInterface 插件实例
|
||||
*/
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface
|
||||
{
|
||||
// 支付单已经带了渠道和支付方式快照,这里直接复用渠道工厂逻辑,避免两套实例化口径分叉。
|
||||
return $this->createByChannel((int) $payOrder->channel_id, (int) $payOrder->pay_type_id, $allowDisabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验渠道是否支持指定支付方式。
|
||||
*
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @return void
|
||||
* @throws PaymentException
|
||||
*/
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
// 只做能力校验,不实例化插件,便于后台在保存配置前先拦住不兼容组合。
|
||||
$plugin = $this->resolvePlugin((string) $channel->plugin_code, false);
|
||||
$payTypeCode = $this->resolvePayTypeCode($payTypeId);
|
||||
|
||||
@@ -73,6 +114,13 @@ class PaymentPluginFactoryService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件支持的支付方式编码。
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return array 支付方式编码列表
|
||||
*/
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
$plugin = $this->resolvePlugin($pluginCode, $allowDisabled);
|
||||
@@ -80,12 +128,21 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return $this->normalizeCodes($plugin->pay_types ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装渠道初始化配置。
|
||||
*
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @param PaymentPlugin $plugin 插件
|
||||
* @return array 初始化配置
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function buildChannelConfig(PaymentChannel $channel, PaymentPlugin $plugin): array
|
||||
{
|
||||
$config = [];
|
||||
$configId = (int) $channel->api_config_id;
|
||||
|
||||
if ($configId > 0) {
|
||||
// 渠道绑定了配置时,先把配置表里的内容作为插件初始化基础数据。
|
||||
$pluginConf = $this->paymentPluginConfRepository->find($configId);
|
||||
if (!$pluginConf) {
|
||||
throw new PaymentException('支付插件配置不存在', 40403, [
|
||||
@@ -103,10 +160,12 @@ class PaymentPluginFactoryService extends BaseService
|
||||
}
|
||||
|
||||
$config = (array) ($pluginConf->config ?? []);
|
||||
// 结算周期信息属于配置层,插件可以直接读取,不必再去查数据库。
|
||||
$config['settlement_cycle_type'] = (int) ($pluginConf->settlement_cycle_type ?? 1);
|
||||
$config['settlement_cutoff_time'] = (string) ($pluginConf->settlement_cutoff_time ?? '23:59:59');
|
||||
}
|
||||
|
||||
// 以下字段是所有插件都通用的运行时上下文。
|
||||
$config['plugin_code'] = (string) $plugin->code;
|
||||
$config['plugin_name'] = (string) $plugin->name;
|
||||
$config['channel_id'] = (int) $channel->id;
|
||||
@@ -120,6 +179,13 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化支付插件。
|
||||
*
|
||||
* @param string $className 类名
|
||||
* @return PaymentInterface&PayPluginInterface 插件实例
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function instantiatePlugin(string $className): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$className = $this->resolvePluginClassName($className);
|
||||
@@ -131,7 +197,9 @@ class PaymentPluginFactoryService extends BaseService
|
||||
throw new PaymentException('支付插件实现类不存在', 40404, ['class_name' => $className]);
|
||||
}
|
||||
|
||||
// 通过容器实例化插件,便于插件内部继续使用依赖注入。
|
||||
$instance = container_make($className, []);
|
||||
// 插件必须同时实现动作接口和元信息接口,否则工厂无法正常调用和展示。
|
||||
if (!$instance instanceof PaymentInterface || !$instance instanceof PayPluginInterface) {
|
||||
throw new PaymentException('支付插件必须同时实现 PaymentInterface 与 PayPluginInterface', 40213, ['class_name' => $className]);
|
||||
}
|
||||
@@ -139,6 +207,12 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化插件类名。
|
||||
*
|
||||
* @param string $className 类名
|
||||
* @return string 完整类名
|
||||
*/
|
||||
private function resolvePluginClassName(string $className): string
|
||||
{
|
||||
$className = trim($className);
|
||||
@@ -153,6 +227,14 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return 'app\\common\\payment\\' . $className;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据编码解析支付插件。
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return PaymentPlugin 插件模型
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function resolvePlugin(string $pluginCode, bool $allowDisabled): PaymentPlugin
|
||||
{
|
||||
/** @var PaymentPlugin|null $plugin */
|
||||
@@ -168,6 +250,13 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付类型 ID 解析支付方式编码。
|
||||
*
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @return string 支付方式编码
|
||||
* @throws PaymentException
|
||||
*/
|
||||
private function resolvePayTypeCode(int $payTypeId): string
|
||||
{
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
@@ -178,6 +267,13 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return trim((string) $paymentType->code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断插件是否支持指定支付方式。
|
||||
*
|
||||
* @param PaymentPlugin $plugin 插件
|
||||
* @param string $payTypeCode 支付方式编码
|
||||
* @return bool 是否支持
|
||||
*/
|
||||
private function pluginSupportsPayType(PaymentPlugin $plugin, string $payTypeCode): bool
|
||||
{
|
||||
$payTypeCode = trim($payTypeCode);
|
||||
@@ -188,6 +284,14 @@ class PaymentPluginFactoryService extends BaseService
|
||||
return in_array($payTypeCode, $this->normalizeCodes($plugin->pay_types ?? []), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化编码列表。
|
||||
*
|
||||
* 支持数组和 JSON 字符串两种输入形式,输出去重后的纯字符串数组。
|
||||
*
|
||||
* @param array|string|null $codes 原始编码集合
|
||||
* @return array<int, string> 编码列表
|
||||
*/
|
||||
private function normalizeCodes(mixed $codes): array
|
||||
{
|
||||
if (is_string($codes)) {
|
||||
|
||||
@@ -9,34 +9,72 @@ use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付插件门面服务。
|
||||
* 支付插件服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给插件工厂服务。
|
||||
* @property PaymentPluginFactoryService $factoryService 插件工厂服务
|
||||
*/
|
||||
class PaymentPluginManager extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPluginFactoryService $factoryService 插件工厂服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginFactoryService $factoryService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据渠道创建支付插件实例。
|
||||
*
|
||||
* @param PaymentChannel|int $channel 渠道对象或渠道ID
|
||||
* @param int|null $payTypeId 支付类型ID
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return PaymentInterface&PayPluginInterface 插件实例
|
||||
*/
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByChannel($channel, $payTypeId, $allowDisabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付订单创建支付插件实例。
|
||||
*
|
||||
* @param PayOrder $payOrder 支付订单
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return PaymentInterface&PayPluginInterface 插件实例
|
||||
*/
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByPayOrder($payOrder, $allowDisabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验渠道是否支持指定支付方式。
|
||||
*
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @return void
|
||||
*/
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
$this->factoryService->ensureChannelSupportsPayType($channel, $payTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件支持的支付方式编码。
|
||||
*
|
||||
* @param string $pluginCode 插件编码
|
||||
* @param bool $allowDisabled 是否允许已禁用插件
|
||||
* @return array 支付方式编码列表
|
||||
*/
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
return $this->factoryService->pluginPayTypes($pluginCode, $allowDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -23,10 +23,30 @@ use support\Redis;
|
||||
/**
|
||||
* 支付路由解析服务。
|
||||
*
|
||||
* 负责商户分组 -> 轮询组 -> 支付通道的编排与选择。
|
||||
* 负责商户分组、轮询组、支付类型和支付通道之间的筛选、排序与最终选择。
|
||||
*
|
||||
* @property PaymentPollGroupBindRepository $bindRepository 绑定仓库
|
||||
* @property PaymentPollGroupRepository $pollGroupRepository 轮询分组仓库
|
||||
* @property PaymentPollGroupChannelRepository $pollGroupChannelRepository 轮询分组渠道仓库
|
||||
* @property PaymentChannelRepository $channelRepository 渠道仓库
|
||||
* @property ChannelDailyStatRepository $channelDailyStatRepository 渠道日统计仓库
|
||||
* @property PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @property PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
*/
|
||||
class PaymentRouteResolverService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentPollGroupBindRepository $bindRepository 绑定仓库
|
||||
* @param PaymentPollGroupRepository $pollGroupRepository 轮询分组仓库
|
||||
* @param PaymentPollGroupChannelRepository $pollGroupChannelRepository 轮询分组渠道仓库
|
||||
* @param PaymentChannelRepository $channelRepository 渠道仓库
|
||||
* @param ChannelDailyStatRepository $channelDailyStatRepository 渠道日统计仓库
|
||||
* @param PaymentPluginRepository $paymentPluginRepository 支付插件仓库
|
||||
* @param PaymentTypeRepository $paymentTypeRepository 支付类型仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPollGroupBindRepository $bindRepository,
|
||||
protected PaymentPollGroupRepository $pollGroupRepository,
|
||||
@@ -41,7 +61,17 @@ class PaymentRouteResolverService extends BaseService
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*
|
||||
* @return array{bind:mixed,poll_group:mixed,candidates:array,selected_channel:array}
|
||||
* 先读取有效的商户分组绑定和轮询组,再按支付类型、插件支持、金额区间和日限额过滤候选通道,
|
||||
* 最后依据轮询组策略选出实际使用的通道。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param array $context 路由上下文,支持传入 `stat_date` 等辅助参数
|
||||
* @return array 路由解析结果
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
@@ -74,7 +104,9 @@ class PaymentRouteResolverService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 先拿到轮询组下的编排记录,再去批量加载通道、插件和统计数据,避免逐条查库。
|
||||
$channelIds = $candidateRows->pluck('channel_id')->all();
|
||||
// 先一次性拉出通道和插件信息,避免候选过滤过程中频繁查库。
|
||||
$channels = $this->channelRepository->query()
|
||||
->whereIn('id', $channelIds)
|
||||
->where('status', CommonConstant::STATUS_ENABLED)
|
||||
@@ -83,6 +115,7 @@ class PaymentRouteResolverService extends BaseService
|
||||
$pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all();
|
||||
$plugins = [];
|
||||
if (!empty($pluginCodes)) {
|
||||
// 通道会复用同一个插件实现,插件信息也按编码批量加载一次即可。
|
||||
$plugins = $this->paymentPluginRepository->query()
|
||||
->whereIn('code', $pluginCodes)
|
||||
->get()
|
||||
@@ -92,6 +125,7 @@ class PaymentRouteResolverService extends BaseService
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
$payTypeCode = trim((string) ($paymentType->code ?? ''));
|
||||
|
||||
// 默认统计日期取当天,路由预览时也可以由外部显式传入历史日期。
|
||||
$statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$payAmount = (int) $payAmount;
|
||||
$eligible = [];
|
||||
@@ -105,30 +139,36 @@ class PaymentRouteResolverService extends BaseService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先按支付方式收口,避免插件和通道配置不一致时误选。
|
||||
if ((int) $channel->pay_type_id !== $payTypeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var \app\model\payment\PaymentPlugin|null $plugin */
|
||||
$plugin = $plugins[(string) $channel->plugin_code] ?? null;
|
||||
if (!$plugin || (int) $plugin->status !== CommonConstant::STATUS_ENABLED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 通道还必须被插件明确支持,才允许进入候选集。
|
||||
$pluginPayTypes = is_array($plugin->pay_types) ? $plugin->pay_types : [];
|
||||
$pluginPayTypes = array_values(array_filter(array_map(static fn ($item) => trim((string) $item), $pluginPayTypes)));
|
||||
if ($payTypeCode === '' || !in_array($payTypeCode, $pluginPayTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 金额区间不匹配的通道直接过滤掉。
|
||||
if (!$this->isAmountAllowed($channel, $payAmount)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 日限额和日成功笔数也要同时校验,防止选中已接近上限的通道。
|
||||
$stat = $this->channelDailyStatRepository->findByChannelAndDate($channelId, $statDate);
|
||||
if (!$this->isDailyLimitAllowed($channel, $payAmount, $statDate, $stat)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保留排序和择优所需的权重、默认标记和统计指标。
|
||||
$eligible[] = [
|
||||
'channel' => $channel,
|
||||
'poll_group_channel' => $row,
|
||||
@@ -143,6 +183,7 @@ class PaymentRouteResolverService extends BaseService
|
||||
}
|
||||
|
||||
if (empty($eligible)) {
|
||||
// 所有候选都被过滤后,直接判定通道不可用。
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
@@ -150,10 +191,12 @@ class PaymentRouteResolverService extends BaseService
|
||||
]);
|
||||
}
|
||||
|
||||
// 按路由模式进行排序,然后再选出最终通道。
|
||||
$routeMode = (int) $pollGroup->route_mode;
|
||||
$ordered = $this->sortCandidates($eligible, $routeMode);
|
||||
$selected = $this->selectChannel($ordered, $routeMode, (int) $pollGroup->id);
|
||||
|
||||
// 返回绑定、轮询组、候选集和最终选中项,供路由预览和实际支付共用。
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'poll_group' => $pollGroup,
|
||||
@@ -162,6 +205,13 @@ class PaymentRouteResolverService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断通道是否满足金额区间。
|
||||
*
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @return bool 是否可用
|
||||
*/
|
||||
private function isAmountAllowed(PaymentChannel $channel, int $payAmount): bool
|
||||
{
|
||||
if ((int) $channel->min_amount > 0 && $payAmount < (int) $channel->min_amount) {
|
||||
@@ -175,6 +225,15 @@ class PaymentRouteResolverService extends BaseService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断通道是否满足日限额和日成功笔数。
|
||||
*
|
||||
* @param PaymentChannel $channel 渠道
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param string $statDate 统计日期
|
||||
* @param object|null $stat 当日统计数据
|
||||
* @return bool 是否可用
|
||||
*/
|
||||
private function isDailyLimitAllowed(PaymentChannel $channel, int $payAmount, string $statDate, ?object $stat = null): bool
|
||||
{
|
||||
if ((int) $channel->daily_limit_amount <= 0 && (int) $channel->daily_limit_count <= 0) {
|
||||
@@ -196,9 +255,17 @@ class PaymentRouteResolverService extends BaseService
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路由模式整理候选通道顺序。
|
||||
*
|
||||
* @param array $candidates 候选通道列表
|
||||
* @param int $routeMode 路由模式
|
||||
* @return array 排序后的候选列表
|
||||
*/
|
||||
private function sortCandidates(array $candidates, int $routeMode): array
|
||||
{
|
||||
usort($candidates, function (array $left, array $right) use ($routeMode) {
|
||||
// 第一可用模式下先把默认通道排到前面,其余模式再按排序号和主键做稳定排序。
|
||||
if (
|
||||
$routeMode === RouteConstant::ROUTE_MODE_FIRST_AVAILABLE
|
||||
&& (int) $left['is_default'] !== (int) $right['is_default']
|
||||
@@ -216,6 +283,14 @@ class PaymentRouteResolverService extends BaseService
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路由模式选择最终通道。
|
||||
*
|
||||
* @param array $candidates 候选通道列表
|
||||
* @param int $routeMode 路由模式
|
||||
* @param int $pollGroupId 轮询分组ID
|
||||
* @return array 选中的通道候选
|
||||
*/
|
||||
private function selectChannel(array $candidates, int $routeMode, int $pollGroupId): array
|
||||
{
|
||||
if (count($candidates) === 1) {
|
||||
@@ -230,6 +305,12 @@ class PaymentRouteResolverService extends BaseService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按权重随机选择通道。
|
||||
*
|
||||
* @param array $candidates 候选通道列表
|
||||
* @return array 选中的通道候选
|
||||
*/
|
||||
private function selectWeightedChannel(array $candidates): array
|
||||
{
|
||||
$totalWeight = array_sum(array_map(static fn (array $item) => max(1, (int) $item['weight']), $candidates));
|
||||
@@ -245,6 +326,13 @@ class PaymentRouteResolverService extends BaseService
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按轮询游标顺序选择通道。
|
||||
*
|
||||
* @param array $candidates 候选通道列表
|
||||
* @param int $pollGroupId 轮询分组ID
|
||||
* @return array 选中的通道候选
|
||||
*/
|
||||
private function selectSequentialChannel(array $candidates, int $pollGroupId): array
|
||||
{
|
||||
if ($pollGroupId <= 0) {
|
||||
@@ -252,17 +340,27 @@ class PaymentRouteResolverService extends BaseService
|
||||
}
|
||||
|
||||
try {
|
||||
// 用 Redis 维护跨进程共享的轮询游标,避免每个 PHP 进程各选各的。
|
||||
$cursorKey = sprintf('payment:route:round_robin:%d', $pollGroupId);
|
||||
$cursor = (int) Redis::incr($cursorKey);
|
||||
// 游标保留一个较长的生命周期,避免 Redis 清理后轮询顺序完全丢失。
|
||||
Redis::expire($cursorKey, 30 * 86400);
|
||||
// Redis 自增从 1 开始,这里转成 0 基索引后再对候选集取模。
|
||||
$index = max(0, ($cursor - 1) % count($candidates));
|
||||
|
||||
return $candidates[$index] ?? $candidates[0];
|
||||
} catch (\Throwable) {
|
||||
// Redis 不可用时降级成首个候选,保证路由还能继续往下走。
|
||||
return $candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先返回默认通道,否则返回首个候选。
|
||||
*
|
||||
* @param array $candidates 候选通道列表
|
||||
* @return array 选中的通道候选
|
||||
*/
|
||||
private function selectDefaultChannel(array $candidates): array
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace app\service\payment\runtime;
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 支付路由门面服务。
|
||||
* 支付路由服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给路由解析服务。
|
||||
* @property PaymentRouteResolverService $resolverService 路由解析服务
|
||||
*/
|
||||
class PaymentRouteService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param PaymentRouteResolverService $resolverService 路由解析服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentRouteResolverService $resolverService
|
||||
) {
|
||||
@@ -18,9 +24,18 @@ class PaymentRouteService extends BaseService
|
||||
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*
|
||||
* @param int $merchantGroupId 商户分组ID
|
||||
* @param int $payTypeId 支付类型ID
|
||||
* @param int $payAmount 支付金额(分)
|
||||
* @param array $context 路由上下文,例如统计日期、额外筛选条件
|
||||
* @return array 路由解析结果
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
return $this->resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,22 @@ use app\service\account\funds\MerchantAccountService;
|
||||
* 清算生命周期服务。
|
||||
*
|
||||
* 负责清算单创建、明细写入、入账完成和失败终态处理。
|
||||
*
|
||||
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @property SettlementItemRepository $settlementItemRepository 结算明细仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property MerchantAccountService $merchantAccountService 商户账户服务
|
||||
*/
|
||||
class SettlementLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @param SettlementItemRepository $settlementItemRepository 结算明细仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param MerchantAccountService $merchantAccountService 商户账户服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
@@ -36,9 +47,10 @@ class SettlementLifecycleService extends BaseService
|
||||
*
|
||||
* 适用于平台代收链路的清算批次生成,会同时写入汇总与明细。
|
||||
*
|
||||
* @param array $input 清算单参数
|
||||
* @param array $items 清算明细列表
|
||||
* @return SettlementOrder
|
||||
* @param array $input 清算参数
|
||||
* @param array $items 清算明细
|
||||
* @return SettlementOrder 清算单记录
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function createSettlementOrder(array $input, array $items = []): SettlementOrder
|
||||
{
|
||||
@@ -47,6 +59,7 @@ class SettlementLifecycleService extends BaseService
|
||||
$settleNo = $this->generateNo('STL');
|
||||
}
|
||||
|
||||
// 清算单号天然幂等,同一批次重复触发时直接复用已有记录。
|
||||
if ($existing = $this->settlementOrderRepository->findBySettleNo($settleNo)) {
|
||||
return $existing;
|
||||
}
|
||||
@@ -62,6 +75,7 @@ class SettlementLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
return $this->transactionRetry(function () use ($settleNo, $input, $items, $merchantId, $merchantGroupId, $channelId, $cycleType, $cycleKey) {
|
||||
// 先汇总主表金额,再写入主表和明细,保证批次头尾一致。
|
||||
$summary = $this->buildSummary($items, $input);
|
||||
$traceNo = trim((string) ($input['trace_no'] ?? $settleNo));
|
||||
|
||||
@@ -86,6 +100,7 @@ class SettlementLifecycleService extends BaseService
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
// 每一笔清算明细都单独落库,方便后续对账和问题定位。
|
||||
$this->settlementItemRepository->create([
|
||||
'settle_no' => $settleNo,
|
||||
'merchant_id' => $merchantId,
|
||||
@@ -111,8 +126,10 @@ class SettlementLifecycleService extends BaseService
|
||||
*
|
||||
* 会把清算净额计入商户可提现余额,并同步标记清算单与清算明细为已完成。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @return SettlementOrder
|
||||
* @param string $settleNo 结算单号
|
||||
* @return SettlementOrder 清算单记录
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function completeSettlement(string $settleNo): SettlementOrder
|
||||
{
|
||||
@@ -123,6 +140,7 @@ class SettlementLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
$currentStatus = (int) $settlementOrder->status;
|
||||
// 已结算或已终态的单子直接返回,避免重复入账。
|
||||
if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_SETTLED) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
@@ -139,6 +157,7 @@ class SettlementLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
if ((int) $settlementOrder->accounted_amount > 0) {
|
||||
// 只有净额大于 0 时才入账到商户可提现余额。
|
||||
$this->merchantAccountService->creditAvailableAmountInCurrentTransaction(
|
||||
(int) $settlementOrder->merchant_id,
|
||||
(int) $settlementOrder->accounted_amount,
|
||||
@@ -159,6 +178,7 @@ class SettlementLifecycleService extends BaseService
|
||||
|
||||
$items = $this->settlementItemRepository->listBySettleNo($settleNo);
|
||||
foreach ($items as $item) {
|
||||
// 清算明细和关联支付单状态一起同步,避免批次与订单状态不一致。
|
||||
$item->item_status = TradeConstant::SETTLEMENT_STATUS_SETTLED;
|
||||
$item->save();
|
||||
|
||||
@@ -180,9 +200,11 @@ class SettlementLifecycleService extends BaseService
|
||||
*
|
||||
* 仅用于清算批次未成功入账时的终态标记。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param string $settleNo 结算单号
|
||||
* @param string $reason 失败原因
|
||||
* @return SettlementOrder
|
||||
* @return SettlementOrder 清算单记录
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder
|
||||
{
|
||||
@@ -193,6 +215,7 @@ class SettlementLifecycleService extends BaseService
|
||||
}
|
||||
|
||||
$currentStatus = (int) $settlementOrder->status;
|
||||
// 失败态也只处理可变状态,终态直接返回。
|
||||
if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_REVERSED) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
@@ -213,6 +236,7 @@ class SettlementLifecycleService extends BaseService
|
||||
$settlementOrder->failed_at = $this->now();
|
||||
$extJson = (array) $settlementOrder->ext_json;
|
||||
if (trim($reason) !== '') {
|
||||
// 把失败原因同步到扩展字段,便于后台排查。
|
||||
$extJson['fail_reason'] = $reason;
|
||||
}
|
||||
$settlementOrder->ext_json = $extJson;
|
||||
@@ -230,6 +254,10 @@ class SettlementLifecycleService extends BaseService
|
||||
|
||||
/**
|
||||
* 根据清算明细构造汇总数据。
|
||||
*
|
||||
* @param array $items 清算明细
|
||||
* @param array $input 清算参数
|
||||
* @return array 汇总数据
|
||||
*/
|
||||
private function buildSummary(array $items, array $input): array
|
||||
{
|
||||
@@ -241,6 +269,7 @@ class SettlementLifecycleService extends BaseService
|
||||
$netAmount = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
// 汇总字段都从明细逐项累加,避免依赖上游传入的批次统计值。
|
||||
$grossAmount += (int) ($item['pay_amount'] ?? 0);
|
||||
$feeAmount += (int) ($item['fee_amount'] ?? 0);
|
||||
$refundAmount += (int) ($item['refund_amount'] ?? 0);
|
||||
@@ -258,6 +287,7 @@ class SettlementLifecycleService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
// 明细为空时,直接使用外部传入的汇总字段,兼容上游已经算好的批次数据。
|
||||
return [
|
||||
'gross_amount' => (int) ($input['gross_amount'] ?? 0),
|
||||
'fee_amount' => (int) ($input['fee_amount'] ?? 0),
|
||||
|
||||
@@ -13,11 +13,22 @@ use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
|
||||
/**
|
||||
* 清算订单查询服务。
|
||||
*
|
||||
* 负责清算订单的列表、详情、时间线和关联流水装配。
|
||||
*
|
||||
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @property SettlementItemRepository $settlementItemRepository 结算明细仓库
|
||||
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
*/
|
||||
class SettlementOrderQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入清算订单仓库。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @param SettlementItemRepository $settlementItemRepository 结算明细仓库
|
||||
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
@@ -28,6 +39,12 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 分页查询清算订单。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null)
|
||||
{
|
||||
@@ -35,6 +52,7 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
// 关键词同时命中清算单、追踪号、商户和通道,方便按任一线索回查批次。
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('s.settle_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('s.trace_no', 'like', '%' . $keyword . '%')
|
||||
@@ -69,6 +87,7 @@ class SettlementOrderQueryService extends BaseService
|
||||
->orderByDesc('s.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
// 列表页需要直接展示文本字段,所以这里统一把每一行补成可渲染结构。
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
@@ -78,6 +97,10 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 按清算单号查询详情。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return SettlementOrder|null 清算订单模型
|
||||
*/
|
||||
public function findBySettleNo(string $settleNo, ?int $merchantId = null): ?SettlementOrder
|
||||
{
|
||||
@@ -90,6 +113,12 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询清算订单详情。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array{settlement_order: SettlementOrder, items: array, account_ledgers: \Illuminate\Support\Collection, timeline: array<int, array<string, mixed>>} 详情结构
|
||||
* @throws ValidationException
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function detail(string $settleNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -109,6 +138,7 @@ class SettlementOrderQueryService extends BaseService
|
||||
: collect();
|
||||
|
||||
if ($accountLedgers->isEmpty()) {
|
||||
// 清算流水优先按追踪号查,缺失时回退到清算单号兜底。
|
||||
$accountLedgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $settlementOrder->settle_no);
|
||||
}
|
||||
|
||||
@@ -122,6 +152,9 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 构建时间线。
|
||||
*
|
||||
* @param SettlementOrder|null $settlementOrder 结算订单
|
||||
* @return array<int, array<string, mixed>> 清算时间线
|
||||
*/
|
||||
public function buildTimeline(?SettlementOrder $settlementOrder): array
|
||||
{
|
||||
@@ -129,6 +162,7 @@ class SettlementOrderQueryService extends BaseService
|
||||
return [];
|
||||
}
|
||||
|
||||
// 清算时间线只展示真正走到过的节点,未发生的步骤不占位。
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'title' => '生成清算单',
|
||||
@@ -156,9 +190,13 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 格式化单条记录。
|
||||
*
|
||||
* @param object $row 原始查询行
|
||||
* @return object 格式化后的记录
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
// 列表页直接要展示状态文案和金额文案,所以在查询层就把格式化字段补齐。
|
||||
$row->cycle_type_text = (string) (TradeConstant::settlementCycleMap()[(int) $row->cycle_type] ?? '未知');
|
||||
$row->status_text = (string) (TradeConstant::settlementStatusMap()[(int) $row->status] ?? '未知');
|
||||
$row->gross_amount_text = $this->formatAmount((int) $row->gross_amount);
|
||||
@@ -178,6 +216,9 @@ class SettlementOrderQueryService extends BaseService
|
||||
|
||||
/**
|
||||
* 统一构建查询。
|
||||
*
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
|
||||
*/
|
||||
private function baseQuery(?int $merchantId = null)
|
||||
{
|
||||
|
||||
@@ -6,14 +6,19 @@ use app\common\base\BaseService;
|
||||
use app\model\payment\SettlementOrder;
|
||||
|
||||
/**
|
||||
* 清算门面服务。
|
||||
* 清算服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给清算生命周期服务。
|
||||
* @property SettlementOrderQueryService $queryService 查询服务
|
||||
* @property SettlementLifecycleService $lifecycleService 生命周期服务
|
||||
*/
|
||||
class SettlementService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
* 构造方法。
|
||||
*
|
||||
* @param SettlementOrderQueryService $queryService 查询服务
|
||||
* @param SettlementLifecycleService $lifecycleService 生命周期服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderQueryService $queryService,
|
||||
@@ -23,6 +28,10 @@ class SettlementService extends BaseService
|
||||
|
||||
/**
|
||||
* 创建清算单和明细。
|
||||
*
|
||||
* @param array $input 输入参数
|
||||
* @param array $items 清算明细
|
||||
* @return SettlementOrder 清算订单模型
|
||||
*/
|
||||
public function createSettlementOrder(array $input, array $items = []): SettlementOrder
|
||||
{
|
||||
@@ -31,6 +40,10 @@ class SettlementService extends BaseService
|
||||
|
||||
/**
|
||||
* 查询清算订单详情。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param int|null $merchantId 商户ID
|
||||
* @return array 详情结构
|
||||
*/
|
||||
public function detail(string $settleNo, ?int $merchantId = null): array
|
||||
{
|
||||
@@ -38,7 +51,10 @@ class SettlementService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算入账成功。
|
||||
* 标记清算入账成功。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @return SettlementOrder 清算订单模型
|
||||
*/
|
||||
public function completeSettlement(string $settleNo): SettlementOrder
|
||||
{
|
||||
@@ -46,10 +62,16 @@ class SettlementService extends BaseService
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算失败。
|
||||
* 标记清算失败。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param string $reason 失败原因
|
||||
* @return SettlementOrder 清算订单模型
|
||||
*/
|
||||
public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder
|
||||
{
|
||||
return $this->lifecycleService->failSettlement($settleNo, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ class TradeTraceReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 汇总追踪统计数据。
|
||||
*
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @param array $payOrders 支付订单列表
|
||||
* @param array $refundOrders 退款订单列表
|
||||
* @param array $settlementOrders 清算订单列表
|
||||
* @param array $accountLedgers 账户流水列表
|
||||
* @param array $payCallbacks 支付回调列表
|
||||
* @return array<string, int|bool> 汇总统计
|
||||
*/
|
||||
public function buildSummary(?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders, array $accountLedgers, array $payCallbacks): array
|
||||
{
|
||||
@@ -36,6 +44,14 @@ class TradeTraceReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 根据关联记录组装追踪时间线。
|
||||
*
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @param array $payOrders 支付订单列表
|
||||
* @param array $refundOrders 退款订单列表
|
||||
* @param array $settlementOrders 清算订单列表
|
||||
* @param array $accountLedgers 账户流水列表
|
||||
* @param array $payCallbacks 支付回调列表
|
||||
* @return array<int, array<string, mixed>> 时间线事件
|
||||
*/
|
||||
public function buildTimeline(?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders, array $accountLedgers, array $payCallbacks): array
|
||||
{
|
||||
@@ -213,8 +229,17 @@ class TradeTraceReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 追加一条时间线事件。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $events 事件列表
|
||||
* @param int $sortOrder 当前排序号
|
||||
* @param string $type 事件类型
|
||||
* @param string $sourceNo 事件来源单号
|
||||
* @param string $status 事件状态
|
||||
* @param \DateTimeInterface|int|string|float|null $at 事件时间
|
||||
* @param array<string, mixed> $payload 事件载荷
|
||||
* @return void
|
||||
*/
|
||||
private function pushEvent(array &$events, int &$sortOrder, string $type, string $sourceNo, string $status, mixed $at, array $payload = []): void
|
||||
private function pushEvent(array &$events, int &$sortOrder, string $type, string $sourceNo, string $status, \DateTimeInterface|int|string|float|null $at, array $payload = []): void
|
||||
{
|
||||
$atText = $this->formatDateTime($at);
|
||||
if ($atText === '') {
|
||||
@@ -235,6 +260,10 @@ class TradeTraceReportService extends BaseService
|
||||
|
||||
/**
|
||||
* 汇总模型列表中的数值字段。
|
||||
*
|
||||
* @param array $items 模型列表
|
||||
* @param string $field 字段名
|
||||
* @return int 汇总值
|
||||
*/
|
||||
private function sumBy(array $items, string $field): int
|
||||
{
|
||||
@@ -246,3 +275,6 @@ class TradeTraceReportService extends BaseService
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,9 +15,29 @@ use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
|
||||
/**
|
||||
* 跨域交易追踪查询服务。
|
||||
*
|
||||
* @property TradeTraceReportService $tradeTraceReportService 交易追踪报表服务
|
||||
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @property PayOrderRepository $payOrderRepository 支付单仓库
|
||||
* @property RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @property PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
|
||||
*/
|
||||
class TradeTraceService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param TradeTraceReportService $tradeTraceReportService 交易追踪报表服务
|
||||
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
|
||||
* @param PayOrderRepository $payOrderRepository 支付订单仓库
|
||||
* @param RefundOrderRepository $refundOrderRepository 退款单仓库
|
||||
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
|
||||
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
|
||||
* @param PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected TradeTraceReportService $tradeTraceReportService,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
@@ -31,6 +51,10 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 根据追踪号查询完整交易链路。
|
||||
*
|
||||
* @param string $traceNo 追踪号
|
||||
* @return array{trace_no: string, resolved_trace_no: string, matched_by: string, biz_order: BizOrder|null, pay_orders: array, refund_orders: array, settlement_orders: array, account_ledgers: array, pay_callbacks: array, summary: array, timeline: array} 追踪结果
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function queryByTraceNo(string $traceNo): array
|
||||
{
|
||||
@@ -40,6 +64,7 @@ class TradeTraceService extends BaseService
|
||||
}
|
||||
|
||||
$matchedBy = 'trace_no';
|
||||
// 先按追踪号找,找不到再用业务单号兜底,尽量把同一条链路串起来。
|
||||
$bizOrder = $this->bizOrderRepository->findByTraceNo($traceNo);
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($traceNo);
|
||||
@@ -58,6 +83,7 @@ class TradeTraceService extends BaseService
|
||||
$settlementOrders = $this->loadSettlementOrders($resolvedTraceNo);
|
||||
|
||||
if (!$bizOrder) {
|
||||
// 如果主单没直接查到,就从支付单或退款单反推业务单,保证追踪页尽量有完整链路。
|
||||
$bizOrder = $this->deriveBizOrder($payOrders, $refundOrders);
|
||||
if ($bizOrder) {
|
||||
$matchedBy = $matchedBy === 'trace_no' ? 'derived' : $matchedBy;
|
||||
@@ -102,9 +128,14 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载支付单列表。
|
||||
*
|
||||
* @param string $traceNo 追踪号
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @return array<int, object> 支付订单列表
|
||||
*/
|
||||
private function loadPayOrders(string $traceNo, ?BizOrder $bizOrder): array
|
||||
{
|
||||
// 优先按 trace_no 查,缺失时再回到 biz_no,兼容早期单据没有完整追踪号的情况。
|
||||
$items = $this->collectionToArray($this->payOrderRepository->listByTraceNo($traceNo));
|
||||
if (!empty($items)) {
|
||||
return $items;
|
||||
@@ -119,9 +150,14 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载退款单列表。
|
||||
*
|
||||
* @param string $traceNo 追踪号
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @return array<int, object> 退款订单列表
|
||||
*/
|
||||
private function loadRefundOrders(string $traceNo, ?BizOrder $bizOrder): array
|
||||
{
|
||||
// 退款单同样先按追踪号查,再用业务单号兜底。
|
||||
$items = $this->collectionToArray($this->refundOrderRepository->listByTraceNo($traceNo));
|
||||
if (!empty($items)) {
|
||||
return $items;
|
||||
@@ -136,6 +172,9 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载清结算单列表。
|
||||
*
|
||||
* @param string $traceNo 追踪号
|
||||
* @return array<int, object> 清算订单列表
|
||||
*/
|
||||
private function loadSettlementOrders(string $traceNo): array
|
||||
{
|
||||
@@ -144,11 +183,15 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载支付回调日志列表。
|
||||
*
|
||||
* @param array<int, object> $payOrders 支付订单列表
|
||||
* @return array<int, array<string, mixed>> 支付回调列表
|
||||
*/
|
||||
private function loadPayCallbacks(array $payOrders): array
|
||||
{
|
||||
$callbacks = [];
|
||||
foreach ($payOrders as $payOrder) {
|
||||
// 同一追踪号下可能有多次回调记录,这里把每笔支付单的回调都收进来统一展示。
|
||||
foreach ($this->payCallbackLogRepository->listByPayNo((string) $payOrder->pay_no) as $callback) {
|
||||
$callbacks[] = [
|
||||
'id' => (int) ($callback->id ?? 0),
|
||||
@@ -168,6 +211,7 @@ class TradeTraceService extends BaseService
|
||||
}
|
||||
|
||||
usort($callbacks, static function ($left, $right): int {
|
||||
// 新的回调日志排在前面,时间线页面直接从近到远看。
|
||||
return ($right['id'] ?? 0) <=> ($left['id'] ?? 0);
|
||||
});
|
||||
|
||||
@@ -176,18 +220,27 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 加载资金流水列表。
|
||||
*
|
||||
* @param string $traceNo 追踪号
|
||||
* @param BizOrder|null $bizOrder 业务订单
|
||||
* @param array<int, object> $payOrders 支付订单列表
|
||||
* @param array<int, object> $refundOrders 退款订单列表
|
||||
* @param array<int, object> $settlementOrders 清算订单列表
|
||||
* @return array<int, object> 资金流水列表
|
||||
*/
|
||||
private function loadLedgers(string $traceNo, ?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders): array
|
||||
{
|
||||
$ledgers = [];
|
||||
$seen = [];
|
||||
|
||||
// 先合并 trace_no 命中的流水,再补查相关业务单号下的流水并去重。
|
||||
foreach ($this->collectionToArray($this->merchantAccountLedgerRepository->listByTraceNo($traceNo)) as $ledger) {
|
||||
$seen[(string) $ledger->ledger_no] = true;
|
||||
$ledgers[] = $ledger;
|
||||
}
|
||||
|
||||
$bizNos = [];
|
||||
// 资金流水有时挂在业务单号,有时挂在支付单号、退款单号或清算单号上,这里一并纳入兜底查询。
|
||||
if ($bizOrder) {
|
||||
$bizNos[] = (string) $bizOrder->biz_no;
|
||||
}
|
||||
@@ -208,6 +261,7 @@ class TradeTraceService extends BaseService
|
||||
foreach ($bizNos as $bizNo) {
|
||||
foreach ($this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo)) as $ledger) {
|
||||
$ledgerNo = (string) ($ledger->ledger_no ?? '');
|
||||
// 同一笔流水可能同时被 trace_no 和 biz_no 命中,这里只保留一份。
|
||||
if ($ledgerNo !== '' && isset($seen[$ledgerNo])) {
|
||||
continue;
|
||||
}
|
||||
@@ -227,10 +281,15 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 从支付单或退款单反推出业务单。
|
||||
*
|
||||
* @param array<int, object> $payOrders 支付订单列表
|
||||
* @param array<int, object> $refundOrders 退款订单列表
|
||||
* @return BizOrder|null 业务订单模型
|
||||
*/
|
||||
private function deriveBizOrder(array $payOrders, array $refundOrders): ?BizOrder
|
||||
{
|
||||
if (!empty($payOrders)) {
|
||||
// 先从支付单反推业务单,支付单通常比退款单更早、更稳定。
|
||||
$bizNo = (string) ($payOrders[0]->biz_no ?? '');
|
||||
if ($bizNo !== '') {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
@@ -241,6 +300,7 @@ class TradeTraceService extends BaseService
|
||||
}
|
||||
|
||||
if (!empty($refundOrders)) {
|
||||
// 没有支付单时,再用退款单反推业务单作为兜底。
|
||||
$bizNo = (string) ($refundOrders[0]->biz_no ?? '');
|
||||
if ($bizNo !== '') {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
@@ -255,6 +315,9 @@ class TradeTraceService extends BaseService
|
||||
|
||||
/**
|
||||
* 将可迭代对象转换为普通数组。
|
||||
*
|
||||
* @param iterable $items 可迭代对象
|
||||
* @return array<int, mixed> 普通数组
|
||||
*/
|
||||
private function collectionToArray(iterable $items): array
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user