更新统一使用 PHPDoc + PSR-19 标准注释

This commit is contained in:
技术老胡
2026-04-21 08:38:59 +08:00
parent dcd58e24ce
commit 9a16a88640
252 changed files with 9218 additions and 659 deletions

View File

@@ -16,9 +16,19 @@ use app\repository\account\ledger\MerchantAccountLedgerRepository;
* 商户账户命令服务。
*
* 只负责账户创建、冻结、扣减、释放和入账等资金变更。
*
* @property MerchantAccountRepository $accountRepository 账户仓库
* @property MerchantAccountLedgerRepository $ledgerRepository 流水仓库
*/
class MerchantAccountCommandService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantAccountRepository $accountRepository 账户仓库
* @param MerchantAccountLedgerRepository $ledgerRepository 流水仓库
* @return void
*/
public function __construct(
protected MerchantAccountRepository $accountRepository,
protected MerchantAccountLedgerRepository $ledgerRepository
@@ -27,6 +37,9 @@ class MerchantAccountCommandService extends BaseService
/**
* 获取或创建商户账户。
*
* @param int $merchantId 商户ID
* @return MerchantAccount 账户记录
*/
public function ensureAccount(int $merchantId): MerchantAccount
{
@@ -37,6 +50,10 @@ class MerchantAccountCommandService extends BaseService
/**
* 在当前事务中获取或创建商户账户。
*
* @param int $merchantId 商户ID
* @return MerchantAccount 账户记录
* @throws ValidationException
*/
public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount
{
@@ -59,6 +76,17 @@ class MerchantAccountCommandService extends BaseService
return $account;
}
/**
* 冻结可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function freezeAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) {
@@ -66,6 +94,19 @@ class MerchantAccountCommandService extends BaseService
});
}
/**
* 在当前事务中冻结可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
* @throws ValidationException
* @throws BalanceInsufficientException
*/
public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
$this->assertPositiveAmount($amount);
@@ -108,6 +149,17 @@ class MerchantAccountCommandService extends BaseService
]);
}
/**
* 扣减冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function deductFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) {
@@ -115,6 +167,18 @@ class MerchantAccountCommandService extends BaseService
});
}
/**
* 在当前事务中扣减冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
* @throws ValidationException
*/
public function deductFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
$this->assertPositiveAmount($amount);
@@ -160,6 +224,17 @@ class MerchantAccountCommandService extends BaseService
]);
}
/**
* 释放冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function releaseFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) {
@@ -167,6 +242,18 @@ class MerchantAccountCommandService extends BaseService
});
}
/**
* 在当前事务中释放冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
* @throws ValidationException
*/
public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
$this->assertPositiveAmount($amount);
@@ -213,6 +300,17 @@ class MerchantAccountCommandService extends BaseService
]);
}
/**
* 增加可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function creditAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) {
@@ -220,6 +318,18 @@ class MerchantAccountCommandService extends BaseService
});
}
/**
* 在当前事务中增加可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
* @throws ValidationException
*/
public function creditAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
$this->assertPositiveAmount($amount);
@@ -257,6 +367,17 @@ class MerchantAccountCommandService extends BaseService
]);
}
/**
* 扣减可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function debitAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->transactionRetry(function () use ($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo) {
@@ -264,6 +385,19 @@ class MerchantAccountCommandService extends BaseService
});
}
/**
* 在当前事务中扣减可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
* @throws ValidationException
* @throws BalanceInsufficientException
*/
public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
$this->assertPositiveAmount($amount);
@@ -305,6 +439,12 @@ class MerchantAccountCommandService extends BaseService
]);
}
/**
* 创建账户流水。
*
* @param array $data 流水数据
* @return MerchantAccountLedger 流水记录
*/
private function createLedger(array $data): MerchantAccountLedger
{
$data['ledger_no'] = $data['ledger_no'] ?? $this->generateNo('LG');
@@ -314,11 +454,24 @@ class MerchantAccountCommandService extends BaseService
return $this->ledgerRepository->create($data);
}
/**
* 按幂等键查询流水。
*
* @param string $idempotencyKey 幂等键
* @return MerchantAccountLedger|null 流水记录
*/
private function findLedgerByIdempotencyKey(string $idempotencyKey): ?MerchantAccountLedger
{
return $this->ledgerRepository->findByIdempotencyKey($idempotencyKey);
}
/**
* 校验金额必须大于 0。
*
* @param int $amount 金额(分)
* @return void
* @throws ValidationException
*/
private function assertPositiveAmount(int $amount): void
{
if ($amount <= 0) {
@@ -326,6 +479,17 @@ class MerchantAccountCommandService extends BaseService
}
}
/**
* 校验幂等流水与当前请求一致。
*
* @param MerchantAccountLedger $ledger 流水
* @param int $bizType 业务类型
* @param string $bizNo 业务单号
* @param int $amount 金额(分)
* @param int $direction 流向
* @return void
* @throws ConflictException
*/
private function assertLedgerMatch(MerchantAccountLedger $ledger, int $bizType, string $bizNo, int $amount, int $direction): void
{
if ((int) $ledger->biz_type !== $bizType || (int) $ledger->amount !== $amount || (string) $ledger->biz_no !== $bizNo || (int) $ledger->direction !== $direction) {
@@ -337,6 +501,13 @@ class MerchantAccountCommandService extends BaseService
}
}
/**
* 归一化追踪号。
*
* @param string $traceNo 追踪号
* @param string $bizNo 业务单号
* @return string 追踪号
*/
private function normalizeTraceNo(string $traceNo, string $bizNo): string
{
$traceNo = trim($traceNo);
@@ -347,3 +518,8 @@ class MerchantAccountCommandService extends BaseService
return $bizNo;
}
}

View File

@@ -11,9 +11,19 @@ use app\repository\account\ledger\MerchantAccountLedgerRepository;
* 商户账户查询服务。
*
* 只负责账户列表、概览和快照查询,不承载资金变更逻辑。
*
* @property MerchantAccountRepository $accountRepository 账户仓库
* @property MerchantAccountLedgerRepository $ledgerRepository 流水仓库
*/
class MerchantAccountQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantAccountRepository $accountRepository 账户仓库
* @param MerchantAccountLedgerRepository $ledgerRepository 流水仓库
* @return void
*/
public function __construct(
protected MerchantAccountRepository $accountRepository,
protected MerchantAccountLedgerRepository $ledgerRepository
@@ -22,6 +32,11 @@ class MerchantAccountQueryService 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)
{
@@ -73,6 +88,8 @@ class MerchantAccountQueryService extends BaseService
/**
* 资金中心概览。
*
* @return array 概览数据
*/
public function summary(): array
{
@@ -103,6 +120,9 @@ class MerchantAccountQueryService extends BaseService
* 获取商户余额快照。
*
* 用于后台展示和接口返回,不修改任何账户数据。
*
* @param int $merchantId 商户ID
* @return array 快照数据
*/
public function getBalanceSnapshot(int $merchantId): array
{
@@ -125,6 +145,9 @@ class MerchantAccountQueryService extends BaseService
/**
* 查询商户账户详情。
*
* @param int $id 商户账户查询ID
* @return MerchantAccount|null 账户记录
*/
public function findById(int $id): ?MerchantAccount
{
@@ -158,3 +181,6 @@ class MerchantAccountQueryService extends BaseService
}
}

View File

@@ -7,95 +7,252 @@ use app\model\merchant\MerchantAccount;
use app\model\merchant\MerchantAccountLedger;
/**
* 商户余额门面服务。
* 商户余额服务。
*
* 对外保留原有调用契约,内部委托给查询和命令两个子服务
* @property MerchantAccountQueryService $queryService 查询服务
* @property MerchantAccountCommandService $commandService 命令服务
*/
class MerchantAccountService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantAccountQueryService $queryService 查询服务
* @param MerchantAccountCommandService $commandService 命令服务
* @return void
*/
public function __construct(
protected MerchantAccountQueryService $queryService,
protected MerchantAccountCommandService $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);
}
/**
* 获取商户账户总览。
*
* @return array 总览数据
*/
public function summary(): array
{
return $this->queryService->summary();
}
/**
* 获取或创建商户账户。
*
* @param int $merchantId 商户ID
* @return MerchantAccount 账户记录
*/
public function ensureAccount(int $merchantId): MerchantAccount
{
return $this->commandService->ensureAccount($merchantId);
}
/**
* 在当前事务中获取或创建商户账户。
*
* @param int $merchantId 商户ID
* @return MerchantAccount 账户记录
*/
public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount
{
return $this->commandService->ensureAccountInCurrentTransaction($merchantId);
}
/**
* 冻结可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function freezeAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->freezeAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 在当前事务中冻结可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->freezeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 扣减冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function deductFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->deductFrozenAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 在当前事务中扣减冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function deductFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->deductFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 释放冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function releaseFrozenAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->releaseFrozenAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 在当前事务中释放冻结余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->releaseFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 增加可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function creditAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->creditAvailableAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 在当前事务中增加可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function creditAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->creditAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 扣减可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function debitAvailableAmount(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->debitAvailableAmount($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 在当前事务中扣减可用余额。
*
* @param int $merchantId 商户ID
* @param int $amount 金额(分)
* @param string $bizNo 业务单号
* @param string $idempotencyKey 幂等键
* @param array $extJson 扩展字段
* @param string $traceNo 追踪号
* @return MerchantAccountLedger 流水记录
*/
public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
{
return $this->commandService->debitAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
}
/**
* 获取余额快照。
*
* @param int $merchantId 商户ID
* @return array 快照数据
*/
public function getBalanceSnapshot(int $merchantId): array
{
return $this->queryService->getBalanceSnapshot($merchantId);
}
/**
* 按ID查询商户账户。
*
* @param int $id 商户账户ID
* @return MerchantAccount|null 账户记录
*/
public function findById(int $id): ?MerchantAccount
{
return $this->queryService->findById($id);
}
}

View File

@@ -9,11 +9,18 @@ use app\repository\account\ledger\MerchantAccountLedgerRepository;
/**
* 商户账户流水查询服务。
*
* 负责商户账户流水的列表、详情和展示字段装配。
*
* @property MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
*/
class MerchantAccountLedgerService extends BaseService
{
/**
* 构造函数,注入流水仓库
* 构造方法
*
* @param MerchantAccountLedgerRepository $merchantAccountLedgerRepository 商户账户流水仓库
* @return void
*/
public function __construct(
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository
@@ -22,6 +29,11 @@ class MerchantAccountLedgerService 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)
{
@@ -72,6 +84,9 @@ class MerchantAccountLedgerService extends BaseService
/**
* 查询流水详情。
*
* @param int $id 商户账户流水ID
* @return MerchantAccountLedger|null 流水模型
*/
public function findById(int $id): ?MerchantAccountLedger
{
@@ -84,6 +99,9 @@ class MerchantAccountLedgerService extends BaseService
/**
* 格式化记录。
*
* @param object $row 原始查询行
* @return object 格式化后的记录
*/
private function decorateRow(object $row): object
{
@@ -103,6 +121,8 @@ class MerchantAccountLedgerService extends BaseService
/**
* 构建查询。
*
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
*/
private function baseQuery()
{
@@ -136,3 +156,6 @@ class MerchantAccountLedgerService extends BaseService
}
}

View File

@@ -4,8 +4,19 @@ namespace app\service\bootstrap;
use app\common\base\BaseService;
/**
* 系统引导服务。
*
* 用于提供前端启动时需要的菜单树和字典项数据。
*/
class SystemBootstrapService extends BaseService
{
/**
* 获取指定面板的菜单树。
*
* @param string $panel 面板标识,通常为 `admin` 或 `merchant`
* @return array 菜单树
*/
public function getMenuTree(string $panel): array
{
$roles = $panel === 'merchant' ? ['common'] : ['admin'];
@@ -14,6 +25,14 @@ class SystemBootstrapService extends BaseService
return $this->normalizeRedirects($this->buildTree($nodes));
}
/**
* 获取字典项。
*
* 支持一次获取全部字典,也支持按逗号分隔的 code 过滤。
*
* @param string|null $code 字典编码
* @return array 字典数据
*/
public function getDictItems(?string $code = null): array
{
$items = $this->dictItems();
@@ -34,16 +53,33 @@ class SystemBootstrapService extends BaseService
return array_values(array_intersect_key($items, array_flip($codes)));
}
/**
* 获取面板菜单配置原始节点。
*
* @param string $panel 面板标识
* @return array 原始节点
*/
protected function menuNodes(string $panel): array
{
return (array) config("menu.$panel", config('menu.admin', []));
}
/**
* 获取系统字典原始配置。
*
* @return array 原始字典配置
*/
protected function dictItems(): array
{
return $this->normalizeDictItems((array) config('dict', []));
}
/**
* 将系统字典配置标准化为 code 索引结构。
*
* @param array $items 原始配置
* @return array 标准化后的字典项
*/
protected function normalizeDictItems(array $items): array
{
$normalized = [];
@@ -80,6 +116,13 @@ class SystemBootstrapService extends BaseService
return $normalized;
}
/**
* 按角色过滤菜单节点。
*
* @param array $nodes 菜单节点
* @param array $roles 角色集合
* @return array 过滤后的节点
*/
protected function filterByRoles(array $nodes, array $roles): array
{
return array_values(array_filter($nodes, function (array $node) use ($roles): bool {
@@ -96,6 +139,12 @@ class SystemBootstrapService extends BaseService
}));
}
/**
* 将扁平菜单节点构造成树。
*
* @param array $nodes 菜单节点
* @return array 树结构
*/
protected function buildTree(array $nodes): array
{
$grouped = [];
@@ -127,6 +176,12 @@ class SystemBootstrapService extends BaseService
return $build('0');
}
/**
* 为有子节点的菜单补充默认重定向路径。
*
* @param array $tree 菜单树
* @return array 处理后的菜单树
*/
protected function normalizeRedirects(array $tree): array
{
foreach ($tree as &$node) {
@@ -142,6 +197,12 @@ class SystemBootstrapService extends BaseService
return $tree;
}
/**
* 获取首个可渲染路径。
*
* @param array $nodes 菜单节点
* @return string|null 路径
*/
protected function firstRenderablePath(array $nodes): ?string
{
foreach ($nodes as $node) {
@@ -155,3 +216,8 @@ class SystemBootstrapService extends BaseService
}
}

View File

@@ -4,6 +4,7 @@ namespace app\service\file;
use app\common\base\BaseService;
use app\exception\BusinessStateException;
use app\exception\ResourceNotFoundException;
use app\exception\ValidationException;
use app\repository\file\FileRecordRepository;
use app\service\file\storage\StorageManager;
@@ -12,9 +13,25 @@ use Webman\Http\UploadFile;
/**
* 文件命令服务。
*
* 负责上传、远程导入和删除文件,并负责把文件内容同步到存储驱动和数据库。
*
* @property FileRecordRepository $fileRecordRepository 文件记录仓库
* @property FileRecordQueryService $fileRecordQueryService 文件记录查询服务
* @property StorageManager $storageManager 存储管理器
* @property StorageConfigService $storageConfigService 存储配置服务
*/
class FileRecordCommandService extends BaseService
{
/**
* 构造方法。
*
* @param FileRecordRepository $fileRecordRepository 文件记录仓库
* @param FileRecordQueryService $fileRecordQueryService 文件记录查询服务
* @param StorageManager $storageManager 存储管理器
* @param StorageConfigService $storageConfigService 存储配置服务
* @return void
*/
public function __construct(
protected FileRecordRepository $fileRecordRepository,
protected FileRecordQueryService $fileRecordQueryService,
@@ -23,6 +40,15 @@ class FileRecordCommandService extends BaseService
) {
}
/**
* 上传文件并创建记录。
*
* @param UploadFile $file 上传文件
* @param array $data 文件参数
* @param int $createdBy 创建人ID
* @param string $createdByName 创建人名称
* @return array 文件记录
*/
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
{
$this->assertFileUpload($file);
@@ -31,7 +57,7 @@ class FileRecordCommandService extends BaseService
try {
$scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, (string) $file->getUploadName(), (string) $file->getUploadMimeType());
$visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene);
$engine = $this->storageConfigService->defaultEngine();
$engine = $this->resolveStorageEngine($data);
$result = $this->storageManager->storeFromPath(
$sourcePath,
@@ -62,7 +88,10 @@ class FileRecordCommandService extends BaseService
'created_by_name' => $createdByName,
]);
} catch (\Throwable $e) {
$this->storageManager->delete($result);
try {
$this->storageManager->delete($result);
} catch (\Throwable) {
}
throw $e;
}
@@ -74,18 +103,28 @@ class FileRecordCommandService extends BaseService
}
}
/**
* 导入远程文件并创建记录。
*
* @param string $remoteUrl 远程地址
* @param array $data 文件参数
* @param int $createdBy 创建人ID
* @param string $createdByName 创建人名称
* @return array 文件记录
* @throws ValidationException
*/
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
{
$remoteUrl = trim($remoteUrl);
if ($remoteUrl === '') {
throw new ValidationException('远程图片地址不能为空');
throw new ValidationException('远程文件地址不能为空');
}
$download = $this->downloadRemoteFile($remoteUrl, (int) ($data['scene'] ?? 0));
try {
$scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, $download['name'], $download['mime_type']);
$visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene);
$engine = $this->storageConfigService->defaultEngine();
$engine = $this->resolveStorageEngine($data);
$result = $this->storageManager->storeFromPath(
$download['path'],
@@ -116,7 +155,10 @@ class FileRecordCommandService extends BaseService
'created_by_name' => $createdByName,
]);
} catch (\Throwable $e) {
$this->storageManager->delete($result);
try {
$this->storageManager->delete($result);
} catch (\Throwable) {
}
throw $e;
}
@@ -128,18 +170,38 @@ class FileRecordCommandService extends BaseService
}
}
/**
* 删除文件记录。
*
* @param int $id 文件记录命令ID
* @return bool 是否删除成功
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function delete(int $id): bool
{
$asset = $this->fileRecordRepository->findById($id);
if (!$asset) {
return false;
throw new ResourceNotFoundException('文件不存在', ['id' => $id]);
}
$this->storageManager->delete($this->fileRecordQueryService->formatModel($asset));
return $this->fileRecordRepository->deleteById($id);
if (!$this->fileRecordRepository->deleteById($id)) {
throw new BusinessStateException('文件删除失败');
}
return true;
}
/**
* 校验上传文件是否合法。
*
* @param UploadFile $file 文件
* @return void
* @throws ValidationException
* @throws BusinessStateException
*/
private function assertFileUpload(UploadFile $file): void
{
if (!$file->isValid()) {
@@ -162,10 +224,19 @@ class FileRecordCommandService extends BaseService
}
}
/**
* 下载远程文件到临时文件。
*
* @param string $remoteUrl 远程地址
* @param int $scene 场景
* @return array 下载结果
* @throws ValidationException
* @throws BusinessStateException
*/
private function downloadRemoteFile(string $remoteUrl, int $scene = 0): array
{
if (!filter_var($remoteUrl, FILTER_VALIDATE_URL)) {
throw new ValidationException('远程图片地址格式不正确');
throw new ValidationException('远程文件地址格式不正确');
}
$scheme = strtolower((string) parse_url($remoteUrl, PHP_URL_SCHEME));
@@ -175,7 +246,7 @@ class FileRecordCommandService extends BaseService
$host = (string) parse_url($remoteUrl, PHP_URL_HOST);
if ($host === '') {
throw new ValidationException('远程图片地址格式不正确');
throw new ValidationException('远程文件地址格式不正确');
}
if (filter_var($host, FILTER_VALIDATE_IP) && Request::isIntranetIp($host)) {
@@ -278,4 +349,22 @@ class FileRecordCommandService extends BaseService
'scene' => $scene,
];
}
/**
* 解析存储Engine
*
* @param array $data 数据
* @return int 整数结果
*/
private function resolveStorageEngine(array $data): int
{
if (!array_key_exists('storage_engine', $data) || $data['storage_engine'] === null || $data['storage_engine'] === '') {
return $this->storageConfigService->defaultEngine();
}
return $this->storageConfigService->normalizeEngine($data['storage_engine']);
}
}

View File

@@ -10,15 +10,35 @@ use app\service\file\storage\StorageManager;
/**
* 文件查询服务。
*
* 负责文件记录的分页、详情、选项和展示数据格式化。
*
* @property FileRecordRepository $fileRecordRepository 文件记录仓库
* @property StorageManager $storageManager 存储管理器
*/
class FileRecordQueryService extends BaseService
{
/**
* 构造方法。
*
* @param FileRecordRepository $fileRecordRepository 文件记录仓库
* @param StorageManager $storageManager 存储管理器
* @return void
*/
public function __construct(
protected FileRecordRepository $fileRecordRepository,
protected StorageManager $storageManager
) {
}
/**
* 分页查询文件记录。
*
* @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->fileRecordRepository->query()->from('ma_file_asset as f');
@@ -50,6 +70,13 @@ class FileRecordQueryService extends BaseService
return $paginator;
}
/**
* 查询文件记录详情。
*
* @param int $id 文件记录查询ID
* @return array 文件详情
* @throws ResourceNotFoundException
*/
public function detail(int $id): array
{
$asset = $this->fileRecordRepository->findById($id);
@@ -60,7 +87,13 @@ class FileRecordQueryService extends BaseService
return $this->formatModel($asset);
}
public function formatModel(mixed $asset): array
/**
* 将文件记录格式化为前端展示结构。
*
* @param array|object|null $asset 文件记录
* @return array<string, mixed> 展示数据
*/
public function formatModel(array|object|null $asset): array
{
$id = (int) $this->field($asset, 'id', 0);
$scene = (int) $this->field($asset, 'scene', FileConstant::SCENE_OTHER);
@@ -68,10 +101,19 @@ class FileRecordQueryService extends BaseService
$storageEngine = (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL);
$sourceType = (int) $this->field($asset, 'source_type', FileConstant::SOURCE_UPLOAD);
$size = (int) $this->field($asset, 'size', 0);
$publicUrl = (string) $this->field($asset, 'url', '');
$previewUrl = $publicUrl !== '' ? $publicUrl : $this->storageManager->temporaryUrl($this->normalizeAsset($asset));
if ($previewUrl === '' && $id > 0) {
$previewUrl = '/adminapi/file-asset/' . $id . '/preview';
$mimeType = strtolower((string) $this->field($asset, 'mime_type', ''));
$fileExt = strtolower((string) $this->field($asset, 'file_ext', ''));
$normalizedAsset = $this->normalizeAsset($asset);
$publicUrl = $visibility === FileConstant::VISIBILITY_PUBLIC
? $this->storageManager->publicUrl($normalizedAsset)
: '';
$previewable = $this->isPreviewable($scene, $mimeType, $fileExt);
$previewUrl = '';
if ($previewable) {
$previewUrl = $publicUrl !== '' ? $publicUrl : $this->storageManager->temporaryUrl($normalizedAsset);
if ($previewUrl === '' && $id > 0) {
$previewUrl = '/adminapi/file-asset/' . $id . '/preview';
}
}
return [
@@ -93,10 +135,11 @@ class FileRecordQueryService extends BaseService
'md5' => (string) $this->field($asset, 'md5', ''),
'object_key' => (string) $this->field($asset, 'object_key', ''),
'source_url' => (string) $this->field($asset, 'source_url', ''),
'url' => $previewUrl,
'url' => $publicUrl,
'public_url' => $publicUrl,
'preview_url' => $previewUrl,
'download_url' => $id > 0 ? '/adminapi/file-asset/' . $id . '/download' : '',
'previewable' => $previewable,
'created_by' => (int) $this->field($asset, 'created_by', 0),
'created_by_name' => (string) $this->field($asset, 'created_by_name', ''),
'remark' => (string) $this->field($asset, 'remark', ''),
@@ -106,6 +149,11 @@ class FileRecordQueryService extends BaseService
];
}
/**
* 获取文件记录选项。
*
* @return array<string, array<int, array{label: string, value: int}>> 选项数据
*/
public function options(): array
{
return [
@@ -117,6 +165,12 @@ class FileRecordQueryService extends BaseService
];
}
/**
* 格式化文件大小。
*
* @param int $size 文件大小(字节)
* @return string 格式化后的大小
*/
private function formatSize(int $size): string
{
if ($size <= 0) {
@@ -134,6 +188,12 @@ class FileRecordQueryService extends BaseService
return $index === 0 ? (string) (int) $value . ' ' . $units[$index] : number_format($value, 2) . ' ' . $units[$index];
}
/**
* 将映射表转换为前端选项。
*
* @param array $map 映射表
* @return array 选项列表
*/
private function toOptions(array $map): array
{
$options = [];
@@ -147,7 +207,15 @@ class FileRecordQueryService extends BaseService
return $options;
}
private function field(mixed $asset, string $key, mixed $default = null): mixed
/**
* 从数组或对象中读取字段值。
*
* @param array|object|null $asset 文件记录数据
* @param string $key 字段名
* @param mixed $default 默认值
* @return mixed 文件字段值
*/
private function field(array|object|null $asset, string $key, mixed $default = null): mixed
{
if (is_array($asset)) {
return $asset[$key] ?? $default;
@@ -160,7 +228,13 @@ class FileRecordQueryService extends BaseService
return $default;
}
private function normalizeAsset(mixed $asset): array
/**
* 归一化文件记录。
*
* @param array|object|null $asset 原始记录
* @return array<string, mixed> 标准化记录
*/
private function normalizeAsset(array|object|null $asset): array
{
return $this->field($asset, 'id', null) === null ? [] : [
'id' => (int) $this->field($asset, 'id', 0),
@@ -173,4 +247,25 @@ class FileRecordQueryService extends BaseService
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
];
}
/**
* 判断文件是否支持预览。
*
* @param int $scene 场景
* @param string $mimeType MIME 类型
* @param string $fileExt 文件扩展名
* @return bool 是否支持预览
*/
private function isPreviewable(int $scene, string $mimeType, string $fileExt): bool
{
if ($scene === FileConstant::SCENE_IMAGE || str_starts_with($mimeType, 'image/')) {
return true;
}
if ($scene === FileConstant::SCENE_TEXT || str_starts_with($mimeType, 'text/')) {
return true;
}
return in_array($fileExt, ['pem', 'crt', 'cer', 'key'], true);
}
}

View File

@@ -7,10 +7,22 @@ use app\service\file\storage\StorageManager;
use Webman\Http\UploadFile;
/**
* 文件门面服务。
* 文件记录服务。
*
* @property FileRecordQueryService $queryService 查询服务
* @property FileRecordCommandService $commandService 命令服务
* @property StorageManager $storageManager 存储管理器
*/
class FileRecordService extends BaseService
{
/**
* 构造方法。
*
* @param FileRecordQueryService $queryService 查询服务
* @param FileRecordCommandService $commandService 命令服务
* @param StorageManager $storageManager 存储管理器
* @return void
*/
public function __construct(
protected FileRecordQueryService $queryService,
protected FileRecordCommandService $commandService,
@@ -18,36 +30,85 @@ class FileRecordService 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)
{
return $this->queryService->paginate($filters, $page, $pageSize);
}
/**
* 查询文件记录详情。
*
* @param int $id 文件记录ID
* @return array 文件详情
*/
public function detail(int $id): array
{
return $this->queryService->detail($id);
}
/**
* 获取文件记录选项。
*
* @return array 选项数据
*/
public function options(): array
{
return $this->queryService->options();
}
/**
* 上传文件并创建记录。
*
* @param UploadFile $file 上传文件
* @param array $data 文件参数
* @param int $createdBy 创建人ID
* @param string $createdByName 创建人名称
* @return array 文件记录
*/
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
{
return $this->commandService->upload($file, $data, $createdBy, $createdByName);
}
/**
* 导入远程文件并创建记录。
*
* @param string $remoteUrl 远程地址
* @param array $data 文件参数
* @param int $createdBy 创建人ID
* @param string $createdByName 创建人名称
* @return array 文件记录
*/
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
{
return $this->commandService->importRemote($remoteUrl, $data, $createdBy, $createdByName);
}
/**
* 删除文件记录。
*
* @param int $id 文件记录ID
* @return bool 是否删除成功
*/
public function delete(int $id): bool
{
return $this->commandService->delete($id);
}
/**
* 获取文件预览响应。
*
* @param int $id 文件记录ID
* @return \support\Response 响应对象
*/
public function previewResponse(int $id)
{
$asset = $this->queryService->detail($id);
@@ -55,6 +116,12 @@ class FileRecordService extends BaseService
return $this->storageManager->previewResponse($asset);
}
/**
* 获取文件下载响应。
*
* @param int $id 文件记录ID
* @return \support\Response 响应对象
*/
public function downloadResponse(int $id)
{
$asset = $this->queryService->detail($id);

View File

@@ -4,32 +4,51 @@ namespace app\service\file;
use app\common\base\BaseService;
use app\common\constant\FileConstant;
use app\exception\BusinessStateException;
/**
* 文件存储配置服务。
*
* 负责读取系统配置并统一整理文件场景、可见性、扩展名和存储引擎相关规则。
*/
class StorageConfigService extends BaseService
{
/**
* 获取默认存储引擎。
*
* @return int 存储引擎
*/
public function defaultEngine(): int
{
return $this->normalizeSelectableEngine((int) sys_config(FileConstant::CONFIG_DEFAULT_ENGINE, FileConstant::STORAGE_LOCAL));
}
/**
* 获取本地公开访问基址。
*
* @return string 基础地址
* @throws BusinessStateException
*/
public function localPublicBaseUrl(): string
{
$baseUrl = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_BASE_URL, ''));
if ($baseUrl !== '') {
return rtrim($baseUrl, '/');
}
$siteUrl = trim((string) sys_config('site_url', ''));
if ($siteUrl !== '') {
$baseUrl = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_BASE_URL, ''));
if ($baseUrl !== '') {
return rtrim($baseUrl, '/');
}
return rtrim($siteUrl, '/');
}
return '';
throw new BusinessStateException('请先在系统配置中设置站点 URL');
}
/**
* 获取本地公开目录。
*
* @return string 目录
*/
public function localPublicDir(): string
{
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_DIR, 'storage/uploads'), "/ \t\n\r\0\x0B");
@@ -37,6 +56,11 @@ class StorageConfigService extends BaseService
return $dir !== '' ? $dir : 'storage/uploads';
}
/**
* 获取本地私有目录。
*
* @return string 目录
*/
public function localPrivateDir(): string
{
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PRIVATE_DIR, 'storage/private'), "/ \t\n\r\0\x0B");
@@ -44,6 +68,11 @@ class StorageConfigService extends BaseService
return $dir !== '' ? $dir : 'storage/private';
}
/**
* 获取上传大小上限。
*
* @return int 字节数
*/
public function uploadMaxSizeBytes(): int
{
$mb = max(1, (int) sys_config(FileConstant::CONFIG_UPLOAD_MAX_SIZE_MB, 20));
@@ -51,6 +80,11 @@ class StorageConfigService extends BaseService
return $mb * 1024 * 1024;
}
/**
* 获取远程下载大小上限。
*
* @return int 字节数
*/
public function remoteDownloadLimitBytes(): int
{
$mb = max(1, (int) sys_config(FileConstant::CONFIG_REMOTE_DOWNLOAD_LIMIT_MB, 10));
@@ -58,6 +92,11 @@ class StorageConfigService extends BaseService
return $mb * 1024 * 1024;
}
/**
* 获取允许上传的扩展名列表。
*
* @return array 允许的扩展名列表
*/
public function allowedExtensions(): array
{
$raw = trim((string) sys_config(FileConstant::CONFIG_ALLOWED_EXTENSIONS, implode(',', FileConstant::defaultAllowedExtensions())));
@@ -70,6 +109,11 @@ class StorageConfigService extends BaseService
return array_values(array_unique($extensions));
}
/**
* 获取阿里云 OSS 配置。
*
* @return array OSS 配置
*/
public function ossConfig(): array
{
return [
@@ -82,6 +126,11 @@ class StorageConfigService extends BaseService
];
}
/**
* 获取腾讯云 COS 配置。
*
* @return array COS 配置
*/
public function cosConfig(): array
{
return [
@@ -93,6 +142,14 @@ class StorageConfigService extends BaseService
];
}
/**
* 归一化文件场景。
*
* @param int|string|null $scene 场景
* @param string $originalName 原始文件名
* @param string $mimeType MIME 类型
* @return int 场景值
*/
public function normalizeScene(int|string|null $scene = null, string $originalName = '', string $mimeType = ''): int
{
$scene = (int) $scene;
@@ -122,6 +179,13 @@ class StorageConfigService extends BaseService
return FileConstant::SCENE_OTHER;
}
/**
* 归一化文件可见性。
*
* @param int|string|null $visibility 可见性
* @param int $scene 场景
* @return int 可见性值
*/
public function normalizeVisibility(int|string|null $visibility = null, int $scene = FileConstant::SCENE_OTHER): int
{
$visibility = (int) $visibility;
@@ -134,6 +198,12 @@ class StorageConfigService extends BaseService
: FileConstant::VISIBILITY_PRIVATE;
}
/**
* 归一化存储引擎。
*
* @param int|string|null $engine 存储引擎
* @return int 存储引擎值
*/
public function normalizeEngine(int|string|null $engine = null): int
{
$engine = (int) $engine;
@@ -141,6 +211,12 @@ class StorageConfigService extends BaseService
return $this->normalizeSelectableEngine($engine);
}
/**
* 获取场景对应的目录名。
*
* @param int $scene 场景
* @return string 目录名
*/
public function sceneFolder(int $scene): string
{
return match ($scene) {
@@ -151,6 +227,14 @@ class StorageConfigService extends BaseService
};
}
/**
* 构建对象键。
*
* @param int $scene 场景
* @param int $visibility 可见性
* @param string $extension 扩展名
* @return string 对象键
*/
public function buildObjectKey(int $scene, int $visibility, string $extension): string
{
$extension = strtolower(trim($extension, ". \t\n\r\0\x0B"));
@@ -168,6 +252,13 @@ class StorageConfigService extends BaseService
return trim($rootDir . '/' . $this->sceneFolder($scene) . '/' . $timestampPath . '/' . $name, '/');
}
/**
* 构建本地绝对路径。
*
* @param int $visibility 可见性
* @param string $objectKey 对象键
* @return string 绝对路径
*/
public function buildLocalAbsolutePath(int $visibility, string $objectKey): string
{
$root = $visibility === FileConstant::VISIBILITY_PUBLIC
@@ -178,6 +269,12 @@ class StorageConfigService extends BaseService
return rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
}
/**
* 构建本地公开访问 URL。
*
* @param string $objectKey 对象键
* @return string 访问 URL
*/
public function buildLocalPublicUrl(string $objectKey): string
{
$path = trim(str_replace('\\', '/', $objectKey), '/');
@@ -190,6 +287,12 @@ class StorageConfigService extends BaseService
return '/' . $path;
}
/**
* 归一化可选存储引擎。
*
* @param int $engine 存储引擎
* @return int 存储引擎值
*/
private function normalizeSelectableEngine(int $engine): int
{
return match ($engine) {
@@ -200,3 +303,7 @@ class StorageConfigService extends BaseService
};
}
}

View File

@@ -3,57 +3,69 @@
namespace app\service\file\storage;
use app\common\constant\FileConstant;
use app\exception\ResourceNotFoundException;
use app\service\file\StorageConfigService;
use support\Response;
/**
* 文件存储驱动抽象基类。
*
* 提供文件存储驱动公共能力。
*
* @property-read StorageConfigService $storageConfigService 存储配置服务
*/
abstract class AbstractStorageDriver implements StorageDriverInterface
{
/**
* 注入存储配置服务。
*
* @param StorageConfigService $storageConfigService 存储配置服务
*/
public function __construct(
protected StorageConfigService $storageConfigService
) {
}
/**
* 从资产数组中读取指定字段。
*
* @param array<string, mixed> $asset 文件资产数据
* @param string $key 字段名
* @param mixed $default 默认值
* @return mixed 资产字段值
*/
protected function assetValue(array $asset, string $key, mixed $default = null): mixed
{
return $asset[$key] ?? $default;
}
/**
* 解析本地存储文件的绝对路径。
*
* @param array $asset 文件资产数据
* @return string 绝对路径
*/
protected function resolveLocalAbsolutePath(array $asset): string
{
$objectKey = trim((string) $this->assetValue($asset, 'object_key', ''));
if ($objectKey === '') {
return '';
}
$visibility = (int) $this->assetValue($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE);
$candidate = '';
if ($objectKey !== '') {
$candidate = $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
if ($candidate !== '' && is_file($candidate)) {
return $candidate;
}
}
foreach (['url', 'public_url'] as $field) {
$url = trim((string) $this->assetValue($asset, $field, ''));
if ($url === '') {
continue;
}
$parsedPath = (string) parse_url($url, PHP_URL_PATH);
if ($parsedPath === '') {
continue;
}
$candidate = public_path() . DIRECTORY_SEPARATOR . ltrim($parsedPath, '/');
if (is_file($candidate)) {
return $candidate;
}
}
return $candidate;
return $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
}
/**
* 构造字符串响应。
*
* @param string $body 响应内容
* @param string $mimeType MIME 类型
* @param int $status HTTP 状态码
* @param array $headers 额外响应头
* @return Response 响应对象
*/
protected function bodyResponse(string $body, string $mimeType = 'application/octet-stream', int $status = 200, array $headers = []): Response
{
$responseHeaders = array_merge([
@@ -63,6 +75,14 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
return response($body, $status, $responseHeaders);
}
/**
* 构造文件下载响应。
*
* @param string $body 响应内容
* @param string $downloadName 下载文件名
* @param string $mimeType MIME 类型
* @return Response 响应对象
*/
protected function downloadBodyResponse(string $body, string $downloadName, string $mimeType = 'application/octet-stream'): Response
{
$response = $this->bodyResponse($body, $mimeType, 200, [
@@ -72,6 +92,14 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
return $response;
}
/**
* 根据本地路径构造预览或下载响应。
*
* @param string $path 本地文件路径
* @param string $downloadName 下载文件名
* @param bool $attachment 是否下载附件
* @return Response 响应对象
*/
protected function responseFromPath(string $path, string $downloadName = '', bool $attachment = false): Response
{
if ($attachment) {
@@ -81,26 +109,46 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
return response()->file($path);
}
/**
* 构造本地文件预览响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
* @throws ResourceNotFoundException
*/
protected function localPreviewResponse(array $asset): Response
{
$path = $this->resolveLocalAbsolutePath($asset);
if ($path === '' || !is_file($path)) {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
return $this->responseFromPath($path);
}
/**
* 构造本地文件下载响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
* @throws ResourceNotFoundException
*/
protected function localDownloadResponse(array $asset): Response
{
$path = $this->resolveLocalAbsolutePath($asset);
if ($path === '' || !is_file($path)) {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
return $this->responseFromPath($path, (string) $this->assetValue($asset, 'original_name', basename($path)), true);
}
/**
* 根据文件场景返回目录前缀。
*
* @param int $scene 文件场景
* @return string 目录前缀
*/
protected function scenePrefix(int $scene): string
{
return match ($scene) {

View File

@@ -4,20 +4,36 @@ namespace app\service\file\storage;
use app\common\constant\FileConstant;
use app\exception\BusinessStateException;
use app\exception\ResourceNotFoundException;
use Qcloud\Cos\Client as CosClient;
use support\Response;
use Throwable;
/**
* 腾讯云 COS 文件存储驱动。
*
* 负责对象上传、删除、公开地址生成和对象内容响应。
*/
class CosStorageDriver extends AbstractStorageDriver
{
/**
* 获取 COS 存储引擎标识。
*
* @return int 存储引擎常量
*/
public function engine(): int
{
return FileConstant::STORAGE_TENCENT_COS;
}
/**
* 将本地临时文件上传到 COS。
*
* @param string $sourcePath 待上传文件路径
* @param array $context 上传上下文,包含 object_key、visibility 等信息
* @return array 上传后的资产数据
* @throws BusinessStateException
*/
public function storeFromPath(string $sourcePath, array $context): array
{
if (!is_file($sourcePath)) {
@@ -33,6 +49,7 @@ class CosStorageDriver extends AbstractStorageDriver
$client = $this->client($config);
$objectKey = (string) ($context['object_key'] ?? '');
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
$client->putObject([
'Bucket' => (string) $config['bucket'],
'Key' => $objectKey,
@@ -40,23 +57,30 @@ class CosStorageDriver extends AbstractStorageDriver
]);
$publicUrl = $this->publicUrl([
'visibility' => $visibility,
'object_key' => $objectKey,
]);
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
return [
'storage_engine' => $this->engine(),
'object_key' => $objectKey,
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
'public_url' => $publicUrl,
'public_url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
];
}
/**
* 删除 COS 对象。
*
* @param array $asset 文件资产数据
* @return bool 是否删除成功
* @throws BusinessStateException
*/
public function delete(array $asset): bool
{
$config = $this->storageConfigService->cosConfig();
if (trim((string) ($config['bucket'] ?? '')) === '') {
return false;
throw new BusinessStateException('腾讯云 COS 存储配置未完整');
}
$objectKey = (string) ($asset['object_key'] ?? '');
@@ -73,23 +97,41 @@ class CosStorageDriver extends AbstractStorageDriver
return true;
}
/**
* 构造 COS 文件预览响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function previewResponse(array $asset): Response
{
$url = $this->publicUrl($asset);
if ($url !== '') {
return redirect($url);
}
return $this->responseFromObject($asset, false);
}
/**
* 构造 COS 文件下载响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response
{
return $this->responseFromObject($asset, true);
}
/**
* 获取 COS 公开访问地址。
*
* @param array $asset 文件资产数据
* @return string 公共 URL
*/
public function publicUrl(array $asset): string
{
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
return '';
}
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
if ($publicUrl !== '') {
return $publicUrl;
@@ -115,11 +157,17 @@ class CosStorageDriver extends AbstractStorageDriver
return 'https://' . $bucket . '.cos.' . $region . '.myqcloud.com/' . ltrim($objectKey, '/');
}
/**
* 获取 COS 临时访问地址。
*
* @param array $asset 文件资产数据
* @return string 临时 URL
*/
public function temporaryUrl(array $asset): string
{
$config = $this->storageConfigService->cosConfig();
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
return $this->publicUrl($asset);
return '';
}
try {
@@ -134,10 +182,16 @@ class CosStorageDriver extends AbstractStorageDriver
$objectKey
);
} catch (Throwable) {
return $this->publicUrl($asset);
return '';
}
}
/**
* 创建 COS 客户端。
*
* @param array $config 存储配置
* @return CosClient COS 客户端
*/
private function client(array $config): CosClient
{
return new CosClient([
@@ -149,13 +203,21 @@ class CosStorageDriver extends AbstractStorageDriver
]);
}
/**
* 根据 COS 对象内容构造预览或下载响应。
*
* @param array $asset 文件资产数据
* @param bool $attachment 是否下载附件
* @return Response 响应对象
* @throws ResourceNotFoundException
*/
private function responseFromObject(array $asset, bool $attachment): Response
{
$config = $this->storageConfigService->cosConfig();
$bucket = trim((string) ($config['bucket'] ?? ''));
$objectKey = (string) ($asset['object_key'] ?? '');
if ($bucket === '' || $objectKey === '') {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
try {
@@ -171,7 +233,7 @@ class CosStorageDriver extends AbstractStorageDriver
} elseif (is_object($result) && method_exists($result, '__toString')) {
$body = (string) $result;
} elseif (is_array($result)) {
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
}
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
@@ -182,7 +244,7 @@ class CosStorageDriver extends AbstractStorageDriver
return $this->bodyResponse($body, $mimeType);
} catch (Throwable) {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
}
}

View File

@@ -8,14 +8,29 @@ use support\Response;
/**
* 本地文件存储驱动。
*
* 负责本地文件存储和响应构造。
*/
class LocalStorageDriver extends AbstractStorageDriver
{
/**
* 获取本地存储引擎标识。
*
* @return int 存储引擎常量
*/
public function engine(): int
{
return FileConstant::STORAGE_LOCAL;
}
/**
* 将临时文件写入本地存储目录。
*
* @param string $sourcePath 待上传文件路径
* @param array $context 上传上下文,包含 object_key、visibility、public_url 等信息
* @return array 上传后的资产数据
* @throws BusinessStateException
*/
public function storeFromPath(string $sourcePath, array $context): array
{
if (!is_file($sourcePath)) {
@@ -51,6 +66,13 @@ class LocalStorageDriver extends AbstractStorageDriver
];
}
/**
* 删除本地文件。
*
* @param array $asset 文件资产数据
* @return bool 是否删除成功
* @throws BusinessStateException
*/
public function delete(array $asset): bool
{
$path = $this->resolveLocalAbsolutePath($asset);
@@ -58,26 +80,43 @@ class LocalStorageDriver extends AbstractStorageDriver
return true;
}
return @unlink($path);
if (@unlink($path)) {
return true;
}
throw new BusinessStateException('本地文件删除失败');
}
/**
* 构造本地文件预览响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function previewResponse(array $asset): Response
{
return $this->localPreviewResponse($asset);
}
/**
* 构造本地文件下载响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response
{
return $this->localDownloadResponse($asset);
}
/**
* 获取本地公开访问地址。
*
* @param array $asset 文件资产数据
* @return string 公共 URL
*/
public function publicUrl(array $asset): string
{
$url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
if ($url !== '') {
return $url;
}
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
return '';
@@ -91,13 +130,14 @@ class LocalStorageDriver extends AbstractStorageDriver
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
}
/**
* 获取本地临时访问地址。
*
* @param array $asset 文件资产数据
* @return string 临时 URL
*/
public function temporaryUrl(array $asset): string
{
$url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
if ($url !== '') {
return $url;
}
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
if ($visibility === FileConstant::VISIBILITY_PUBLIC) {
return $this->publicUrl($asset);
@@ -108,6 +148,12 @@ class LocalStorageDriver extends AbstractStorageDriver
return $id > 0 ? '/adminapi/file-asset/' . $id . '/preview' : '';
}
/**
* 确保目标目录存在。
*
* @param string $directory 目录路径
* @throws BusinessStateException
*/
private function ensureDirectory(string $directory): void
{
if (is_dir($directory)) {

View File

@@ -4,20 +4,36 @@ namespace app\service\file\storage;
use app\common\constant\FileConstant;
use app\exception\BusinessStateException;
use app\exception\ResourceNotFoundException;
use AlibabaCloud\Oss\V2 as Oss;
use support\Response;
use Throwable;
/**
* 阿里云 OSS 文件存储驱动。
*
* 负责对象上传、删除、公开地址生成和预签名访问。
*/
class OssStorageDriver extends AbstractStorageDriver
{
/**
* 获取 OSS 存储引擎标识。
*
* @return int 存储引擎常量
*/
public function engine(): int
{
return FileConstant::STORAGE_ALIYUN_OSS;
}
/**
* 将本地临时文件上传到 OSS。
*
* @param string $sourcePath 待上传文件路径
* @param array $context 上传上下文,包含 object_key、visibility 等信息
* @return array 上传后的资产数据
* @throws BusinessStateException
*/
public function storeFromPath(string $sourcePath, array $context): array
{
if (!is_file($sourcePath)) {
@@ -33,6 +49,9 @@ class OssStorageDriver extends AbstractStorageDriver
$client = $this->client($config);
$objectKey = (string) ($context['object_key'] ?? '');
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
/** @var Oss\Models\PutObjectRequest $request */
$request = new Oss\Models\PutObjectRequest(
bucket: (string) $config['bucket'],
key: $objectKey
@@ -42,23 +61,30 @@ class OssStorageDriver extends AbstractStorageDriver
$client->putObject($request);
$publicUrl = $this->publicUrl([
'visibility' => $visibility,
'object_key' => $objectKey,
]);
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
return [
'storage_engine' => $this->engine(),
'object_key' => $objectKey,
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
'public_url' => $publicUrl,
'public_url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
];
}
/**
* 删除 OSS 对象。
*
* @param array $asset 文件资产数据
* @return bool 是否删除成功
* @throws BusinessStateException
*/
public function delete(array $asset): bool
{
$config = $this->storageConfigService->ossConfig();
if (trim((string) ($config['bucket'] ?? '')) === '') {
return false;
throw new BusinessStateException('阿里云 OSS 存储配置未完整');
}
$objectKey = (string) ($asset['object_key'] ?? '');
@@ -67,6 +93,8 @@ class OssStorageDriver extends AbstractStorageDriver
}
$client = $this->client($config);
/** @var Oss\Models\DeleteObjectRequest $request */
$request = new Oss\Models\DeleteObjectRequest(
bucket: (string) $config['bucket'],
key: $objectKey
@@ -76,23 +104,41 @@ class OssStorageDriver extends AbstractStorageDriver
return true;
}
/**
* 构造 OSS 文件预览响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function previewResponse(array $asset): Response
{
$url = $this->publicUrl($asset);
if ($url !== '') {
return redirect($url);
}
return $this->responseFromObject($asset, false);
}
/**
* 构造 OSS 文件下载响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response
{
return $this->responseFromObject($asset, true);
}
/**
* 获取 OSS 公开访问地址。
*
* @param array $asset 文件资产数据
* @return string 公共 URL
*/
public function publicUrl(array $asset): string
{
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
return '';
}
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
if ($publicUrl !== '') {
return $publicUrl;
@@ -120,11 +166,17 @@ class OssStorageDriver extends AbstractStorageDriver
return 'https://' . $bucket . '.' . ltrim($endpoint, '/') . '/' . ltrim($objectKey, '/');
}
/**
* 获取 OSS 预签名访问地址。
*
* @param array $asset 文件资产数据
* @return string 临时 URL
*/
public function temporaryUrl(array $asset): string
{
$config = $this->storageConfigService->ossConfig();
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
return $this->publicUrl($asset);
return '';
}
try {
@@ -134,6 +186,7 @@ class OssStorageDriver extends AbstractStorageDriver
return '';
}
/** @var Oss\Models\GetObjectRequest $request */
$request = new Oss\Models\GetObjectRequest(
bucket: (string) $config['bucket'],
key: $objectKey
@@ -142,12 +195,19 @@ class OssStorageDriver extends AbstractStorageDriver
return (string) ($result->url ?? '');
} catch (Throwable) {
return $this->publicUrl($asset);
return '';
}
}
/**
* 创建 OSS 客户端。
*
* @param array $config 存储配置
* @return Oss\Client OSS 客户端
*/
private function client(array $config): Oss\Client
{
/** @var Oss\Credentials\StaticCredentialsProvider $provider */
$provider = new Oss\Credentials\StaticCredentialsProvider(
accessKeyId: (string) $config['access_key_id'],
accessKeySecret: (string) $config['access_key_secret']
@@ -165,17 +225,26 @@ class OssStorageDriver extends AbstractStorageDriver
return new Oss\Client($cfg);
}
/**
* 根据 OSS 对象内容构造预览或下载响应。
*
* @param array $asset 文件资产数据
* @param bool $attachment 是否下载附件
* @return Response 响应对象
* @throws ResourceNotFoundException
*/
private function responseFromObject(array $asset, bool $attachment): Response
{
$config = $this->storageConfigService->ossConfig();
$bucket = trim((string) ($config['bucket'] ?? ''));
$objectKey = (string) ($asset['object_key'] ?? '');
if ($bucket === '' || $objectKey === '') {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
try {
$client = $this->client($config);
/** @var Oss\Models\GetObjectRequest $request */
$request = new Oss\Models\GetObjectRequest(
bucket: $bucket,
key: $objectKey
@@ -190,7 +259,7 @@ class OssStorageDriver extends AbstractStorageDriver
return $this->bodyResponse($body, $mimeType);
} catch (Throwable) {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
}
}

View File

@@ -4,48 +4,95 @@ namespace app\service\file\storage;
use app\common\constant\FileConstant;
use app\exception\BusinessStateException;
use app\exception\ResourceNotFoundException;
use support\Response;
/**
* 远程引用驱动。
* 远程引用文件存储驱动。
*
* 仅保存原始远程 URL不做本地落盘或对象存储复制。
*/
class RemoteUrlStorageDriver extends AbstractStorageDriver
{
/**
* 获取远程引用引擎标识。
*
* @return int 存储引擎常量
*/
public function engine(): int
{
return FileConstant::STORAGE_REMOTE_URL;
}
/**
* 远程引用模式不支持直接上传。
*
* @param string $sourcePath 待上传文件路径
* @param array $context 上传上下文
* @return array 上传后的资产数据
* @throws BusinessStateException
*/
public function storeFromPath(string $sourcePath, array $context): array
{
throw new BusinessStateException('远程引用模式不支持直接上传,请先下载后再入库');
}
/**
* 远程引用模式不需要真正删除对象。
*
* @param array $asset 文件资产数据
* @return bool 是否删除成功
*/
public function delete(array $asset): bool
{
return true;
}
/**
* 直接跳转到源站地址进行预览。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
* @throws ResourceNotFoundException
*/
public function previewResponse(array $asset): Response
{
$url = (string) ($asset['source_url'] ?? $asset['url'] ?? '');
if ($url === '') {
return response('文件不存在', 404);
throw new ResourceNotFoundException('文件不存在');
}
return redirect($url);
}
/**
* 远程引用文件的下载行为与预览保持一致。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response
{
return $this->previewResponse($asset);
}
/**
* 获取原始远程地址。
*
* @param array $asset 文件资产数据
* @return string 远程 URL
*/
public function publicUrl(array $asset): string
{
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
}
/**
* 获取原始远程地址。
*
* @param array $asset 文件资产数据
* @return string 远程 URL
*/
public function temporaryUrl(array $asset): string
{
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');

View File

@@ -6,20 +6,64 @@ use support\Response;
/**
* 文件存储驱动接口。
*
* 统一定义文件存储驱动能力。
*/
interface StorageDriverInterface
{
/**
* 获取存储引擎标识。
*
* @return int 存储引擎常量
*/
public function engine(): int;
/**
* 将本地临时文件写入存储后端。
*
* @param string $sourcePath 待上传的本地临时文件路径
* @param array $context 上传上下文,通常包含 object_key、visibility 等信息
* @return array 上传后的资产数据
*/
public function storeFromPath(string $sourcePath, array $context): array;
/**
* 删除指定文件资产。
*
* @param array $asset 文件资产数据
* @return bool 是否删除成功
*/
public function delete(array $asset): bool;
/**
* 构造文件预览响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function previewResponse(array $asset): Response;
/**
* 构造文件下载响应。
*
* @param array $asset 文件资产数据
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response;
/**
* 获取公开访问地址。
*
* @param array $asset 文件资产数据
* @return string 公开 URL
*/
public function publicUrl(array $asset): string;
/**
* 获取临时访问地址。
*
* @param array $asset 文件资产数据
* @return string 临时 URL
*/
public function temporaryUrl(array $asset): string;
}

View File

@@ -8,9 +8,27 @@ use support\Response;
/**
* 文件存储驱动管理器。
*
* 负责根据存储引擎分发文件操作。
*
* @property StorageConfigService $storageConfigService 存储配置服务
* @property LocalStorageDriver $localStorageDriver 本地存储驱动
* @property OssStorageDriver $ossStorageDriver oss存储驱动
* @property CosStorageDriver $cosStorageDriver cos存储驱动
* @property RemoteUrlStorageDriver $remoteUrlStorageDriver remoteUrl存储驱动
*/
class StorageManager
{
/**
* 构造方法。
*
* @param StorageConfigService $storageConfigService 存储配置服务
* @param LocalStorageDriver $localStorageDriver 本地存储驱动
* @param OssStorageDriver $ossStorageDriver oss存储驱动
* @param CosStorageDriver $cosStorageDriver cos存储驱动
* @param RemoteUrlStorageDriver $remoteUrlStorageDriver remoteUrl存储驱动
* @return void
*/
public function __construct(
protected StorageConfigService $storageConfigService,
protected LocalStorageDriver $localStorageDriver,
@@ -20,6 +38,18 @@ class StorageManager
) {
}
/**
* 构建存储上下文。
*
* @param string $sourcePath 源文件路径
* @param string $originalName 原始文件名
* @param int|null $scene 场景
* @param int|null $visibility 可见性
* @param int|null $engine 存储引擎
* @param string|null $sourceUrl 源地址
* @param string $sourceType 来源类型
* @return array 上下文数据
*/
public function buildContext(
string $sourcePath,
string $originalName,
@@ -58,6 +88,18 @@ class StorageManager
];
}
/**
* 从文件路径保存文件。
*
* @param string $sourcePath 源文件路径
* @param string $originalName 原始文件名
* @param int|null $scene 场景
* @param int|null $visibility 可见性
* @param int|null $engine 存储引擎
* @param string|null $sourceUrl 源地址
* @param string $sourceType 来源类型
* @return array 保存结果
*/
public function storeFromPath(
string $sourcePath,
string $originalName,
@@ -73,36 +115,72 @@ class StorageManager
return array_merge($context, $driver->storeFromPath($sourcePath, $context));
}
/**
* 删除存储对象。
*
* @param array $asset 文件记录
* @return bool 是否删除成功
*/
public function delete(array $asset): bool
{
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
->delete($asset);
}
/**
* 获取预览响应。
*
* @param array $asset 文件记录
* @return Response 响应对象
*/
public function previewResponse(array $asset): Response
{
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
->previewResponse($asset);
}
/**
* 获取下载响应。
*
* @param array $asset 文件记录
* @return Response 响应对象
*/
public function downloadResponse(array $asset): Response
{
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
->downloadResponse($asset);
}
/**
* 获取公开访问 URL。
*
* @param array $asset 文件记录
* @return string 访问 URL
*/
public function publicUrl(array $asset): string
{
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
->publicUrl($asset);
}
/**
* 获取临时访问 URL。
*
* @param array $asset 文件记录
* @return string 访问 URL
*/
public function temporaryUrl(array $asset): string
{
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
->temporaryUrl($asset);
}
/**
* 解析对应的存储驱动。
*
* @param int $engine 存储引擎
* @return StorageDriverInterface 存储驱动
*/
public function resolveDriver(int $engine): StorageDriverInterface
{
return match ($engine) {
@@ -114,6 +192,14 @@ class StorageManager
};
}
/**
* 按存储引擎构建公开访问 URL。
*
* @param int $engine 存储引擎
* @param int $visibility 可见性
* @param string $objectKey 对象键
* @return string 访问 URL
*/
private function buildPublicUrlByEngine(int $engine, int $visibility, string $objectKey): string
{
if ($engine === FileConstant::STORAGE_LOCAL && $visibility === FileConstant::VISIBILITY_PUBLIC) {
@@ -123,6 +209,13 @@ class StorageManager
return '';
}
/**
* 估算 MIME 类型。
*
* @param string $sourcePath 源文件路径
* @param string $originalName 原始文件名
* @return string MIME 类型
*/
private function guessMimeType(string $sourcePath, string $originalName): string
{
$mimeType = '';
@@ -156,3 +249,5 @@ class StorageManager
};
}
}

View File

@@ -24,9 +24,37 @@ use app\service\merchant\security\MerchantApiCredentialService;
* 商户命令服务。
*
* 负责商户创建、更新、删除、密码和登录元数据这类写操作。
*
* @property MerchantRepository $merchantRepository 商户仓库
* @property MerchantGroupRepository $merchantGroupRepository 商户分组仓库
* @property MerchantQueryService $merchantQueryService 商户查询服务
* @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
* @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
* @property BizOrderRepository $bizOrderRepository 业务订单仓库
* @property RefundOrderRepository $refundOrderRepository 退款单仓库
* @property SettlementOrderRepository $settlementOrderRepository 结算订单仓库
* @property MerchantAccountService $merchantAccountService 商户账户服务
*/
class MerchantCommandService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantRepository $merchantRepository 商户仓库
* @param MerchantGroupRepository $merchantGroupRepository 商户分组仓库
* @param MerchantQueryService $merchantQueryService 商户查询服务
* @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
* @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
* @param BizOrderRepository $bizOrderRepository 业务订单仓库
* @param RefundOrderRepository $refundOrderRepository 退款单仓库
* @param SettlementOrderRepository $settlementOrderRepository 结算订单仓库
* @param MerchantAccountService $merchantAccountService 商户账户服务
* @return void
*/
public function __construct(
protected MerchantRepository $merchantRepository,
protected MerchantGroupRepository $merchantGroupRepository,
@@ -42,6 +70,13 @@ class MerchantCommandService extends BaseService
) {
}
/**
* 创建商户。
*
* @param array $data 商户数据
* @return Merchant 商户模型
* @throws ValidationException
*/
public function create(array $data): Merchant
{
return $this->transaction(function () use ($data) {
@@ -96,6 +131,13 @@ class MerchantCommandService extends BaseService
});
}
/**
* 更新商户。
*
* @param int $merchantId 商户ID
* @param array $data 商户数据
* @return Merchant|null 商户模型
*/
public function update(int $merchantId, array $data): ?Merchant
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -132,6 +174,14 @@ class MerchantCommandService extends BaseService
return $this->merchantRepository->find($merchantId);
}
/**
* 删除商户命令
*
* @param int $merchantId 商户ID
* @return bool 是否删除成功
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function delete(int $merchantId): bool
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -160,6 +210,14 @@ class MerchantCommandService extends BaseService
return $this->merchantRepository->deleteById($merchantId);
}
/**
* 重置商户密码。
*
* @param int $merchantId 商户ID
* @param string $password 新密码
* @return Merchant 商户模型
* @throws ResourceNotFoundException
*/
public function resetPassword(int $merchantId, string $password): Merchant
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -175,11 +233,25 @@ class MerchantCommandService extends BaseService
return $this->merchantRepository->find($merchantId);
}
/**
* 校验商户命令密码
*
* @param Merchant $merchant 商户
* @param string $password 密码
* @return bool 是否密码匹配
*/
public function verifyPassword(Merchant $merchant, string $password): bool
{
return $password !== '' && password_verify($password, (string) $merchant->password_hash);
}
/**
* 更新商户命令登录信息
*
* @param int $merchantId 商户ID
* @param string $ip ip
* @return void
*/
public function touchLoginMeta(int $merchantId, string $ip = ''): void
{
$this->merchantRepository->updateById($merchantId, [
@@ -188,6 +260,13 @@ class MerchantCommandService extends BaseService
]);
}
/**
* 生成或重置商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
* @throws ResourceNotFoundException
*/
public function issueCredential(int $merchantId): array
{
$merchant = $this->merchantQueryService->findById($merchantId);
@@ -205,6 +284,14 @@ class MerchantCommandService extends BaseService
];
}
/**
* 根据商户号查询已启用商户。
*
* @param string $merchantNo 商户号
* @return Merchant 商户模型
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function findEnabledMerchantByNo(string $merchantNo): Merchant
{
$merchant = $this->merchantRepository->findByMerchantNo($merchantNo);
@@ -220,6 +307,14 @@ class MerchantCommandService extends BaseService
return $merchant;
}
/**
* 校验商户是否启用。
*
* @param int $merchantId 商户ID
* @return Merchant 商户模型
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function ensureMerchantEnabled(int $merchantId): Merchant
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -235,6 +330,14 @@ class MerchantCommandService extends BaseService
return $merchant;
}
/**
* 校验商户分组是否启用。
*
* @param int $groupId 分组ID
* @return MerchantGroup 商户分组模型
* @throws ResourceNotFoundException
* @throws BusinessStateException
*/
public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup
{
$group = $this->merchantGroupRepository->find($groupId);
@@ -250,6 +353,11 @@ class MerchantCommandService extends BaseService
return $group;
}
/**
* 生成商户No
*
* @return string 商户号
*/
private function generateMerchantNo(): string
{
do {
@@ -261,6 +369,8 @@ class MerchantCommandService extends BaseService
/**
* 生成商户初始临时密码。
*
* @return string 临时密码
*/
private function generateTemporaryPassword(): string
{

View File

@@ -16,10 +16,29 @@ use app\repository\payment\trade\PayOrderRepository;
/**
* 商户总览查询服务。
*
* 负责商户资料、接口凭证、资金、路由、通道最近交易的总览拼装
* 负责拼装商户资料、接口凭证、资金、路由、通道以及最近交易和清结算的总览数据
*
* @property MerchantQueryService $merchantQueryService 商户查询服务
* @property MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
* @property PaymentPollGroupBindRepository $paymentPollGroupBindRepository 支付轮询分组绑定仓库
* @property PayOrderRepository $payOrderRepository 支付订单仓库
* @property SettlementOrderRepository $settlementOrderRepository 清结算订单仓库
*/
class MerchantOverviewQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantQueryService $merchantQueryService 商户查询服务
* @param MerchantAccountRepository $merchantAccountRepository 商户账户仓库
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
* @param PaymentPollGroupBindRepository $paymentPollGroupBindRepository 支付轮询分组绑定仓库
* @param PayOrderRepository $payOrderRepository 支付订单仓库
* @param SettlementOrderRepository $settlementOrderRepository 清结算订单仓库
*/
public function __construct(
protected MerchantQueryService $merchantQueryService,
protected MerchantAccountRepository $merchantAccountRepository,
@@ -33,6 +52,10 @@ class MerchantOverviewQueryService extends BaseService
/**
* 查询商户总览。
*
* @param int $merchantId 商户ID
* @return array 总览数据
* @throws ResourceNotFoundException
*/
public function overview(int $merchantId): array
{
@@ -128,3 +151,6 @@ class MerchantOverviewQueryService extends BaseService
];
}
}

View File

@@ -14,9 +14,21 @@ use app\repository\merchant\base\MerchantRepository;
* 商户查询服务。
*
* 负责商户列表、详情和总览这类只读查询。
*
* @property MerchantRepository $merchantRepository 商户仓库
* @property MerchantGroupRepository $merchantGroupRepository 商户分组仓库
* @property MerchantPolicyRepository $merchantPolicyRepository 商户策略仓库
*/
class MerchantQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantRepository $merchantRepository 商户仓库
* @param MerchantGroupRepository $merchantGroupRepository 商户分组仓库
* @param MerchantPolicyRepository $merchantPolicyRepository 商户策略仓库
* @return void
*/
public function __construct(
protected MerchantRepository $merchantRepository,
protected MerchantGroupRepository $merchantGroupRepository,
@@ -24,6 +36,14 @@ class MerchantQueryService 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)
{
$query = $this->merchantRepository->query()
@@ -94,6 +114,14 @@ class MerchantQueryService extends BaseService
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
}
/**
* 分页查询商户列表并附带分组选项。
*
* @param array $filters 筛选条件
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 页面数据
*/
public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array
{
$paginator = $this->paginate($filters, $page, $pageSize);
@@ -107,6 +135,11 @@ class MerchantQueryService extends BaseService
];
}
/**
* 获取启用商户下拉选项。
*
* @return array 商户选项列表
*/
public function enabledOptions(): array
{
return $this->merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name'])
@@ -120,6 +153,11 @@ class MerchantQueryService extends BaseService
->all();
}
/**
* 获取启用商户分组选项。
*
* @return array 分组选项列表
*/
public function enabledGroupOptions(): array
{
return $this->merchantGroupRepository->enabledList(['id', 'group_name'])
@@ -133,6 +171,14 @@ class MerchantQueryService extends BaseService
->all();
}
/**
* 搜索启用商户下拉选项。
*
* @param array $filters 筛选条件
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 搜索结果
*/
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
{
$query = $this->merchantRepository->query()
@@ -181,6 +227,12 @@ class MerchantQueryService extends BaseService
];
}
/**
* 按 ID 查询商户详情。
*
* @param int $merchantId 商户ID
* @return object|null 商户详情对象
*/
public function findById(int $merchantId): ?object
{
return $this->merchantRepository->query()
@@ -217,11 +269,23 @@ class MerchantQueryService extends BaseService
->first();
}
/**
* 查询商户策略。
*
* @param int $merchantId 商户ID
* @return MerchantPolicy|null 商户策略模型
*/
public function findPolicy(int $merchantId): ?MerchantPolicy
{
return $this->merchantPolicyRepository->findByMerchantId($merchantId);
}
/**
* 规范化商户 ID 列表。
*
* @param array|string|int $ids 原始 ID 值
* @return array 正整数 ID 列表
*/
private function normalizeIds(array|string|int $ids): array
{
if (is_string($ids)) {
@@ -233,3 +297,4 @@ class MerchantQueryService extends BaseService
return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0));
}
}

View File

@@ -8,12 +8,22 @@ use app\model\merchant\MerchantGroup;
use app\model\merchant\MerchantPolicy;
/**
* 商户基础服务门面
* 商户服务
*
* 仅保留现有控制器和其他服务依赖的统一入口。
* @property MerchantQueryService $queryService 查询服务
* @property MerchantCommandService $commandService 命令服务
* @property MerchantOverviewQueryService $overviewQueryService 总览查询服务
*/
class MerchantService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantQueryService $queryService 查询服务
* @param MerchantCommandService $commandService 命令服务
* @param MerchantOverviewQueryService $overviewQueryService 总览查询服务
* @return void
*/
public function __construct(
protected MerchantQueryService $queryService,
protected MerchantCommandService $commandService,
@@ -21,36 +31,83 @@ class MerchantService 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)
{
return $this->queryService->paginate($filters, $page, $pageSize);
}
/**
* 分页查询商户列表并附带分组选项。
*
* @param array $filters 筛选条件
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 页面数据
*/
public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array
{
return $this->queryService->paginateWithGroupOptions($filters, $page, $pageSize);
}
/**
* 获取启用商户下拉选项。
*
* @return array 商户选项列表
*/
public function enabledOptions(): array
{
return $this->queryService->enabledOptions();
}
/**
* 搜索启用商户下拉选项。
*
* @param array $filters 筛选条件
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 搜索结果
*/
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
{
return $this->queryService->searchOptions($filters, $page, $pageSize);
}
/**
* 按 ID 查询商户详情。
*
* @param int $merchantId 商户ID
* @return object|null 商户详情
*/
public function findById(int $merchantId): ?object
{
return $this->queryService->findById($merchantId);
}
/**
* 创建商户。
*
* @param array $data 商户数据
* @return Merchant 商户模型
*/
public function create(array $data): Merchant
{
return $this->commandService->create($data);
}
/**
* 创建商户并返回详情。
*
* @param array $data 商户数据
* @return object|null 商户详情
*/
public function createWithDetail(array $data): ?object
{
$merchant = $this->create($data);
@@ -62,11 +119,25 @@ class MerchantService extends BaseService
return $detail ?? $merchant;
}
/**
* 更新商户。
*
* @param int $merchantId 商户ID
* @param array $data 商户数据
* @return Merchant|null 商户模型
*/
public function update(int $merchantId, array $data): ?Merchant
{
return $this->commandService->update($merchantId, $data);
}
/**
* 更新商户并返回详情。
*
* @param int $merchantId 商户ID
* @param array $data 商户数据
* @return object|null 商户详情
*/
public function updateWithDetail(int $merchantId, array $data): ?object
{
$merchant = $this->update($merchantId, $data);
@@ -77,53 +148,118 @@ class MerchantService extends BaseService
return $this->findById($merchantId);
}
/**
* 删除商户。
*
* @param int $merchantId 商户ID
* @return bool 是否删除成功
*/
public function delete(int $merchantId): bool
{
return $this->commandService->delete($merchantId);
}
/**
* 重置商户密码。
*
* @param int $merchantId 商户ID
* @param string $password 新密码
* @return Merchant 商户模型
*/
public function resetPassword(int $merchantId, string $password): Merchant
{
return $this->commandService->resetPassword($merchantId, $password);
}
/**
* 校验商户密码。
*
* @param Merchant $merchant 商户
* @param string $password 密码
* @return bool 是否匹配
*/
public function verifyPassword(Merchant $merchant, string $password): bool
{
return $this->commandService->verifyPassword($merchant, $password);
}
/**
* 更新商户登录信息。
*
* @param int $merchantId 商户ID
* @param string $ip 登录 IP
* @return void
*/
public function touchLoginMeta(int $merchantId, string $ip = ''): void
{
$this->commandService->touchLoginMeta($merchantId, $ip);
}
/**
* 生成或重置商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function issueCredential(int $merchantId): array
{
return $this->commandService->issueCredential($merchantId);
}
/**
* 获取商户总览。
*
* @param int $merchantId 商户ID
* @return array 总览数据
*/
public function overview(int $merchantId): array
{
return $this->overviewQueryService->overview($merchantId);
}
/**
* 根据商户号查询已启用商户。
*
* @param string $merchantNo 商户号
* @return Merchant 商户模型
*/
public function findEnabledMerchantByNo(string $merchantNo): Merchant
{
return $this->commandService->findEnabledMerchantByNo($merchantNo);
}
/**
* 校验商户是否启用。
*
* @param int $merchantId 商户ID
* @return Merchant 商户模型
*/
public function ensureMerchantEnabled(int $merchantId): Merchant
{
return $this->commandService->ensureMerchantEnabled($merchantId);
}
/**
* 校验商户分组是否启用。
*
* @param int $groupId 分组ID
* @return MerchantGroup 商户分组模型
*/
public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup
{
return $this->commandService->ensureMerchantGroupEnabled($groupId);
}
/**
* 查询商户策略。
*
* @param int $merchantId 商户ID
* @return MerchantPolicy|null 商户策略模型
*/
public function findPolicy(int $merchantId): ?MerchantPolicy
{
return $this->queryService->findPolicy($merchantId);
}
}

View File

@@ -13,9 +13,23 @@ use app\service\merchant\portal\MerchantPortalSupportService;
/**
* 商户认证服务。
*
* @property MerchantRepository $merchantRepository 商户仓库
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @property MerchantPortalSupportService $merchantPortalSupportService 商户门户支持服务
* @property JwtTokenManager $jwtTokenManager jwtToken管理器
*/
class MerchantAuthService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantRepository $merchantRepository 商户仓库
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @param MerchantPortalSupportService $merchantPortalSupportService 商户门户支持服务
* @param JwtTokenManager $jwtTokenManager jwtToken管理器
* @return void
*/
public function __construct(
protected MerchantRepository $merchantRepository,
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
@@ -26,6 +40,10 @@ class MerchantAuthService extends BaseService
/**
* 获取当前登录商户的资料。
*
* @param int $merchantId 商户ID
* @param string $merchantNo 商户号
* @return array{merchant_id: int, merchant_no: string, merchant: array<string, mixed>, user: array<string, mixed>, roles: array<int, string>, permissions: array<int, string>} 商户资料
*/
public function profile(int $merchantId, string $merchantNo = ''): array
{
@@ -78,6 +96,11 @@ class MerchantAuthService extends BaseService
/**
* 校验商户登录 token并返回商户与登录态信息。
*
* @param string $token 登录令牌
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return array{merchant: Merchant, credential: \app\model\merchant\MerchantApiCredential|null}|null 登录态
*/
public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?array
{
@@ -107,6 +130,13 @@ class MerchantAuthService extends BaseService
/**
* 校验商户登录凭证并签发 JWT。
*
* @param string $merchantNo 商户号
* @param string $password 密码
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, sign_type: int, last_used_at: mixed}|null} 登录结果
* @throws ValidationException
*/
public function authenticateCredentials(string $merchantNo, string $password, string $ip = '', string $userAgent = ''): array
{
@@ -136,6 +166,9 @@ class MerchantAuthService extends BaseService
/**
* 撤销当前商户登录 token。
*
* @param string $token 登录令牌
* @return bool 是否撤销成功
*/
public function revokeToken(string $token): bool
{
@@ -144,6 +177,13 @@ class MerchantAuthService extends BaseService
/**
* 签发新的商户登录 token。
*
* @param int $merchantId 商户ID
* @param int $ttlSeconds 过期秒数
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return array{token: string, expires_in: int, merchant: Merchant, credential: array{status: int, sign_type: int, last_used_at: mixed}|null} 登录结果
* @throws ValidationException
*/
public function issueToken(int $merchantId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array
{
@@ -179,3 +219,8 @@ class MerchantAuthService extends BaseService
}
}

View File

@@ -9,11 +9,15 @@ use app\repository\merchant\base\MerchantGroupRepository;
/**
* 商户分组管理服务。
*
* @property MerchantGroupRepository $merchantGroupRepository 商户分组仓库
*/
class MerchantGroupService extends BaseService
{
/**
* 构造函数,注入对应依赖
* 构造方法
*
* @param MerchantGroupRepository $merchantGroupRepository 商户分组仓库
*/
public function __construct(
protected MerchantGroupRepository $merchantGroupRepository
@@ -24,6 +28,8 @@ class MerchantGroupService extends BaseService
* 获取启用中的商户分组选项。
*
* 前端筛选框直接使用 `label / value` 结构即可。
*
* @return array 启用分组选项
*/
public function enabledOptions(): array
{
@@ -40,6 +46,11 @@ class MerchantGroupService 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)
{
@@ -66,6 +77,9 @@ class MerchantGroupService extends BaseService
/**
* 根据 ID 查询商户分组。
*
* @param int $id 商户分组ID
* @return MerchantGroup|null 商户分组模型
*/
public function findById(int $id): ?MerchantGroup
{
@@ -74,6 +88,9 @@ class MerchantGroupService extends BaseService
/**
* 新增商户分组。
*
* @param array $data 分组数据
* @return MerchantGroup 商户分组模型
*/
public function create(array $data): MerchantGroup
{
@@ -83,6 +100,10 @@ class MerchantGroupService extends BaseService
/**
* 更新商户分组。
*
* @param int $id 商户分组ID
* @param array $data 分组数据
* @return MerchantGroup|null 商户分组模型
*/
public function update(int $id, array $data): ?MerchantGroup
{
@@ -96,6 +117,9 @@ class MerchantGroupService extends BaseService
/**
* 删除商户分组。
*
* @param int $id 商户分组ID
* @return bool 是否删除成功
*/
public function delete(int $id): bool
{
@@ -104,6 +128,11 @@ class MerchantGroupService extends BaseService
/**
* 校验商户分组名称唯一。
*
* @param string $groupName 分组名称
* @param int $ignoreId 忽略的分组ID
* @return void
* @throws ValidationException
*/
private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void
{
@@ -118,3 +147,8 @@ class MerchantGroupService extends BaseService
}
}

View File

@@ -10,9 +10,18 @@ use app\repository\merchant\base\MerchantRepository;
/**
* 商户策略服务。
*
* @property MerchantPolicyRepository $merchantPolicyRepository 商户策略仓库
* @property MerchantRepository $merchantRepository 商户仓库
*/
class MerchantPolicyService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPolicyRepository $merchantPolicyRepository 商户策略仓库
* @param MerchantRepository $merchantRepository 商户仓库
*/
public function __construct(
protected MerchantPolicyRepository $merchantPolicyRepository,
protected MerchantRepository $merchantRepository
@@ -21,6 +30,11 @@ class MerchantPolicyService 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)
{
@@ -106,6 +120,9 @@ class MerchantPolicyService extends BaseService
/**
* 查询单个商户的策略。
*
* @param int $merchantId 商户ID
* @return MerchantPolicy|null 商户策略模型
*/
public function findByMerchantId(int $merchantId): ?MerchantPolicy
{
@@ -114,6 +131,11 @@ class MerchantPolicyService extends BaseService
/**
* 保存商户策略。
*
* @param int $merchantId 商户ID
* @param array $data 策略数据
* @return MerchantPolicy 商户策略模型
* @throws ResourceNotFoundException
*/
public function saveByMerchantId(int $merchantId, array $data): MerchantPolicy
{
@@ -139,12 +161,21 @@ class MerchantPolicyService extends BaseService
/**
* 删除商户策略。
*
* @param int $merchantId 商户ID
* @return bool 是否删除成功
*/
public function deleteByMerchantId(int $merchantId): bool
{
return $this->merchantPolicyRepository->deleteWhere(['merchant_id' => $merchantId]) > 0;
}
/**
* 将结算周期值转换为文本。
*
* @param int $value 结算周期值
* @return string 结算周期文本
*/
private function settlementCycleText(int $value): string
{
return match ($value) {
@@ -159,3 +190,8 @@ class MerchantPolicyService extends BaseService
}

View File

@@ -8,9 +8,22 @@ use app\service\account\ledger\MerchantAccountLedgerService;
/**
* 商户门户余额服务。
*
* 负责余额快照、可提现余额和资金流水页面的数据拼装。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property MerchantAccountService $merchantAccountService 商户账户服务
* @property MerchantAccountLedgerService $merchantAccountLedgerService 商户账户流水服务
*/
class MerchantPortalBalanceService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param MerchantAccountService $merchantAccountService 商户账户服务
* @param MerchantAccountLedgerService $merchantAccountLedgerService 商户账户流水服务
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected MerchantAccountService $merchantAccountService,
@@ -18,13 +31,19 @@ class MerchantPortalBalanceService extends BaseService
) {
}
/**
* 查询商户门户可提现余额。
*
* @param int $merchantId 商户ID
* @return array 余额摘要
*/
public function withdrawableBalance(int $merchantId): array
{
$merchant = $this->supportService->merchantSummary($merchantId);
$snapshot = $this->merchantAccountService->getBalanceSnapshot($merchantId);
$snapshot['available_balance_text'] = $this->supportService->formatAmount((int) ($snapshot['available_balance'] ?? 0));
$snapshot['frozen_balance_text'] = $this->supportService->formatAmount((int) ($snapshot['frozen_balance'] ?? 0));
$snapshot['available_balance_text'] = $this->formatAmount((int) ($snapshot['available_balance'] ?? 0));
$snapshot['frozen_balance_text'] = $this->formatAmount((int) ($snapshot['frozen_balance'] ?? 0));
$snapshot['withdrawable_balance_text'] = $snapshot['available_balance_text'];
return [
@@ -33,6 +52,15 @@ class MerchantPortalBalanceService extends BaseService
];
}
/**
* 查询商户门户资金流水。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 流水列表数据
*/
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
{
$filters['merchant_id'] = $merchantId;
@@ -48,3 +76,5 @@ class MerchantPortalBalanceService extends BaseService
];
}
}

View File

@@ -11,9 +11,18 @@ use app\repository\payment\config\PaymentChannelRepository;
* 商户门户通道查询服务。
*
* 负责商户通道列表查询和通道行格式化。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
*/
class MerchantPortalChannelQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param PaymentChannelRepository $paymentChannelRepository 支付渠道仓库
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected PaymentChannelRepository $paymentChannelRepository
@@ -22,6 +31,12 @@ class MerchantPortalChannelQueryService extends BaseService
/**
* 查询当前商户的通道列表。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 通道列表数据
*/
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
{
@@ -95,19 +110,27 @@ class MerchantPortalChannelQueryService extends BaseService
];
}
/**
* 为通道行补充文本字段。
*
* @param object $row 通道行
* @return object 处理后的通道行
*/
private function decorateChannelRow(object $row): object
{
$row->channel_mode_text = (string) (RouteConstant::channelModeMap()[(int) $row->channel_mode] ?? '未知');
$row->status_text = (string) (CommonConstant::statusMap()[(int) $row->status] ?? '未知');
$row->split_rate_text = $this->supportService->formatRate((int) $row->split_rate_bp);
$row->cost_rate_text = $this->supportService->formatRate((int) $row->cost_rate_bp);
$row->daily_limit_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->daily_limit_amount);
$row->daily_limit_count_text = $this->supportService->formatCountOrUnlimited((int) $row->daily_limit_count);
$row->min_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->min_amount);
$row->max_amount_text = $this->supportService->formatAmountOrUnlimited((int) $row->max_amount);
$row->created_at_text = $this->supportService->formatDateTime($row->created_at ?? null);
$row->updated_at_text = $this->supportService->formatDateTime($row->updated_at ?? null);
$row->split_rate_text = $this->formatRate((int) $row->split_rate_bp);
$row->cost_rate_text = $this->formatRate((int) $row->cost_rate_bp);
$row->daily_limit_amount_text = $this->formatAmountOrUnlimited((int) $row->daily_limit_amount);
$row->daily_limit_count_text = $this->formatCountOrUnlimited((int) $row->daily_limit_count);
$row->min_amount_text = $this->formatAmountOrUnlimited((int) $row->min_amount);
$row->max_amount_text = $this->formatAmountOrUnlimited((int) $row->max_amount);
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
$row->updated_at_text = $this->formatDateTime($row->updated_at ?? null);
return $row;
}
}

View File

@@ -5,25 +5,51 @@ namespace app\service\merchant\portal;
use app\common\base\BaseService;
/**
* 商户门户通道门面服务。
* 商户门户通道服务。
*
* 对外保留原有调用契约,内部委托给通道查询和路由预览子服务
* @property MerchantPortalChannelQueryService $queryService 查询服务
* @property MerchantPortalRoutePreviewService $routePreviewService 路由解析服务
*/
class MerchantPortalChannelService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalChannelQueryService $queryService 查询服务
* @param MerchantPortalRoutePreviewService $routePreviewService 路由解析服务
*/
public function __construct(
protected MerchantPortalChannelQueryService $queryService,
protected MerchantPortalRoutePreviewService $routePreviewService
) {
}
/**
* 查询当前商户已开通的渠道。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 渠道列表数据
*/
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->queryService->myChannels($filters, $merchantId, $page, $pageSize);
}
/**
* 获取商户渠道路由解析结果。
*
* @param int $merchantId 商户ID
* @param int $payTypeId 支付类型ID
* @param int $payAmount 支付金额
* @param string $statDate 统计日期
* @return array 路由解析数据
*/
public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array
{
return $this->routePreviewService->routePreview($merchantId, $payTypeId, $payAmount, $statDate);
}
}

View File

@@ -7,10 +7,21 @@ use app\repository\merchant\credential\MerchantApiCredentialRepository;
use app\service\merchant\security\MerchantApiCredentialService;
/**
* 商户门户接口凭证命令服务。
* 商户门户 API 凭证命令服务。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @property MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
*/
class MerchantPortalCredentialCommandService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @param MerchantApiCredentialService $merchantApiCredentialService 商户 API 凭证服务
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
@@ -18,10 +29,17 @@ class MerchantPortalCredentialCommandService extends BaseService
) {
}
/**
* 生成或重置商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function issueCredential(int $merchantId): array
{
$merchant = $this->supportService->merchantSummary($merchantId);
$credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId);
// 凭证明文只在发放当次返回一次,随后再查库只拿脱敏后的展示结构。
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
return [
@@ -31,6 +49,13 @@ class MerchantPortalCredentialCommandService extends BaseService
];
}
/**
* 格式化接口凭证展示数据。
*
* @param \app\model\merchant\MerchantApiCredential $credential 凭证
* @param array $merchant 商户摘要
* @return array 展示数据
*/
private function formatCredential(\app\model\merchant\MerchantApiCredential $credential, array $merchant): array
{
$signType = (int) $credential->sign_type;
@@ -42,12 +67,13 @@ class MerchantPortalCredentialCommandService extends BaseService
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
'sign_type' => $signType,
'sign_type_text' => $this->supportService->signTypeText($signType),
'api_key_preview' => $this->supportService->maskCredentialValue((string) $credential->api_key),
// 展示页只保留脱敏后的 key 片段,避免明文凭证再次暴露。
'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key),
'status' => (int) $credential->status,
'status_text' => (string) ($credential->status ? '启用' : '禁用'),
'last_used_at' => $this->supportService->formatDateTime($credential->last_used_at ?? null),
'created_at' => $this->supportService->formatDateTime($credential->created_at ?? null),
'updated_at' => $this->supportService->formatDateTime($credential->updated_at ?? null),
'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null),
'created_at' => $this->formatDateTime($credential->created_at ?? null),
'updated_at' => $this->formatDateTime($credential->updated_at ?? null),
];
}
}

View File

@@ -8,16 +8,31 @@ use app\model\merchant\MerchantApiCredential;
use app\repository\merchant\credential\MerchantApiCredentialRepository;
/**
* 商户门户接口凭证查询服务。
* 商户门户 API 凭证查询服务。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
*/
class MerchantPortalCredentialQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected MerchantApiCredentialRepository $merchantApiCredentialRepository
) {
}
/**
* 查询商户 API 凭证详情。
*
* @param int $merchantId 商户ID
* @return array 凭证详情
*/
public function apiCredential(int $merchantId): array
{
$merchant = $this->supportService->merchantSummary($merchantId);
@@ -30,6 +45,13 @@ class MerchantPortalCredentialQueryService extends BaseService
];
}
/**
* 格式化接口凭证展示数据。
*
* @param MerchantApiCredential $credential 凭证
* @param array $merchant 商户摘要
* @return array 展示数据
*/
private function formatCredential(MerchantApiCredential $credential, array $merchant): array
{
$signType = (int) $credential->sign_type;
@@ -42,12 +64,14 @@ class MerchantPortalCredentialQueryService extends BaseService
'merchant_name' => (string) ($merchant['merchant_name'] ?? ''),
'sign_type' => $signType,
'sign_type_text' => $this->supportService->signTypeText($signType),
'api_key_preview' => $this->supportService->maskCredentialValue((string) $credential->api_key),
'api_key_preview' => $this->maskCredentialValue((string) $credential->api_key),
'status' => $status,
'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'),
'last_used_at' => $this->supportService->formatDateTime($credential->last_used_at ?? null),
'created_at' => $this->supportService->formatDateTime($credential->created_at ?? null),
'updated_at' => $this->supportService->formatDateTime($credential->updated_at ?? null),
'last_used_at' => $this->formatDateTime($credential->last_used_at ?? null),
'created_at' => $this->formatDateTime($credential->created_at ?? null),
'updated_at' => $this->formatDateTime($credential->updated_at ?? null),
];
}
}

View File

@@ -5,23 +5,45 @@ namespace app\service\merchant\portal;
use app\common\base\BaseService;
/**
* 商户门户接口凭证门面服务。
* 商户门户 API 凭证服务。
*
* @property MerchantPortalCredentialQueryService $queryService 查询服务
* @property MerchantPortalCredentialCommandService $commandService 命令服务
*/
class MerchantPortalCredentialService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalCredentialQueryService $queryService 查询服务
* @param MerchantPortalCredentialCommandService $commandService 命令服务
*/
public function __construct(
protected MerchantPortalCredentialQueryService $queryService,
protected MerchantPortalCredentialCommandService $commandService
) {
}
/**
* 查询商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function apiCredential(int $merchantId): array
{
return $this->queryService->apiCredential($merchantId);
}
/**
* 生成或重置商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function issueCredential(int $merchantId): array
{
return $this->commandService->issueCredential($merchantId);
}
}

View File

@@ -5,35 +5,76 @@ namespace app\service\merchant\portal;
use app\common\base\BaseService;
/**
* 商户门户资金与清算门面服务。
* 商户门户资金与清算服务。
*
* 对外保留原有调用契约,内部委托给清算与余额子服务
* @property MerchantPortalSettlementService $settlementService 结算服务
* @property MerchantPortalBalanceService $balanceService 余额服务
*/
class MerchantPortalFinanceService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSettlementService $settlementService 结算服务
* @param MerchantPortalBalanceService $balanceService 余额服务
*/
public function __construct(
protected MerchantPortalSettlementService $settlementService,
protected MerchantPortalBalanceService $balanceService
) {
}
/**
* 查询商户结算记录。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 结算记录列表
*/
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->settlementService->settlementRecords($filters, $merchantId, $page, $pageSize);
}
/**
* 查询商户结算记录详情。
*
* @param string $settleNo 结算单号
* @param int $merchantId 商户ID
* @return array|null 结算详情
*/
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
{
return $this->settlementService->settlementRecordDetail($settleNo, $merchantId);
}
/**
* 查询商户可提现余额。
*
* @param int $merchantId 商户ID
* @return array 余额数据
*/
public function withdrawableBalance(int $merchantId): array
{
return $this->balanceService->withdrawableBalance($merchantId);
}
/**
* 查询商户资金流水。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 流水列表
*/
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->balanceService->balanceFlows($filters, $merchantId, $page, $pageSize);
}
}

View File

@@ -9,15 +9,32 @@ use app\repository\merchant\base\MerchantRepository;
/**
* 商户门户资料命令服务。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property MerchantRepository $merchantRepository 商户仓库
*/
class MerchantPortalProfileCommandService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param MerchantRepository $merchantRepository 商户仓库
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected MerchantRepository $merchantRepository
) {
}
/**
* 更新商户门户资料。
*
* @param int $merchantId 商户ID
* @param array $data 资料数据
* @return array 更新后的资料数据
* @throws ResourceNotFoundException
*/
public function updateProfile(int $merchantId, array $data): array
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -42,6 +59,15 @@ class MerchantPortalProfileCommandService extends BaseService
];
}
/**
* 修改商户门户密码。
*
* @param int $merchantId 商户ID
* @param array $data 密码数据
* @return array 密码修改结果
* @throws ResourceNotFoundException
* @throws ValidationException
*/
public function changePassword(int $merchantId, array $data): array
{
$merchant = $this->merchantRepository->find($merchantId);
@@ -63,7 +89,8 @@ class MerchantPortalProfileCommandService extends BaseService
return [
'updated' => true,
'password_updated_at' => $this->supportService->formatDateTime($this->now()),
'password_updated_at' => $this->formatDateTime($this->now()),
];
}
}

View File

@@ -6,14 +6,27 @@ use app\common\base\BaseService;
/**
* 商户门户资料查询服务。
*
* @property MerchantPortalSupportService $supportService 支持服务
*/
class MerchantPortalProfileQueryService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
*/
public function __construct(
protected MerchantPortalSupportService $supportService
) {
}
/**
* 查询商户门户资料页数据。
*
* @param int $merchantId 商户ID
* @return array 页面数据
*/
public function profile(int $merchantId): array
{
return [
@@ -22,3 +35,6 @@ class MerchantPortalProfileQueryService extends BaseService
];
}
}

View File

@@ -5,28 +5,59 @@ namespace app\service\merchant\portal;
use app\common\base\BaseService;
/**
* 商户门户资料门面服务。
* 商户门户资料服务。
*
* @property MerchantPortalProfileQueryService $queryService 查询服务
* @property MerchantPortalProfileCommandService $commandService 命令服务
*/
class MerchantPortalProfileService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalProfileQueryService $queryService 查询服务
* @param MerchantPortalProfileCommandService $commandService 命令服务
*/
public function __construct(
protected MerchantPortalProfileQueryService $queryService,
protected MerchantPortalProfileCommandService $commandService
) {
}
/**
* 查询商户门户资料。
*
* @param int $merchantId 商户ID
* @return array 资料数据
*/
public function profile(int $merchantId): array
{
return $this->queryService->profile($merchantId);
}
/**
* 更新商户门户资料。
*
* @param int $merchantId 商户ID
* @param array $data 资料数据
* @return array 更新后的资料数据
*/
public function updateProfile(int $merchantId, array $data): array
{
return $this->commandService->updateProfile($merchantId, $data);
}
/**
* 修改商户门户密码。
*
* @param int $merchantId 商户ID
* @param array $data 密码数据
* @return array 密码修改结果
*/
public function changePassword(int $merchantId, array $data): array
{
return $this->commandService->changePassword($merchantId, $data);
}
}

View File

@@ -10,10 +10,21 @@ use app\service\payment\runtime\PaymentRouteService;
use Throwable;
/**
* 商户门户路由预览服务。
* 商户门户路由解析服务。
*
* 负责根据商户分组、支付方式和金额解析路由结果。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property PaymentRouteService $paymentRouteService 支付路由服务
*/
class MerchantPortalRoutePreviewService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param PaymentRouteService $paymentRouteService 支付路由服务
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected PaymentRouteService $paymentRouteService
@@ -21,22 +32,30 @@ class MerchantPortalRoutePreviewService extends BaseService
}
/**
* 预览当前商户的路由选择结果。
* 获取当前商户的路由解析结果。
*
* @param int $merchantId 商户ID
* @param int $payTypeId 支付类型ID
* @param int $payAmount 支付金额
* @param string $statDate 统计日期
* @return array 路由解析数据
*/
public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array
{
// 先拿商户摘要,后面即使路由解析失败,也还能返回基础商户信息给前端展示。
$merchant = $this->supportService->merchantSummary($merchantId);
$statDate = trim($statDate) !== '' ? trim($statDate) : FormatHelper::timestamp(time(), 'Y-m-d');
// 先组一个可直接渲染的基础响应结构,失败时只需要改 reason 和可用状态。
$response = [
'merchant' => $merchant,
'pay_types' => $this->supportService->enabledPayTypeOptions(),
'pay_type_id' => $payTypeId,
'pay_amount' => $payAmount,
'pay_amount_text' => $this->supportService->formatAmount($payAmount),
'pay_amount_text' => $this->formatAmount($payAmount),
'stat_date' => $statDate,
'available' => false,
'reason' => '请选择支付方式和金额后预览路由',
'reason' => '请选择支付方式和金额后解析路由',
'merchant_group_id' => (int) ($merchant['merchant_group_id'] ?? 0),
'merchant_group_name' => (string) ($merchant['merchant_group_name'] ?? ''),
'bind' => null,
@@ -50,11 +69,12 @@ class MerchantPortalRoutePreviewService extends BaseService
}
if ((int) $merchant['merchant_group_id'] <= 0) {
$response['reason'] = '当前商户未配置商户分组,无法预览路由';
$response['reason'] = '当前商户未配置商户分组,无法解析路由';
return $response;
}
try {
// 只有基础条件满足时,才进入完整的路由解析流程。
$resolved = $this->paymentRouteService->resolveByMerchantGroup(
(int) $merchant['merchant_group_id'],
$payTypeId,
@@ -63,7 +83,8 @@ class MerchantPortalRoutePreviewService extends BaseService
);
$response['available'] = true;
$response['reason'] = '路由预览成功';
$response['reason'] = '路由解析成功';
// 把模型和仓库返回的对象统一整理成前端可直接展示的数组结构。
$response['bind'] = $this->normalizeBind($resolved['bind'] ?? null);
$response['poll_group'] = $this->normalizePollGroup($resolved['poll_group'] ?? null);
$response['selected_channel'] = $this->normalizePreviewCandidate($resolved['selected_channel'] ?? null);
@@ -73,15 +94,22 @@ class MerchantPortalRoutePreviewService extends BaseService
(array) ($resolved['candidates'] ?? [])
));
} catch (Throwable $e) {
$response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由预览失败';
// 解析异常只影响路由结果,不影响基础信息展示,因此这里只回填失败原因。
$response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由解析失败';
}
return $response;
}
private function normalizeBind(mixed $bind): ?array
/**
* 标准化商户分组与支付方式绑定数据。
*
* @param array|object|null $bind 绑定数据
* @return array|null 标准化结果
*/
private function normalizeBind(array|object|null $bind): ?array
{
$data = $this->supportService->normalizeModel($bind);
$data = $this->normalizeModel($bind);
if ($data === null) {
return null;
}
@@ -95,14 +123,20 @@ class MerchantPortalRoutePreviewService extends BaseService
'status' => $status,
'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'),
'remark' => (string) ($data['remark'] ?? ''),
'created_at' => $this->supportService->formatDateTime($data['created_at'] ?? null),
'updated_at' => $this->supportService->formatDateTime($data['updated_at'] ?? null),
'created_at' => $this->formatDateTime($data['created_at'] ?? null),
'updated_at' => $this->formatDateTime($data['updated_at'] ?? null),
];
}
private function normalizePollGroup(mixed $pollGroup): ?array
/**
* 标准化轮询分组数据。
*
* @param array|object|null $pollGroup 轮询分组
* @return array|null 标准化结果
*/
private function normalizePollGroup(array|object|null $pollGroup): ?array
{
$data = $this->supportService->normalizeModel($pollGroup);
$data = $this->normalizeModel($pollGroup);
if ($data === null) {
return null;
}
@@ -119,21 +153,28 @@ class MerchantPortalRoutePreviewService extends BaseService
'status' => $status,
'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'),
'remark' => (string) ($data['remark'] ?? ''),
'created_at' => $this->supportService->formatDateTime($data['created_at'] ?? null),
'updated_at' => $this->supportService->formatDateTime($data['updated_at'] ?? null),
'created_at' => $this->formatDateTime($data['created_at'] ?? null),
'updated_at' => $this->formatDateTime($data['updated_at'] ?? null),
];
}
private function normalizePreviewCandidate(mixed $candidate): ?array
/**
* 标准化路由候选数据。
*
* @param array|object|null $candidate 候选数据
* @return array|null 标准化结果
*/
private function normalizePreviewCandidate(array|object|null $candidate): ?array
{
$data = is_array($candidate) ? $candidate : $this->supportService->normalizeModel($candidate);
$data = is_array($candidate) ? $candidate : $this->normalizeModel($candidate);
if ($data === null) {
return null;
}
$channel = $this->supportService->normalizeModel($data['channel'] ?? null) ?? [];
$pollGroupChannel = $this->supportService->normalizeModel($data['poll_group_channel'] ?? null) ?? [];
$dailyStat = $this->supportService->normalizeModel($data['daily_stat'] ?? null) ?? [];
// 一个候选项会同时带出通道、轮询关系和日统计三层数据,后面统一整理成展示结构。
$channel = $this->normalizeModel($data['channel'] ?? null) ?? [];
$pollGroupChannel = $this->normalizeModel($data['poll_group_channel'] ?? null) ?? [];
$dailyStat = $this->normalizeModel($data['daily_stat'] ?? null) ?? [];
$channelMode = (int) ($channel['channel_mode'] ?? 0);
$status = (int) ($channel['status'] ?? 0);
@@ -152,27 +193,28 @@ class MerchantPortalRoutePreviewService extends BaseService
'sort_no' => (int) ($pollGroupChannel['sort_no'] ?? 0),
'weight' => (int) ($pollGroupChannel['weight'] ?? 0),
'is_default' => (int) ($pollGroupChannel['is_default'] ?? 0),
// 统计指标同时返回原始值和格式化文本,前端可以直接展示而不再二次换算。
'health_score' => (int) ($dailyStat['health_score'] ?? 0),
'health_score_text' => (string) ($dailyStat['health_score'] ?? 0),
'success_rate_bp' => (int) ($dailyStat['success_rate_bp'] ?? 0),
'success_rate_text' => $this->supportService->formatRate((int) ($dailyStat['success_rate_bp'] ?? 0)),
'success_rate_text' => $this->formatRate((int) ($dailyStat['success_rate_bp'] ?? 0)),
'avg_latency_ms' => (int) ($dailyStat['avg_latency_ms'] ?? 0),
'avg_latency_text' => $this->supportService->formatLatency((int) ($dailyStat['avg_latency_ms'] ?? 0)),
'avg_latency_text' => $this->formatLatency((int) ($dailyStat['avg_latency_ms'] ?? 0)),
'split_rate_bp' => (int) ($channel['split_rate_bp'] ?? 0),
'split_rate_text' => $this->supportService->formatRate((int) ($channel['split_rate_bp'] ?? 0)),
'split_rate_text' => $this->formatRate((int) ($channel['split_rate_bp'] ?? 0)),
'cost_rate_bp' => (int) ($channel['cost_rate_bp'] ?? 0),
'cost_rate_text' => $this->supportService->formatRate((int) ($channel['cost_rate_bp'] ?? 0)),
'cost_rate_text' => $this->formatRate((int) ($channel['cost_rate_bp'] ?? 0)),
'daily_limit_amount' => (int) ($channel['daily_limit_amount'] ?? 0),
'daily_limit_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['daily_limit_amount'] ?? 0)),
'daily_limit_amount_text' => $this->formatAmountOrUnlimited((int) ($channel['daily_limit_amount'] ?? 0)),
'daily_limit_count' => (int) ($channel['daily_limit_count'] ?? 0),
'daily_limit_count_text' => $this->supportService->formatCountOrUnlimited((int) ($channel['daily_limit_count'] ?? 0)),
'daily_limit_count_text' => $this->formatCountOrUnlimited((int) ($channel['daily_limit_count'] ?? 0)),
'min_amount' => (int) ($channel['min_amount'] ?? 0),
'min_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['min_amount'] ?? 0)),
'min_amount_text' => $this->formatAmountOrUnlimited((int) ($channel['min_amount'] ?? 0)),
'max_amount' => (int) ($channel['max_amount'] ?? 0),
'max_amount_text' => $this->supportService->formatAmountOrUnlimited((int) ($channel['max_amount'] ?? 0)),
'max_amount_text' => $this->formatAmountOrUnlimited((int) ($channel['max_amount'] ?? 0)),
'remark' => (string) ($channel['remark'] ?? ''),
'created_at' => $this->supportService->formatDateTime($channel['created_at'] ?? null),
'updated_at' => $this->supportService->formatDateTime($channel['updated_at'] ?? null),
'created_at' => $this->formatDateTime($channel['created_at'] ?? null),
'updated_at' => $this->formatDateTime($channel['updated_at'] ?? null),
];
}
}

View File

@@ -5,12 +5,23 @@ namespace app\service\merchant\portal;
use app\common\base\BaseService;
/**
* 商户后台基础页面服务门面
* 商户后台门户服务
*
* 仅保留控制器依赖的统一入口,具体能力拆到资料、通道、凭证和资金子服务
* @property MerchantPortalProfileService $profileService 资料服务
* @property MerchantPortalChannelService $channelService 渠道服务
* @property MerchantPortalCredentialService $credentialService 凭证服务
* @property MerchantPortalFinanceService $financeService 财务服务
*/
class MerchantPortalService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalProfileService $profileService 资料服务
* @param MerchantPortalChannelService $channelService 渠道服务
* @param MerchantPortalCredentialService $credentialService 凭证服务
* @param MerchantPortalFinanceService $financeService 财务服务
*/
public function __construct(
protected MerchantPortalProfileService $profileService,
protected MerchantPortalChannelService $channelService,
@@ -19,56 +30,137 @@ class MerchantPortalService extends BaseService
) {
}
/**
* 查询商户门户资料。
*
* @param int $merchantId 商户ID
* @return array 资料数据
*/
public function profile(int $merchantId): array
{
return $this->profileService->profile($merchantId);
}
/**
* 更新商户门户资料。
*
* @param int $merchantId 商户ID
* @param array $data 资料数据
* @return array 更新后的资料数据
*/
public function updateProfile(int $merchantId, array $data): array
{
return $this->profileService->updateProfile($merchantId, $data);
}
/**
* 修改商户门户密码。
*
* @param int $merchantId 商户ID
* @param array $data 密码数据
* @return array 密码修改结果
*/
public function changePassword(int $merchantId, array $data): array
{
return $this->profileService->changePassword($merchantId, $data);
}
/**
* 查询当前商户已开通的渠道。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 渠道列表
*/
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->channelService->myChannels($filters, $merchantId, $page, $pageSize);
}
/**
* 获取商户路由解析结果。
*
* @param int $merchantId 商户ID
* @param int $payTypeId 支付类型ID
* @param int $payAmount 支付金额
* @param string $statDate 统计日期
* @return array 路由解析数据
*/
public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array
{
return $this->channelService->routePreview($merchantId, $payTypeId, $payAmount, $statDate);
}
/**
* 查询商户 API 凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function apiCredential(int $merchantId): array
{
return $this->credentialService->apiCredential($merchantId);
}
/**
* 生成或重置商户门户接口凭证。
*
* @param int $merchantId 商户ID
* @return array 凭证数据
*/
public function issueCredential(int $merchantId): array
{
return $this->credentialService->issueCredential($merchantId);
}
/**
* 查询商户结算记录。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 结算记录列表
*/
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->financeService->settlementRecords($filters, $merchantId, $page, $pageSize);
}
/**
* 查询商户结算记录详情。
*
* @param string $settleNo 结算单号
* @param int $merchantId 商户ID
* @return array|null 结算详情
*/
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
{
return $this->financeService->settlementRecordDetail($settleNo, $merchantId);
}
/**
* 查询商户可提现余额。
*
* @param int $merchantId 商户ID
* @return array 余额数据
*/
public function withdrawableBalance(int $merchantId): array
{
return $this->financeService->withdrawableBalance($merchantId);
}
/**
* 查询商户资金流水。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 流水列表
*/
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
{
return $this->financeService->balanceFlows($filters, $merchantId, $page, $pageSize);

View File

@@ -8,15 +8,33 @@ use app\service\payment\settlement\SettlementOrderQueryService;
/**
* 商户门户清算服务。
*
* @property MerchantPortalSupportService $supportService 支持服务
* @property SettlementOrderQueryService $settlementOrderQueryService 结算订单查询服务
*/
class MerchantPortalSettlementService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantPortalSupportService $supportService 支持服务
* @param SettlementOrderQueryService $settlementOrderQueryService 结算订单查询服务
*/
public function __construct(
protected MerchantPortalSupportService $supportService,
protected SettlementOrderQueryService $settlementOrderQueryService
) {
}
/**
* 查询商户结算记录。
*
* @param array $filters 筛选条件
* @param int $merchantId 商户ID
* @param int $page 页码
* @param int $pageSize 每页条数
* @return array 结算记录列表
*/
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
{
$paginator = $this->settlementOrderQueryService->paginate($filters, $page, $pageSize, $merchantId);
@@ -30,6 +48,13 @@ class MerchantPortalSettlementService extends BaseService
];
}
/**
* 查询商户结算记录详情。
*
* @param string $settleNo 结算单号
* @param int $merchantId 商户ID
* @return array|null 结算详情
*/
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
{
try {
@@ -45,3 +70,6 @@ class MerchantPortalSettlementService extends BaseService
];
}
}

View File

@@ -10,12 +10,23 @@ use app\service\merchant\MerchantService;
use app\service\payment\config\PaymentTypeService;
/**
* 商户门户公共支持服务。
* 商户门户支持服务。
*
* 统一承接商户门户里复用的商户摘要、支付方式和通用格式化能力。
* 统一承接商户门户里复用的商户摘要、支付方式和展示整理能力。
*
* @property MerchantService $merchantService 商户服务
* @property MerchantRepository $merchantRepository 商户仓库
* @property PaymentTypeService $paymentTypeService 支付类型服务
*/
class MerchantPortalSupportService extends BaseService
{
/**
* 构造方法。
*
* @param MerchantService $merchantService 商户服务
* @param MerchantRepository $merchantRepository 商户仓库
* @param PaymentTypeService $paymentTypeService 支付类型服务
*/
public function __construct(
protected MerchantService $merchantService,
protected MerchantRepository $merchantRepository,
@@ -25,6 +36,10 @@ class MerchantPortalSupportService extends BaseService
/**
* 当前商户基础资料摘要。
*
* @param int $merchantId 商户ID
* @return array 商户摘要
* @throws ResourceNotFoundException
*/
public function merchantSummary(int $merchantId): array
{
@@ -106,7 +121,9 @@ class MerchantPortalSupportService extends BaseService
}
/**
* 启用的支付方式选项。
* 获取启用的支付方式选项。
*
* @return array 支付方式选项
*/
public function enabledPayTypeOptions(): array
{
@@ -115,6 +132,9 @@ class MerchantPortalSupportService extends BaseService
/**
* 根据支付方式 ID 获取名称。
*
* @param int $payTypeId 支付类型ID
* @return string 支付方式名称
*/
public function paymentTypeName(int $payTypeId): string
{
@@ -127,72 +147,11 @@ class MerchantPortalSupportService extends BaseService
return $payTypeId > 0 ? '未知' : '';
}
/**
* 格式化金额,单位为元。
*/
public function formatAmount(int $amount): string
{
return parent::formatAmount($amount);
}
/**
* 格式化金额0 时显示不限。
*/
public function formatAmountOrUnlimited(int $amount): string
{
return parent::formatAmountOrUnlimited($amount);
}
/**
* 格式化次数0 时显示不限。
*/
public function formatCountOrUnlimited(int $count): string
{
return parent::formatCountOrUnlimited($count);
}
/**
* 格式化费率,单位为百分点。
*/
public function formatRate(int $basisPoints): string
{
return parent::formatRate($basisPoints);
}
/**
* 格式化延迟。
*/
public function formatLatency(int $latencyMs): string
{
return parent::formatLatency($latencyMs);
}
/**
* 格式化日期时间。
*/
public function formatDateTime(mixed $value, string $emptyText = ''): string
{
return parent::formatDateTime($value, $emptyText);
}
/**
* 归一化模型对象,兼容模型和数组。
*/
public function normalizeModel(mixed $value): ?array
{
return parent::normalizeModel($value);
}
/**
* 隐藏接口凭证明文。
*/
public function maskCredentialValue(string $credentialValue, bool $maskShortValue = true): string
{
return parent::maskCredentialValue($credentialValue, $maskShortValue);
}
/**
* 签名类型文案。
*
* @param int $signType 签名类型
* @return string 签名类型文本
*/
public function signTypeText(int $signType): string
{

View File

@@ -8,14 +8,18 @@ use app\model\merchant\MerchantApiCredential;
use app\repository\merchant\credential\MerchantApiCredentialRepository;
/**
* 商户接口凭证查询服务。
* 商户 API 凭证查询服务。
*
* 负责凭证列表和详情展示,不承载验签和写入逻辑。
*
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
*/
class MerchantApiCredentialQueryService extends BaseService
{
/**
* 构造函数,注入对应依赖
* 构造方法
*
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
*/
public function __construct(
protected MerchantApiCredentialRepository $merchantApiCredentialRepository
@@ -23,7 +27,12 @@ class MerchantApiCredentialQueryService extends BaseService
}
/**
* 分页查询商户接口凭证。
* 分页查询商户 API 凭证。
*
* @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)
{
@@ -63,7 +72,10 @@ class MerchantApiCredentialQueryService extends BaseService
}
/**
* 查询商户接口凭证详情。
* 查询商户 API 凭证详情。
*
* @param int $id 商户 API 凭证ID
* @return MerchantApiCredential|null 凭证模型
*/
public function findById(int $id): ?MerchantApiCredential
{
@@ -73,6 +85,9 @@ class MerchantApiCredentialQueryService extends BaseService
/**
* 查询商户对应的接口凭证详情。
*
* @param int $merchantId 商户ID
* @return MerchantApiCredential|null 凭证模型
*/
public function findByMerchantId(int $merchantId): ?MerchantApiCredential
{
@@ -82,6 +97,9 @@ class MerchantApiCredentialQueryService extends BaseService
/**
* 统一构造查询对象。
*
* @param bool $maskCredentialValue 是否脱敏接口凭证
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
*/
private function baseQuery(bool $maskCredentialValue = false)
{
@@ -112,6 +130,9 @@ class MerchantApiCredentialQueryService extends BaseService
/**
* 给详情行补充展示字段。
*
* @param object|null $row 原始记录对象
* @return MerchantApiCredential|null 凭证模型
*/
private function decorateRow(mixed $row): ?MerchantApiCredential
{

View File

@@ -15,12 +15,20 @@ use app\repository\merchant\base\MerchantRepository;
/**
* 商户对外接口凭证与签名校验服务。
*
* 负责外部支付接口签名验、接口凭证发放和最近使用时间更新。
* 负责商户外部接口签名验、接口凭证发放和最近使用时间更新。
*
* @property MerchantRepository $merchantRepository 商户仓库
* @property MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @property MerchantApiCredentialQueryService $merchantApiCredentialQueryService 商户 API 凭证查询服务
*/
class MerchantApiCredentialService extends BaseService
{
/**
* 构造函数,注入对应依赖
* 构造方法
*
* @param MerchantRepository $merchantRepository 商户仓库
* @param MerchantApiCredentialRepository $merchantApiCredentialRepository 商户 API 凭证仓库
* @param MerchantApiCredentialQueryService $merchantApiCredentialQueryService 商户 API 凭证查询服务
*/
public function __construct(
protected MerchantRepository $merchantRepository,
@@ -30,7 +38,12 @@ class MerchantApiCredentialService extends BaseService
}
/**
* 分页查询商户接口凭证。
* 分页查询商户 API 凭证。
*
* @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)
{
@@ -40,7 +53,12 @@ class MerchantApiCredentialService extends BaseService
/**
* 校验外部支付接口的 MD5 签名。
*
* @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential}
* 会先校验商户和接口凭证是否存在,再按签名规则计算并比对请求签名。
*
* @param array $payload 请求载荷
* @return array{merchant: Merchant, credential: MerchantApiCredential} 校验通过后的商户和凭证数据
* @throws ValidationException
* @throws ResourceNotFoundException
*/
public function verifyMd5Sign(array $payload): array
{
@@ -66,15 +84,17 @@ class MerchantApiCredentialService extends BaseService
/** @var MerchantApiCredential|null $credential */
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
throw new ValidationException('商户接口凭证未开通');
throw new ValidationException('商户 API 凭证未开通');
}
if ($providedKey !== '' && !hash_equals((string) $credential->api_key, $providedKey)) {
throw new ValidationException('商户接口凭证错误');
throw new ValidationException('商户 API 凭证错误');
}
// 签名字段本身不参与原文拼接,只保留业务参数。
$params = $payload;
unset($params['sign'], $params['sign_type'], $params['key']);
// 过滤空值并按键名排序,保证不同参数顺序下得到同一签名串。
foreach ($params as $paramKey => $paramValue) {
if ($paramValue === '' || $paramValue === null) {
unset($params[$paramKey]);
@@ -84,12 +104,14 @@ class MerchantApiCredentialService extends BaseService
$key = (string) $credential->api_key;
$query = [];
// 旧版 ePay 采用 `a=1&b=2` 再拼接 key 的方式验签,这里保持兼容。
foreach ($params as $paramKey => $paramValue) {
$query[] = $paramKey . '=' . $paramValue;
}
$base = implode('&', $query) . $key;
$expected = md5($base);
// 使用常量时间比较,避免签名对比被时序差异放大。
if (!hash_equals(strtolower($expected), strtolower($sign))) {
throw new ValidationException('签名验证失败');
}
@@ -107,6 +129,10 @@ class MerchantApiCredentialService extends BaseService
* 为商户生成并保存一份新的接口凭证。
*
* 返回值是明文接口凭证值,只会在调用时完整出现一次,后续仅保存脱敏展示。
*
* @param int $merchantId 商户ID
* @return string 新接口凭证
* @throws ResourceNotFoundException
*/
public function issueCredential(int $merchantId): string
{
@@ -130,7 +156,10 @@ class MerchantApiCredentialService extends BaseService
}
/**
* 查询商户接口凭证详情。
* 查询商户 API 凭证详情。
*
* @param int $id 商户 API 凭证ID
* @return MerchantApiCredential|null 凭证模型
*/
public function findById(int $id): ?MerchantApiCredential
{
@@ -139,6 +168,9 @@ class MerchantApiCredentialService extends BaseService
/**
* 查询商户对应的接口凭证详情。
*
* @param int $merchantId 商户ID
* @return MerchantApiCredential|null 凭证模型
*/
public function findByMerchantId(int $merchantId): ?MerchantApiCredential
{
@@ -146,7 +178,13 @@ class MerchantApiCredentialService extends BaseService
}
/**
* 新增或更新商户接口凭证。
* 新增或更新商户 API 凭证。
*
* 如果商户已有凭证,则转为更新;否则创建新记录。
*
* @param array $data 凭证数据
* @return MerchantApiCredential 凭证模型
* @throws ResourceNotFoundException
*/
public function create(array $data): MerchantApiCredential
{
@@ -158,6 +196,7 @@ class MerchantApiCredentialService extends BaseService
$current = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
if ($current) {
// 同一商户只保留一份凭证,已有记录时优先走更新,避免重复创建。
$updated = $this->update((int) $current->id, $data);
if ($updated) {
return $updated;
@@ -168,7 +207,11 @@ class MerchantApiCredentialService extends BaseService
}
/**
* 修改商户接口凭证。
* 修改商户 API 凭证。
*
* @param int $id 商户 API 凭证ID
* @param array $data 凭证数据
* @return MerchantApiCredential|null 凭证模型
*/
public function update(int $id, array $data): ?MerchantApiCredential
{
@@ -186,7 +229,10 @@ class MerchantApiCredentialService extends BaseService
}
/**
* 删除商户接口凭证。
* 删除商户 API 凭证。
*
* @param int $id 商户 API 凭证ID
* @return bool 是否删除成功
*/
public function delete(int $id): bool
{
@@ -196,9 +242,13 @@ class MerchantApiCredentialService extends BaseService
/**
* 使用商户 ID 和接口凭证直接进行身份校验。
*
* 该方法用于兼容 epay 风格的查询接口,不涉及签名串验签。
* 该方法用于兼容 ePay 风格的查询接口,不涉及签名串验签。
*
* @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential}
* @param int $merchantId 商户ID
* @param string $key 接口凭证
* @return array{merchant: Merchant, credential: MerchantApiCredential} 商户和凭证数据
* @throws ValidationException
* @throws ResourceNotFoundException
*/
public function authenticateByKey(int $merchantId, string $key): array
{
@@ -215,11 +265,12 @@ class MerchantApiCredentialService extends BaseService
/** @var MerchantApiCredential|null $credential */
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
throw new ValidationException('商户接口凭证未开通');
throw new ValidationException('商户 API 凭证未开通');
}
// 同样使用常量时间比较,避免明文 key 对比暴露额外信息。
if (!hash_equals((string) $credential->api_key, $key)) {
throw new ValidationException('商户接口凭证错误');
throw new ValidationException('商户 API 凭证错误');
}
$credential->last_used_at = $this->now();
@@ -233,9 +284,15 @@ class MerchantApiCredentialService extends BaseService
/**
* 整理写入字段。
*
* @param array $data 凭证数据
* @param bool $isUpdate 是否更新
* @param MerchantApiCredential|null $current 当前凭证
* @return array{merchant_id: int, sign_type: int, status: int, api_key?: string} 标准化后的写入数据
*/
private function normalizePayload(array $data, bool $isUpdate, ?MerchantApiCredential $current = null): array
{
// 更新场景下以现有记录的 merchant_id 为准,避免把凭证误挂到别的商户。
$merchantId = (int) ($current?->merchant_id ?? ($data['merchant_id'] ?? 0));
$payload = [
'merchant_id' => $merchantId,
@@ -247,6 +304,7 @@ class MerchantApiCredentialService extends BaseService
if ($apiKey !== '') {
$payload['api_key'] = $apiKey;
} elseif (!$isUpdate) {
// 新增凭证时如果前端没有传入明文 key就自动补一份随机值。
$payload['api_key'] = $this->generateCredentialValue();
}
@@ -255,6 +313,8 @@ class MerchantApiCredentialService extends BaseService
/**
* 生成新的接口凭证值。
*
* @return string 接口凭证
*/
private function generateCredentialValue(): string
{
@@ -263,3 +323,8 @@ class MerchantApiCredentialService extends BaseService
}

View File

@@ -9,11 +9,18 @@ use app\repository\ops\log\ChannelNotifyLogRepository;
/**
* 渠道通知日志查询服务。
*
* 负责查询渠道通知记录、补充展示字段和还原通知处理状态。
*
* @property ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库
*/
class ChannelNotifyLogService extends BaseService
{
/**
* 构造函数,注入渠道通知日志仓库
* 构造方法
*
* @param ChannelNotifyLogRepository $channelNotifyLogRepository 渠道通知日志仓库
* @return void
*/
public function __construct(
protected ChannelNotifyLogRepository $channelNotifyLogRepository
@@ -22,6 +29,11 @@ class ChannelNotifyLogService 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)
{
@@ -84,7 +96,10 @@ class ChannelNotifyLogService extends BaseService
}
/**
* 按 ID 查询详情。
* 按 ID 查询渠道通知日志详情。
*
* @param int $id 渠道通知日志ID
* @return ChannelNotifyLog|null 日志模型
*/
public function findById(int $id): ?ChannelNotifyLog
{
@@ -97,6 +112,9 @@ class ChannelNotifyLogService extends BaseService
/**
* 格式化单条记录。
*
* @param object $row 查询结果对象
* @return object 格式化后的对象
*/
private function decorateRow(object $row): object
{
@@ -113,6 +131,8 @@ class ChannelNotifyLogService extends BaseService
/**
* 构建基础查询。
*
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
*/
private function baseQuery()
{
@@ -152,3 +172,6 @@ class ChannelNotifyLogService extends BaseService
}
}

View File

@@ -9,11 +9,18 @@ use app\repository\ops\log\PayCallbackLogRepository;
/**
* 支付回调日志查询服务。
*
* 负责查询支付回调记录、补充展示字段和还原回调处理状态。
*
* @property PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
*/
class PayCallbackLogService extends BaseService
{
/**
* 构造函数,注入支付回调日志仓库
* 构造方法
*
* @param PayCallbackLogRepository $payCallbackLogRepository 支付回调日志仓库
* @return void
*/
public function __construct(
protected PayCallbackLogRepository $payCallbackLogRepository
@@ -22,6 +29,11 @@ class PayCallbackLogService 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,7 +91,10 @@ class PayCallbackLogService extends BaseService
}
/**
* 按 ID 查询详情。
* 按 ID 查询支付回调日志详情。
*
* @param int $id 支付回调日志ID
* @return PayCallbackLog|null 日志模型
*/
public function findById(int $id): ?PayCallbackLog
{
@@ -92,6 +107,9 @@ class PayCallbackLogService extends BaseService
/**
* 格式化单条记录。
*
* @param object $row 查询结果对象
* @return object 格式化后的对象
*/
private function decorateRow(object $row): object
{
@@ -107,6 +125,8 @@ class PayCallbackLogService extends BaseService
/**
* 构建基础查询。
*
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
*/
private function baseQuery()
{
@@ -139,3 +159,6 @@ class PayCallbackLogService extends BaseService
}
}

View File

@@ -8,11 +8,18 @@ use app\repository\ops\stat\ChannelDailyStatRepository;
/**
* 通道日统计查询服务。
*
* 负责渠道日统计列表、详情和展示字段补充。
*
* @property ChannelDailyStatRepository $channelDailyStatRepository 渠道日统计仓库
*/
class ChannelDailyStatService extends BaseService
{
/**
* 构造函数,注入通道日统计仓库
* 构造方法
*
* @param ChannelDailyStatRepository $channelDailyStatRepository 渠道日统计仓库
* @return void
*/
public function __construct(
protected ChannelDailyStatRepository $channelDailyStatRepository
@@ -21,6 +28,11 @@ class ChannelDailyStatService 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)
{
@@ -67,7 +79,10 @@ class ChannelDailyStatService extends BaseService
}
/**
* 按 ID 查询详情。
* 按 ID 查询渠道日统计详情。
*
* @param int $id 渠道日统计ID
* @return ChannelDailyStat|null 统计模型
*/
public function findById(int $id): ?ChannelDailyStat
{
@@ -80,6 +95,9 @@ class ChannelDailyStatService extends BaseService
/**
* 格式化单条统计记录。
*
* @param object $row 查询结果对象
* @return object 格式化后的对象
*/
private function decorateRow(object $row): object
{
@@ -96,6 +114,8 @@ class ChannelDailyStatService extends BaseService
/**
* 构建基础查询。
*
* @return \Illuminate\Database\Eloquent\Builder 查询构造器
*/
private function baseQuery()
{
@@ -130,3 +150,7 @@ class ChannelDailyStatService extends BaseService
}
}

View File

@@ -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 : '';
}

View File

@@ -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
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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
{

View File

@@ -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;
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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 [];
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}

View File

@@ -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
{

View File

@@ -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
]));
}
}

View File

@@ -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);
}
}

View File

@@ -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'] ?? ''),

View File

@@ -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'] ?? []);

View File

@@ -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
{

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -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
}
}

View File

@@ -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)) {

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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),

View File

@@ -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)
{

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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
{

View File

@@ -14,11 +14,18 @@ use app\repository\system\user\AdminUserRepository;
* 管理员认证服务。
*
* 负责管理员账号校验、JWT 签发、登录态校验和主动注销。
*
* @property AdminUserRepository $adminUserRepository 管理用户仓库
* @property JwtTokenManager $jwtTokenManager jwtToken管理器
*/
class AdminAuthService extends BaseService
{
/**
* 构造函数,注入对应依赖
* 构造方法
*
* @param AdminUserRepository $adminUserRepository 管理用户仓库
* @param JwtTokenManager $jwtTokenManager jwtToken管理器
* @return void
*/
public function __construct(
protected AdminUserRepository $adminUserRepository,
@@ -28,6 +35,11 @@ class AdminAuthService extends BaseService
/**
* 校验中间件传入的管理员登录 token。
*
* @param string $token 登录令牌
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return AdminUser|null 管理员模型
*/
public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?AdminUser
{
@@ -52,6 +64,13 @@ class AdminAuthService extends BaseService
/**
* 校验管理员账号密码并签发 JWT。
*
* @param string $username 管理员账号
* @param string $password 密码
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return array{token: string, expires_in: int, admin: AdminUser} 登录结果
* @throws ValidationException
*/
public function authenticateCredentials(string $username, string $password, string $ip = '', string $userAgent = ''): array
{
@@ -73,6 +92,9 @@ class AdminAuthService extends BaseService
/**
* 撤销当前管理员登录 token。
*
* @param string $token 登录令牌
* @return bool 是否撤销成功
*/
public function revokeToken(string $token): bool
{
@@ -81,6 +103,13 @@ class AdminAuthService extends BaseService
/**
* 签发新的管理员登录 token。
*
* @param int $adminId 管理员ID
* @param int $ttlSeconds 过期秒数
* @param string $ip 请求 IP
* @param string $userAgent 用户代理
* @return array{token: string, expires_in: int, admin: AdminUser} 登录结果
* @throws ValidationException
*/
public function issueToken(int $adminId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array
{
@@ -111,3 +140,7 @@ class AdminAuthService extends BaseService
];
}
}

View File

@@ -5,20 +5,35 @@ namespace app\service\system\config;
use app\common\base\BaseService;
use RuntimeException;
/**
* 系统配置定义解析服务。
*
* 负责读取 `system_config` 配置并标准化为标签页、规则和默认值结构。
*/
class SystemConfigDefinitionService extends BaseService
{
protected const VIRTUAL_FIELD_PREFIX = '__';
/**
* 已解析的标签页缓存。
*
* @var array|null
*/
protected ?array $tabCache = null;
/**
* 标签页键到定义的缓存。
*
* @var array|null
*/
protected ?array $tabMapCache = null;
/**
* 获取全部系统配置标签页。
*
* @return array 标签页列表
* @throws RuntimeException
*/
public function tabs(): array
{
if ($this->tabCache !== null) {
@@ -82,6 +97,12 @@ class SystemConfigDefinitionService extends BaseService
return $this->tabCache;
}
/**
* 根据分组代码获取标签页定义。
*
* @param string $groupCode 分组代码
* @return array|null 标签页定义
*/
public function tab(string $groupCode): ?array
{
$groupCode = strtolower(trim($groupCode));
@@ -94,6 +115,13 @@ class SystemConfigDefinitionService extends BaseService
return $this->tabMapCache[$groupCode] ?? null;
}
/**
* 使用当前值回填标签页规则。
*
* @param array $tab 标签页定义
* @param array $values 当前值映射
* @return array 回填后的规则列表
*/
public function hydrateRules(array $tab, array $values): array
{
$rules = [];
@@ -116,6 +144,13 @@ class SystemConfigDefinitionService extends BaseService
return $rules;
}
/**
* 从标签页规则中提取表单提交数据。
*
* @param array $tab 标签页定义
* @param array $values 当前值映射
* @return array 表单数据
*/
public function extractFormData(array $tab, array $values): array
{
$data = [];
@@ -135,6 +170,12 @@ class SystemConfigDefinitionService extends BaseService
return $data;
}
/**
* 生成必填字段校验消息。
*
* @param array $tab 标签页定义
* @return array 字段到错误消息的映射
*/
public function requiredFieldMessages(array $tab): array
{
$messages = [];
@@ -163,6 +204,13 @@ class SystemConfigDefinitionService extends BaseService
return $messages;
}
/**
* 标准化单个标签页定义。
*
* @param string $groupCode 分组代码
* @param array $definition 原始定义
* @return array|null 标准化后的标签页
*/
private function normalizeTab(string $groupCode, array $definition): ?array
{
$key = strtolower(trim((string) ($definition['key'] ?? $groupCode)));
@@ -191,7 +239,13 @@ class SystemConfigDefinitionService extends BaseService
];
}
private function normalizeRule(mixed $rule): ?array
/**
* 标准化单个配置项定义。
*
* @param array|object|null $rule 原始规则
* @return array|null 标准化后的规则
*/
private function normalizeRule(array|object|null $rule): ?array
{
if (!is_array($rule)) {
return null;
@@ -235,8 +289,15 @@ class SystemConfigDefinitionService extends BaseService
return $normalized;
}
/**
* 判断是否为虚拟字段。
*
* @param string $field 字段名
* @return bool 是否为虚拟字段
*/
private function isVirtualField(string $field): bool
{
return str_starts_with($field, self::VIRTUAL_FIELD_PREFIX);
}
}

View File

@@ -7,14 +7,34 @@ use app\exception\ValidationException;
use app\repository\system\config\SystemConfigRepository;
use Webman\Event\Event;
/**
* 系统配置页面服务。
*
* 负责把配置定义和数据库中的配置值组装成页面所需的数据结构。
* 典型流程是先读取定义,再回填当前值,最后生成表单页数据。
*
* @property SystemConfigRepository $systemConfigRepository 系统配置仓库
* @property SystemConfigDefinitionService $systemConfigDefinitionService 系统配置定义解析服务
*/
class SystemConfigPageService extends BaseService
{
/**
* 构造方法。
*
* @param SystemConfigRepository $systemConfigRepository 系统配置仓库
* @param SystemConfigDefinitionService $systemConfigDefinitionService 系统配置定义解析服务
*/
public function __construct(
protected SystemConfigRepository $systemConfigRepository,
protected SystemConfigDefinitionService $systemConfigDefinitionService
) {
}
/**
* 获取系统配置页面标签页列表。
*
* @return array<string, mixed> 页面所需标签页数据
*/
public function tabs(): array
{
$tabs = [];
@@ -41,6 +61,13 @@ class SystemConfigPageService extends BaseService
];
}
/**
* 查询系统配置页面详情。
*
* @param string $groupCode 分组代码
* @return array<string, mixed> 页面详情
* @throws ValidationException
*/
public function detail(string $groupCode): array
{
$tab = $this->systemConfigDefinitionService->tab($groupCode);
@@ -80,6 +107,14 @@ class SystemConfigPageService extends BaseService
return $tab;
}
/**
* 保存系统配置页面。
*
* @param string $groupCode 分组代码
* @param array<string, mixed> $values 提交值
* @return array<string, mixed> 保存后的页面详情
* @throws ValidationException
*/
public function save(string $groupCode, array $values): array
{
$tab = $this->systemConfigDefinitionService->tab($groupCode);
@@ -119,6 +154,14 @@ class SystemConfigPageService extends BaseService
return $this->detail((string) $tab['key']);
}
/**
* 校验必填配置项。
*
* @param array<string, mixed> $tab 标签页定义
* @param array<string, mixed> $values 配置值
* @return void
* @throws ValidationException
*/
protected function validateRequiredValues(array $tab, array $values): void
{
$messages = $this->systemConfigDefinitionService->requiredFieldMessages($tab);
@@ -131,7 +174,13 @@ class SystemConfigPageService extends BaseService
}
}
protected function isEmptyValue(mixed $value): bool
/**
* 判断配置值是否为空。
*
* @param array|object|bool|float|int|string|null $value 配置值
* @return bool 是否为空
*/
protected function isEmptyValue(array|object|bool|float|int|string|null $value): bool
{
if (is_array($value)) {
return $value === [];
@@ -144,7 +193,14 @@ class SystemConfigPageService extends BaseService
return $value === null || $value === '';
}
protected function stringifyValue(mixed $value): string
/**
* 将配置值转换为可保存字符串。
*
* @param array|object|bool|float|int|string|null $value 配置值
* @return string 可保存字符串
* @throws ValidationException
*/
protected function stringifyValue(array|object|bool|float|int|string|null $value): string
{
if (is_bool($value)) {
return $value ? '1' : '0';
@@ -156,5 +212,6 @@ class SystemConfigPageService extends BaseService
return (string) $value;
}
}

View File

@@ -7,16 +7,37 @@ use app\repository\system\config\SystemConfigRepository;
use support\Cache;
use Throwable;
/**
* 系统配置运行时服务。
*
* 负责读取、缓存和刷新当前进程可直接使用的系统配置值。
* 读取结果以配置键到字符串值的映射形式提供给业务层。
*
* @property SystemConfigRepository $systemConfigRepository 系统配置仓库
* @property SystemConfigDefinitionService $systemConfigDefinitionService 系统配置定义解析服务
*/
class SystemConfigRuntimeService extends BaseService
{
protected const CACHE_KEY = 'system_config:all';
/**
* 构造方法。
*
* @param SystemConfigRepository $systemConfigRepository 系统配置仓库
* @param SystemConfigDefinitionService $systemConfigDefinitionService 系统配置定义解析服务
*/
public function __construct(
protected SystemConfigRepository $systemConfigRepository,
protected SystemConfigDefinitionService $systemConfigDefinitionService
) {
}
/**
* 获取全部系统配置运行时值。
*
* @param bool $refresh 是否强制刷新
* @return array<string, string> 系统配置值映射
*/
public function all(bool $refresh = false): array
{
if (!$refresh) {
@@ -29,7 +50,15 @@ class SystemConfigRuntimeService extends BaseService
return $this->refresh();
}
public function get(string $configKey, mixed $default = '', bool $refresh = false): string
/**
* 根据配置键获取运行时值。
*
* @param string $configKey 配置键
* @param string|int|float|bool|null $default 默认值
* @param bool $refresh 是否强制刷新
* @return string 配置值字符串
*/
public function get(string $configKey, string|int|float|bool|null $default = '', bool $refresh = false): string
{
$configKey = strtolower(trim($configKey));
if ($configKey === '') {
@@ -41,6 +70,11 @@ class SystemConfigRuntimeService extends BaseService
return (string) ($values[$configKey] ?? $default);
}
/**
* 刷新系统配置运行时缓存。
*
* @return array<string, string> 最新配置值映射
*/
public function refresh(): array
{
$values = $this->buildValueMap();
@@ -49,6 +83,11 @@ class SystemConfigRuntimeService extends BaseService
return $values;
}
/**
* 构建配置值映射。
*
* @return array<string, string> 配置值映射
*/
protected function buildValueMap(): array
{
$values = [];
@@ -102,6 +141,11 @@ class SystemConfigRuntimeService extends BaseService
return $values;
}
/**
* 读取运行时缓存。
*
* @return array<string, string>|null 缓存值
*/
protected function readCache(): ?array
{
try {
@@ -113,6 +157,12 @@ class SystemConfigRuntimeService extends BaseService
return is_array($raw) ? $raw : null;
}
/**
* 写入运行时缓存。
*
* @param array<string, string> $values 配置值映射
* @return void
*/
protected function writeCache(array $values): void
{
try {
@@ -122,3 +172,5 @@ class SystemConfigRuntimeService extends BaseService
}
}
}

View File

@@ -12,11 +12,16 @@ use app\repository\system\user\AdminUserRepository;
* 管理员用户管理服务。
*
* 负责管理员账号的列表查询、新增、修改和删除,以及密码字段的统一处理。
*
* @property AdminUserRepository $adminUserRepository 管理用户仓库
*/
class AdminUserService extends BaseService
{
/**
* 构造函数,注入管理员用户仓库
* 构造方法
*
* @param AdminUserRepository $adminUserRepository 管理用户仓库
* @return void
*/
public function __construct(
protected AdminUserRepository $adminUserRepository
@@ -25,6 +30,11 @@ class AdminUserService 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)
{
@@ -80,6 +90,9 @@ class AdminUserService extends BaseService
/**
* 根据 ID 查询管理员用户。
*
* @param int $id 管理员用户ID
* @return AdminUser|null 管理员模型
*/
public function findById(int $id): ?AdminUser
{
@@ -88,6 +101,9 @@ class AdminUserService extends BaseService
/**
* 新增管理员用户。
*
* @param array $data 写入数据
* @return AdminUser 新增后的管理员模型
*/
public function create(array $data): AdminUser
{
@@ -96,6 +112,10 @@ class AdminUserService extends BaseService
/**
* 修改管理员用户。
*
* @param int $id 管理员用户ID
* @param array $data 写入数据
* @return AdminUser|null 更新后的管理员模型
*/
public function update(int $id, array $data): ?AdminUser
{
@@ -113,6 +133,9 @@ class AdminUserService extends BaseService
/**
* 删除管理员用户。
*
* @param int $id 管理员用户ID
* @return bool 是否删除成功
*/
public function delete(int $id): bool
{
@@ -121,6 +144,11 @@ class AdminUserService extends BaseService
/**
* 当前管理员资料。
*
* @param int $adminId 管理员ID
* @param string $adminUsername 管理员账号
* @return array<string, mixed> 当前用户资料
* @throws ResourceNotFoundException
*/
public function profile(int $adminId, string $adminUsername = ''): array
{
@@ -170,6 +198,10 @@ class AdminUserService extends BaseService
/**
* 统一整理写入字段,并处理密码哈希。
*
* @param array $data 写入数据
* @param bool $isUpdate 是否更新
* @return array<string, mixed> 标准化后的数据
*/
private function normalizePayload(array $data, bool $isUpdate): array
{
@@ -194,3 +226,6 @@ class AdminUserService extends BaseService
}
}