mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-24 11:04:26 +08:00
重构初始化
This commit is contained in:
349
app/service/account/funds/MerchantAccountCommandService.php
Normal file
349
app/service/account/funds/MerchantAccountCommandService.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\account\funds;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\LedgerConstant;
|
||||
use app\exception\BalanceInsufficientException;
|
||||
use app\exception\ConflictException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\MerchantAccount;
|
||||
use app\model\merchant\MerchantAccountLedger;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
|
||||
/**
|
||||
* 商户账户命令服务。
|
||||
*
|
||||
* 只负责账户创建、冻结、扣减、释放和入账等资金变更。
|
||||
*/
|
||||
class MerchantAccountCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantAccountRepository $accountRepository,
|
||||
protected MerchantAccountLedgerRepository $ledgerRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建商户账户。
|
||||
*/
|
||||
public function ensureAccount(int $merchantId): MerchantAccount
|
||||
{
|
||||
return $this->transactionRetry(function () use ($merchantId) {
|
||||
return $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中获取或创建商户账户。
|
||||
*/
|
||||
public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount
|
||||
{
|
||||
$account = $this->accountRepository->findForUpdateByMerchantId($merchantId);
|
||||
if ($account) {
|
||||
return $account;
|
||||
}
|
||||
|
||||
$this->accountRepository->create([
|
||||
'merchant_id' => $merchantId,
|
||||
'available_balance' => 0,
|
||||
'frozen_balance' => 0,
|
||||
]);
|
||||
|
||||
$account = $this->accountRepository->findForUpdateByMerchantId($merchantId);
|
||||
if (!$account) {
|
||||
throw new ValidationException('商户账户创建失败', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
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) {
|
||||
return $this->freezeAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
|
||||
});
|
||||
}
|
||||
|
||||
public function freezeAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
|
||||
{
|
||||
$this->assertPositiveAmount($amount);
|
||||
if ($idempotencyKey === '') {
|
||||
throw new ValidationException('幂等键不能为空');
|
||||
}
|
||||
|
||||
if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) {
|
||||
$this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_FREEZE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT);
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
if ((int) $account->available_balance < $amount) {
|
||||
throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance);
|
||||
}
|
||||
|
||||
$availableBefore = (int) $account->available_balance;
|
||||
$frozenBefore = (int) $account->frozen_balance;
|
||||
|
||||
$account->available_balance = $availableBefore - $amount;
|
||||
$account->frozen_balance = $frozenBefore + $amount;
|
||||
$account->save();
|
||||
|
||||
return $this->createLedger([
|
||||
'merchant_id' => $merchantId,
|
||||
'biz_type' => LedgerConstant::BIZ_TYPE_PAY_FREEZE,
|
||||
'biz_no' => $bizNo,
|
||||
'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo),
|
||||
'event_type' => LedgerConstant::EVENT_TYPE_CREATE,
|
||||
'direction' => LedgerConstant::DIRECTION_OUT,
|
||||
'amount' => $amount,
|
||||
'available_before' => $availableBefore,
|
||||
'available_after' => (int) $account->available_balance,
|
||||
'frozen_before' => $frozenBefore,
|
||||
'frozen_after' => (int) $account->frozen_balance,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'remark' => $extJson['remark'] ?? '余额冻结',
|
||||
'ext_json' => $extJson,
|
||||
]);
|
||||
}
|
||||
|
||||
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) {
|
||||
return $this->deductFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
|
||||
});
|
||||
}
|
||||
|
||||
public function deductFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
|
||||
{
|
||||
$this->assertPositiveAmount($amount);
|
||||
if ($idempotencyKey === '') {
|
||||
throw new ValidationException('幂等键不能为空');
|
||||
}
|
||||
|
||||
if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) {
|
||||
$this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_DEDUCT, $bizNo, $amount, LedgerConstant::DIRECTION_OUT);
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
if ((int) $account->frozen_balance < $amount) {
|
||||
throw new ValidationException('冻结余额不足', [
|
||||
'merchant_id' => $merchantId,
|
||||
'amount' => $amount,
|
||||
'frozen_balance' => (int) $account->frozen_balance,
|
||||
]);
|
||||
}
|
||||
|
||||
$availableBefore = (int) $account->available_balance;
|
||||
$frozenBefore = (int) $account->frozen_balance;
|
||||
|
||||
$account->frozen_balance = $frozenBefore - $amount;
|
||||
$account->save();
|
||||
|
||||
return $this->createLedger([
|
||||
'merchant_id' => $merchantId,
|
||||
'biz_type' => LedgerConstant::BIZ_TYPE_PAY_DEDUCT,
|
||||
'biz_no' => $bizNo,
|
||||
'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo),
|
||||
'event_type' => LedgerConstant::EVENT_TYPE_SUCCESS,
|
||||
'direction' => LedgerConstant::DIRECTION_OUT,
|
||||
'amount' => $amount,
|
||||
'available_before' => $availableBefore,
|
||||
'available_after' => (int) $account->available_balance,
|
||||
'frozen_before' => $frozenBefore,
|
||||
'frozen_after' => (int) $account->frozen_balance,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'remark' => $extJson['remark'] ?? '余额扣减',
|
||||
'ext_json' => $extJson,
|
||||
]);
|
||||
}
|
||||
|
||||
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) {
|
||||
return $this->releaseFrozenAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseFrozenAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
|
||||
{
|
||||
$this->assertPositiveAmount($amount);
|
||||
if ($idempotencyKey === '') {
|
||||
throw new ValidationException('幂等键不能为空');
|
||||
}
|
||||
|
||||
if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) {
|
||||
$this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_PAY_RELEASE, $bizNo, $amount, LedgerConstant::DIRECTION_IN);
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
if ((int) $account->frozen_balance < $amount) {
|
||||
throw new ValidationException('冻结余额不足', [
|
||||
'merchant_id' => $merchantId,
|
||||
'amount' => $amount,
|
||||
'frozen_balance' => (int) $account->frozen_balance,
|
||||
]);
|
||||
}
|
||||
|
||||
$availableBefore = (int) $account->available_balance;
|
||||
$frozenBefore = (int) $account->frozen_balance;
|
||||
|
||||
$account->available_balance = $availableBefore + $amount;
|
||||
$account->frozen_balance = $frozenBefore - $amount;
|
||||
$account->save();
|
||||
|
||||
return $this->createLedger([
|
||||
'merchant_id' => $merchantId,
|
||||
'biz_type' => LedgerConstant::BIZ_TYPE_PAY_RELEASE,
|
||||
'biz_no' => $bizNo,
|
||||
'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo),
|
||||
'event_type' => LedgerConstant::EVENT_TYPE_REVERSE,
|
||||
'direction' => LedgerConstant::DIRECTION_IN,
|
||||
'amount' => $amount,
|
||||
'available_before' => $availableBefore,
|
||||
'available_after' => (int) $account->available_balance,
|
||||
'frozen_before' => $frozenBefore,
|
||||
'frozen_after' => (int) $account->frozen_balance,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'remark' => $extJson['remark'] ?? '冻结余额释放',
|
||||
'ext_json' => $extJson,
|
||||
]);
|
||||
}
|
||||
|
||||
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) {
|
||||
return $this->creditAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
|
||||
});
|
||||
}
|
||||
|
||||
public function creditAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
|
||||
{
|
||||
$this->assertPositiveAmount($amount);
|
||||
if ($idempotencyKey === '') {
|
||||
throw new ValidationException('幂等键不能为空');
|
||||
}
|
||||
|
||||
if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) {
|
||||
$this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_SETTLEMENT_CREDIT, $bizNo, $amount, LedgerConstant::DIRECTION_IN);
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
$availableBefore = (int) $account->available_balance;
|
||||
$frozenBefore = (int) $account->frozen_balance;
|
||||
|
||||
$account->available_balance = $availableBefore + $amount;
|
||||
$account->save();
|
||||
|
||||
return $this->createLedger([
|
||||
'merchant_id' => $merchantId,
|
||||
'biz_type' => LedgerConstant::BIZ_TYPE_SETTLEMENT_CREDIT,
|
||||
'biz_no' => $bizNo,
|
||||
'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo),
|
||||
'event_type' => LedgerConstant::EVENT_TYPE_SUCCESS,
|
||||
'direction' => LedgerConstant::DIRECTION_IN,
|
||||
'amount' => $amount,
|
||||
'available_before' => $availableBefore,
|
||||
'available_after' => (int) $account->available_balance,
|
||||
'frozen_before' => $frozenBefore,
|
||||
'frozen_after' => (int) $account->frozen_balance,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'remark' => $extJson['remark'] ?? '清算入账',
|
||||
'ext_json' => $extJson,
|
||||
]);
|
||||
}
|
||||
|
||||
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) {
|
||||
return $this->debitAvailableAmountInCurrentTransaction($merchantId, $amount, $bizNo, $idempotencyKey, $extJson, $traceNo);
|
||||
});
|
||||
}
|
||||
|
||||
public function debitAvailableAmountInCurrentTransaction(int $merchantId, int $amount, string $bizNo, string $idempotencyKey, array $extJson = [], string $traceNo = ''): MerchantAccountLedger
|
||||
{
|
||||
$this->assertPositiveAmount($amount);
|
||||
if ($idempotencyKey === '') {
|
||||
throw new ValidationException('幂等键不能为空');
|
||||
}
|
||||
|
||||
if ($existing = $this->findLedgerByIdempotencyKey($idempotencyKey)) {
|
||||
$this->assertLedgerMatch($existing, LedgerConstant::BIZ_TYPE_REFUND_REVERSE, $bizNo, $amount, LedgerConstant::DIRECTION_OUT);
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$account = $this->ensureAccountInCurrentTransaction($merchantId);
|
||||
if ((int) $account->available_balance < $amount) {
|
||||
throw new BalanceInsufficientException($merchantId, $amount, (int) $account->available_balance);
|
||||
}
|
||||
|
||||
$availableBefore = (int) $account->available_balance;
|
||||
$frozenBefore = (int) $account->frozen_balance;
|
||||
|
||||
$account->available_balance = $availableBefore - $amount;
|
||||
$account->save();
|
||||
|
||||
return $this->createLedger([
|
||||
'merchant_id' => $merchantId,
|
||||
'biz_type' => LedgerConstant::BIZ_TYPE_REFUND_REVERSE,
|
||||
'biz_no' => $bizNo,
|
||||
'trace_no' => $this->normalizeTraceNo($traceNo, $bizNo),
|
||||
'event_type' => LedgerConstant::EVENT_TYPE_REVERSE,
|
||||
'direction' => LedgerConstant::DIRECTION_OUT,
|
||||
'amount' => $amount,
|
||||
'available_before' => $availableBefore,
|
||||
'available_after' => (int) $account->available_balance,
|
||||
'frozen_before' => $frozenBefore,
|
||||
'frozen_after' => (int) $account->frozen_balance,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'remark' => $extJson['remark'] ?? '余额冲减',
|
||||
'ext_json' => $extJson,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createLedger(array $data): MerchantAccountLedger
|
||||
{
|
||||
$data['ledger_no'] = $data['ledger_no'] ?? $this->generateNo('LG');
|
||||
$data['trace_no'] = trim((string) ($data['trace_no'] ?? $data['biz_no'] ?? ''));
|
||||
$data['created_at'] = $data['created_at'] ?? $this->now();
|
||||
|
||||
return $this->ledgerRepository->create($data);
|
||||
}
|
||||
|
||||
private function findLedgerByIdempotencyKey(string $idempotencyKey): ?MerchantAccountLedger
|
||||
{
|
||||
return $this->ledgerRepository->findByIdempotencyKey($idempotencyKey);
|
||||
}
|
||||
|
||||
private function assertPositiveAmount(int $amount): void
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
throw new ValidationException('金额必须大于 0');
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new ConflictException('幂等冲突', [
|
||||
'ledger_no' => (string) $ledger->ledger_no,
|
||||
'biz_type' => $bizType,
|
||||
'biz_no' => $bizNo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeTraceNo(string $traceNo, string $bizNo): string
|
||||
{
|
||||
$traceNo = trim($traceNo);
|
||||
if ($traceNo !== '') {
|
||||
return $traceNo;
|
||||
}
|
||||
|
||||
return $bizNo;
|
||||
}
|
||||
}
|
||||
160
app/service/account/funds/MerchantAccountQueryService.php
Normal file
160
app/service/account/funds/MerchantAccountQueryService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\account\funds;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\merchant\MerchantAccount;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
|
||||
/**
|
||||
* 商户账户查询服务。
|
||||
*
|
||||
* 只负责账户列表、概览和快照查询,不承载资金变更逻辑。
|
||||
*/
|
||||
class MerchantAccountQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantAccountRepository $accountRepository,
|
||||
protected MerchantAccountLedgerRepository $ledgerRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户账户。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->accountRepository->query()
|
||||
->from('ma_merchant_account as a')
|
||||
->leftJoin('ma_merchant as m', 'a.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'a.id',
|
||||
'a.merchant_id',
|
||||
'a.available_balance',
|
||||
'a.frozen_balance',
|
||||
'a.created_at',
|
||||
'a.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name");
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('a.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('a.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->available_balance_text = $this->formatAmount((int) $row->available_balance);
|
||||
$row->frozen_balance_text = $this->formatAmount((int) $row->frozen_balance);
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资金中心概览。
|
||||
*/
|
||||
public function summary(): array
|
||||
{
|
||||
$accountStats = $this->accountRepository->query()
|
||||
->selectRaw('COUNT(*) AS account_count')
|
||||
->selectRaw('SUM(available_balance) AS total_available_balance')
|
||||
->selectRaw('SUM(frozen_balance) AS total_frozen_balance')
|
||||
->first();
|
||||
|
||||
$ledgerStats = $this->ledgerRepository->query()
|
||||
->selectRaw('COUNT(*) AS ledger_count')
|
||||
->first();
|
||||
|
||||
$totalAvailableBalance = (int) ($accountStats->total_available_balance ?? 0);
|
||||
$totalFrozenBalance = (int) ($accountStats->total_frozen_balance ?? 0);
|
||||
|
||||
return [
|
||||
'account_count' => (int) ($accountStats->account_count ?? 0),
|
||||
'ledger_count' => (int) ($ledgerStats->ledger_count ?? 0),
|
||||
'total_available_balance' => $totalAvailableBalance,
|
||||
'total_available_balance_text' => $this->formatAmount($totalAvailableBalance),
|
||||
'total_frozen_balance' => $totalFrozenBalance,
|
||||
'total_frozen_balance_text' => $this->formatAmount($totalFrozenBalance),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商户余额快照。
|
||||
*
|
||||
* 用于后台展示和接口返回,不修改任何账户数据。
|
||||
*/
|
||||
public function getBalanceSnapshot(int $merchantId): array
|
||||
{
|
||||
$account = $this->accountRepository->findByMerchantId($merchantId);
|
||||
|
||||
if (!$account) {
|
||||
return [
|
||||
'merchant_id' => $merchantId,
|
||||
'available_balance' => 0,
|
||||
'frozen_balance' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant_id' => (int) $account->merchant_id,
|
||||
'available_balance' => (int) $account->available_balance,
|
||||
'frozen_balance' => (int) $account->frozen_balance,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户账户详情。
|
||||
*/
|
||||
public function findById(int $id): ?MerchantAccount
|
||||
{
|
||||
$row = $this->accountRepository->query()
|
||||
->from('ma_merchant_account as a')
|
||||
->leftJoin('ma_merchant as m', 'a.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'a.id',
|
||||
'a.merchant_id',
|
||||
'a.available_balance',
|
||||
'a.frozen_balance',
|
||||
'a.created_at',
|
||||
'a.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name")
|
||||
->where('a.id', $id)
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row->available_balance_text = $this->formatAmount((int) $row->available_balance);
|
||||
$row->frozen_balance_text = $this->formatAmount((int) $row->frozen_balance);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
}
|
||||
101
app/service/account/funds/MerchantAccountService.php
Normal file
101
app/service/account/funds/MerchantAccountService.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\account\funds;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\merchant\MerchantAccount;
|
||||
use app\model\merchant\MerchantAccountLedger;
|
||||
|
||||
/**
|
||||
* 商户余额门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给查询和命令两个子服务。
|
||||
*/
|
||||
class MerchantAccountService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantAccountQueryService $queryService,
|
||||
protected MerchantAccountCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function summary(): array
|
||||
{
|
||||
return $this->queryService->summary();
|
||||
}
|
||||
|
||||
public function ensureAccount(int $merchantId): MerchantAccount
|
||||
{
|
||||
return $this->commandService->ensureAccount($merchantId);
|
||||
}
|
||||
|
||||
public function ensureAccountInCurrentTransaction(int $merchantId): MerchantAccount
|
||||
{
|
||||
return $this->commandService->ensureAccountInCurrentTransaction($merchantId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function getBalanceSnapshot(int $merchantId): array
|
||||
{
|
||||
return $this->queryService->getBalanceSnapshot($merchantId);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?MerchantAccount
|
||||
{
|
||||
return $this->queryService->findById($id);
|
||||
}
|
||||
}
|
||||
138
app/service/account/ledger/MerchantAccountLedgerService.php
Normal file
138
app/service/account/ledger/MerchantAccountLedgerService.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\account\ledger;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\LedgerConstant;
|
||||
use app\model\merchant\MerchantAccountLedger;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
|
||||
/**
|
||||
* 商户账户流水查询服务。
|
||||
*/
|
||||
class MerchantAccountLedgerService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入流水仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询账户流水。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('l.ledger_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('l.biz_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('l.trace_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('l.idempotency_key', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('l.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$bizType = (string) ($filters['biz_type'] ?? '');
|
||||
if ($bizType !== '') {
|
||||
$query->where('l.biz_type', (int) $bizType);
|
||||
}
|
||||
|
||||
$eventType = (string) ($filters['event_type'] ?? '');
|
||||
if ($eventType !== '') {
|
||||
$query->where('l.event_type', (int) $eventType);
|
||||
}
|
||||
|
||||
$direction = (string) ($filters['direction'] ?? '');
|
||||
if ($direction !== '') {
|
||||
$query->where('l.direction', (int) $direction);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('l.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询流水详情。
|
||||
*/
|
||||
public function findById(int $id): ?MerchantAccountLedger
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('l.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化记录。
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
$row->biz_type_text = (string) (LedgerConstant::bizTypeMap()[(int) $row->biz_type] ?? '未知');
|
||||
$row->event_type_text = (string) (LedgerConstant::eventTypeMap()[(int) $row->event_type] ?? '未知');
|
||||
$row->direction_text = (string) (LedgerConstant::directionMap()[(int) $row->direction] ?? '未知');
|
||||
$row->amount_text = $this->formatAmount((int) $row->amount);
|
||||
$row->available_before_text = $this->formatAmount((int) $row->available_before);
|
||||
$row->available_after_text = $this->formatAmount((int) $row->available_after);
|
||||
$row->frozen_before_text = $this->formatAmount((int) $row->frozen_before);
|
||||
$row->frozen_after_text = $this->formatAmount((int) $row->frozen_after);
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->ext_json_text = $this->formatJson($row->ext_json ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询。
|
||||
*/
|
||||
private function baseQuery()
|
||||
{
|
||||
return $this->merchantAccountLedgerRepository->query()
|
||||
->from('ma_merchant_account_ledger as l')
|
||||
->leftJoin('ma_merchant as m', 'l.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'l.id',
|
||||
'l.ledger_no',
|
||||
'l.merchant_id',
|
||||
'l.biz_type',
|
||||
'l.biz_no',
|
||||
'l.trace_no',
|
||||
'l.event_type',
|
||||
'l.direction',
|
||||
'l.amount',
|
||||
'l.available_before',
|
||||
'l.available_after',
|
||||
'l.frozen_before',
|
||||
'l.frozen_after',
|
||||
'l.idempotency_key',
|
||||
'l.remark',
|
||||
'l.ext_json',
|
||||
'l.created_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name");
|
||||
}
|
||||
|
||||
}
|
||||
157
app/service/bootstrap/SystemBootstrapService.php
Normal file
157
app/service/bootstrap/SystemBootstrapService.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\bootstrap;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
class SystemBootstrapService extends BaseService
|
||||
{
|
||||
public function getMenuTree(string $panel): array
|
||||
{
|
||||
$roles = $panel === 'merchant' ? ['common'] : ['admin'];
|
||||
$nodes = $this->filterByRoles($this->menuNodes($panel), $roles);
|
||||
|
||||
return $this->normalizeRedirects($this->buildTree($nodes));
|
||||
}
|
||||
|
||||
public function getDictItems(?string $code = null): array
|
||||
{
|
||||
$items = $this->dictItems();
|
||||
$code = trim((string) $code);
|
||||
if ($code === '') {
|
||||
return array_values($items);
|
||||
}
|
||||
|
||||
$codes = array_values(array_filter(array_map('trim', explode(',', $code))));
|
||||
if ($codes === []) {
|
||||
return array_values($items);
|
||||
}
|
||||
|
||||
if (count($codes) === 1) {
|
||||
return $items[$codes[0]] ?? [];
|
||||
}
|
||||
|
||||
return array_values(array_intersect_key($items, array_flip($codes)));
|
||||
}
|
||||
|
||||
protected function menuNodes(string $panel): array
|
||||
{
|
||||
return (array) config("menu.$panel", config('menu.admin', []));
|
||||
}
|
||||
|
||||
protected function dictItems(): array
|
||||
{
|
||||
return $this->normalizeDictItems((array) config('dict', []));
|
||||
}
|
||||
|
||||
protected function normalizeDictItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($items as $key => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = trim((string) ($item['code'] ?? (is_string($key) ? $key : '')));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ((array) ($item['list'] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$list[] = [
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => $row['value'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$normalized[$code] = [
|
||||
'name' => (string) ($item['name'] ?? $code),
|
||||
'code' => $code,
|
||||
'description' => (string) ($item['description'] ?? ''),
|
||||
'list' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
protected function filterByRoles(array $nodes, array $roles): array
|
||||
{
|
||||
return array_values(array_filter($nodes, function (array $node) use ($roles): bool {
|
||||
$metaRoles = (array) ($node['meta']['roles'] ?? []);
|
||||
if ($metaRoles !== [] && count(array_intersect($metaRoles, $roles)) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($node['meta']['disable'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
protected function buildTree(array $nodes): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($nodes as $node) {
|
||||
$node['children'] = null;
|
||||
$parentId = (string) ($node['parentId'] ?? '0');
|
||||
$grouped[$parentId][] = $node;
|
||||
}
|
||||
|
||||
$build = function (string $parentId) use (&$build, &$grouped): array {
|
||||
$children = $grouped[$parentId] ?? [];
|
||||
usort($children, function (array $left, array $right): int {
|
||||
$leftSort = (int) ($left['meta']['sort'] ?? 0);
|
||||
$rightSort = (int) ($right['meta']['sort'] ?? 0);
|
||||
|
||||
return $leftSort <=> $rightSort;
|
||||
});
|
||||
|
||||
foreach ($children as &$child) {
|
||||
$child['children'] = $build((string) $child['id']);
|
||||
if ($child['children'] === []) {
|
||||
$child['children'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $children;
|
||||
};
|
||||
|
||||
return $build('0');
|
||||
}
|
||||
|
||||
protected function normalizeRedirects(array $tree): array
|
||||
{
|
||||
foreach ($tree as &$node) {
|
||||
if (!empty($node['children']) && is_array($node['children'])) {
|
||||
$childPath = $this->firstRenderablePath($node['children']);
|
||||
if ($childPath !== null) {
|
||||
$node['redirect'] = $childPath;
|
||||
}
|
||||
$node['children'] = $this->normalizeRedirects($node['children']);
|
||||
}
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
protected function firstRenderablePath(array $nodes): ?string
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$path = (string) ($node['path'] ?? '');
|
||||
if ($path !== '') {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
281
app/service/file/FileRecordCommandService.php
Normal file
281
app/service/file/FileRecordCommandService.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\file\FileRecordRepository;
|
||||
use app\service\file\storage\StorageManager;
|
||||
use support\Request;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件命令服务。
|
||||
*/
|
||||
class FileRecordCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected FileRecordQueryService $fileRecordQueryService,
|
||||
protected StorageManager $storageManager,
|
||||
protected StorageConfigService $storageConfigService
|
||||
) {
|
||||
}
|
||||
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$this->assertFileUpload($file);
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
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();
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$sourcePath,
|
||||
(string) $file->getUploadName(),
|
||||
$scene,
|
||||
$visibility,
|
||||
$engine,
|
||||
null,
|
||||
'upload'
|
||||
);
|
||||
|
||||
try {
|
||||
$asset = $this->fileRecordRepository->create([
|
||||
'scene' => (int) $result['scene'],
|
||||
'source_type' => (int) $result['source_type'],
|
||||
'visibility' => (int) $result['visibility'],
|
||||
'storage_engine' => (int) $result['storage_engine'],
|
||||
'original_name' => (string) $result['original_name'],
|
||||
'file_name' => (string) $result['file_name'],
|
||||
'file_ext' => (string) $result['file_ext'],
|
||||
'mime_type' => (string) $result['mime_type'],
|
||||
'size' => (int) $result['size'],
|
||||
'md5' => (string) $result['md5'],
|
||||
'object_key' => (string) $result['object_key'],
|
||||
'url' => (string) $result['url'],
|
||||
'source_url' => (string) ($result['source_url'] ?? ''),
|
||||
'created_by' => $createdBy,
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->fileRecordQueryService->formatModel($asset);
|
||||
} finally {
|
||||
if (is_file($sourcePath)) {
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$remoteUrl = trim($remoteUrl);
|
||||
if ($remoteUrl === '') {
|
||||
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();
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$download['path'],
|
||||
$download['name'],
|
||||
$scene,
|
||||
$visibility,
|
||||
$engine,
|
||||
$remoteUrl,
|
||||
'remote_url'
|
||||
);
|
||||
|
||||
try {
|
||||
$asset = $this->fileRecordRepository->create([
|
||||
'scene' => (int) $result['scene'],
|
||||
'source_type' => (int) $result['source_type'],
|
||||
'visibility' => (int) $result['visibility'],
|
||||
'storage_engine' => (int) $result['storage_engine'],
|
||||
'original_name' => (string) $result['original_name'],
|
||||
'file_name' => (string) $result['file_name'],
|
||||
'file_ext' => (string) $result['file_ext'],
|
||||
'mime_type' => (string) $result['mime_type'],
|
||||
'size' => (int) $result['size'],
|
||||
'md5' => (string) $result['md5'],
|
||||
'object_key' => (string) $result['object_key'],
|
||||
'url' => (string) $result['url'],
|
||||
'source_url' => $remoteUrl,
|
||||
'created_by' => $createdBy,
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->fileRecordQueryService->formatModel($asset);
|
||||
} finally {
|
||||
if (is_file($download['path'])) {
|
||||
@unlink($download['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
if (!$asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storageManager->delete($this->fileRecordQueryService->formatModel($asset));
|
||||
|
||||
return $this->fileRecordRepository->deleteById($id);
|
||||
}
|
||||
|
||||
private function assertFileUpload(UploadFile $file): void
|
||||
{
|
||||
if (!$file->isValid()) {
|
||||
throw new ValidationException('上传文件无效');
|
||||
}
|
||||
|
||||
$sizeLimit = $this->storageConfigService->uploadMaxSizeBytes();
|
||||
$size = (int) $file->getSize();
|
||||
if ($size > $sizeLimit) {
|
||||
throw new BusinessStateException('文件大小超过系统限制');
|
||||
}
|
||||
|
||||
$extension = strtolower((string) $file->getUploadExtension());
|
||||
if ($extension === '') {
|
||||
$extension = strtolower((string) pathinfo((string) $file->getUploadName(), PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
if ($extension !== '' && !in_array($extension, $this->storageConfigService->allowedExtensions(), true)) {
|
||||
throw new BusinessStateException('文件类型暂不支持');
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadRemoteFile(string $remoteUrl, int $scene = 0): array
|
||||
{
|
||||
if (!filter_var($remoteUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($remoteUrl, PHP_URL_SCHEME));
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
throw new ValidationException('仅支持 http 或 https 远程地址');
|
||||
}
|
||||
|
||||
$host = (string) parse_url($remoteUrl, PHP_URL_HOST);
|
||||
if ($host === '') {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) && Request::isIntranetIp($host)) {
|
||||
throw new BusinessStateException('远程地址不允许访问内网资源');
|
||||
}
|
||||
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip !== $host && Request::isIntranetIp($ip)) {
|
||||
throw new BusinessStateException('远程地址不允许访问内网资源');
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'file_asset_');
|
||||
if ($tempPath === false) {
|
||||
throw new BusinessStateException('创建临时文件失败');
|
||||
}
|
||||
|
||||
$mimeType = 'application/octet-stream';
|
||||
$downloadName = basename((string) parse_url($remoteUrl, PHP_URL_PATH));
|
||||
if ($downloadName === '') {
|
||||
$downloadName = 'remote-file';
|
||||
}
|
||||
|
||||
$ch = curl_init($remoteUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_USERAGENT => 'MPay File Asset Downloader',
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $httpCode >= 400) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException($error !== '' ? $error : '远程文件下载失败');
|
||||
}
|
||||
|
||||
if ($effectiveUrl !== '') {
|
||||
$effectiveHost = (string) parse_url($effectiveUrl, PHP_URL_HOST);
|
||||
if ($effectiveHost !== '') {
|
||||
$effectiveIp = gethostbyname($effectiveHost);
|
||||
if ($effectiveIp !== $effectiveHost && Request::isIntranetIp($effectiveIp)) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程地址重定向到了内网资源');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($contentType !== '') {
|
||||
$mimeType = trim(explode(';', $contentType)[0]);
|
||||
}
|
||||
|
||||
if (strlen((string) $body) > $this->storageConfigService->remoteDownloadLimitBytes()) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程文件大小超过系统限制');
|
||||
}
|
||||
|
||||
if (file_put_contents($tempPath, (string) $body) === false) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程文件写入失败');
|
||||
}
|
||||
|
||||
$size = is_file($tempPath) ? (int) filesize($tempPath) : 0;
|
||||
$name = $downloadName;
|
||||
$ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($ext === '') {
|
||||
$ext = match (true) {
|
||||
str_starts_with($mimeType, 'image/jpeg') => 'jpg',
|
||||
str_starts_with($mimeType, 'image/png') => 'png',
|
||||
str_starts_with($mimeType, 'image/gif') => 'gif',
|
||||
str_starts_with($mimeType, 'image/webp') => 'webp',
|
||||
str_starts_with($mimeType, 'image/svg') => 'svg',
|
||||
str_starts_with($mimeType, 'text/plain') => 'txt',
|
||||
str_starts_with($mimeType, 'application/json') => 'json',
|
||||
str_starts_with($mimeType, 'application/xml') => 'xml',
|
||||
default => '',
|
||||
};
|
||||
if ($ext !== '') {
|
||||
$name .= '.' . $ext;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $tempPath,
|
||||
'name' => $name,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $size,
|
||||
'scene' => $scene,
|
||||
];
|
||||
}
|
||||
}
|
||||
176
app/service/file/FileRecordQueryService.php
Normal file
176
app/service/file/FileRecordQueryService.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\repository\file\FileRecordRepository;
|
||||
use app\service\file\storage\StorageManager;
|
||||
|
||||
/**
|
||||
* 文件查询服务。
|
||||
*/
|
||||
class FileRecordQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected StorageManager $storageManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->fileRecordRepository->query()->from('ma_file_asset as f');
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('f.original_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.file_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.object_key', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.source_url', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
foreach (['scene', 'source_type', 'visibility', 'storage_engine'] as $field) {
|
||||
if (array_key_exists($field, $filters) && $filters[$field] !== '' && $filters[$field] !== null) {
|
||||
$query->where('f.' . $field, (int) $filters[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$query->orderByDesc('f.id');
|
||||
|
||||
$paginator = $query->paginate(max(1, $pageSize), ['f.*'], 'page', max(1, $page));
|
||||
$collection = $paginator->getCollection();
|
||||
$collection->transform(function ($row): array {
|
||||
return $this->formatModel($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
if (!$asset) {
|
||||
throw new ResourceNotFoundException('文件不存在', ['id' => $id]);
|
||||
}
|
||||
|
||||
return $this->formatModel($asset);
|
||||
}
|
||||
|
||||
public function formatModel(mixed $asset): array
|
||||
{
|
||||
$id = (int) $this->field($asset, 'id', 0);
|
||||
$scene = (int) $this->field($asset, 'scene', FileConstant::SCENE_OTHER);
|
||||
$visibility = (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE);
|
||||
$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';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'scene' => $scene,
|
||||
'scene_text' => (string) (FileConstant::sceneMap()[$scene] ?? '未知'),
|
||||
'source_type' => $sourceType,
|
||||
'source_type_text' => (string) (FileConstant::sourceTypeMap()[$sourceType] ?? '未知'),
|
||||
'visibility' => $visibility,
|
||||
'visibility_text' => (string) (FileConstant::visibilityMap()[$visibility] ?? '未知'),
|
||||
'storage_engine' => $storageEngine,
|
||||
'storage_engine_text' => (string) (FileConstant::storageEngineMap()[$storageEngine] ?? '未知'),
|
||||
'original_name' => (string) $this->field($asset, 'original_name', ''),
|
||||
'file_name' => (string) $this->field($asset, 'file_name', ''),
|
||||
'file_ext' => (string) $this->field($asset, 'file_ext', ''),
|
||||
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
|
||||
'size' => $size,
|
||||
'size_text' => $this->formatSize($size),
|
||||
'md5' => (string) $this->field($asset, 'md5', ''),
|
||||
'object_key' => (string) $this->field($asset, 'object_key', ''),
|
||||
'source_url' => (string) $this->field($asset, 'source_url', ''),
|
||||
'url' => $previewUrl,
|
||||
'public_url' => $publicUrl,
|
||||
'preview_url' => $previewUrl,
|
||||
'download_url' => $id > 0 ? '/adminapi/file-asset/' . $id . '/download' : '',
|
||||
'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', ''),
|
||||
'is_image' => $scene === FileConstant::SCENE_IMAGE || str_starts_with(strtolower((string) $this->field($asset, 'mime_type', '')), 'image/'),
|
||||
'created_at' => $this->formatDateTime($this->field($asset, 'created_at', null)),
|
||||
'updated_at' => $this->formatDateTime($this->field($asset, 'updated_at', null)),
|
||||
];
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
return [
|
||||
'sourceTypes' => $this->toOptions(FileConstant::sourceTypeMap()),
|
||||
'visibilities' => $this->toOptions(FileConstant::visibilityMap()),
|
||||
'scenes' => $this->toOptions(FileConstant::sceneMap()),
|
||||
'storageEngines' => $this->toOptions(FileConstant::storageEngineMap()),
|
||||
'selectableStorageEngines' => $this->toOptions(FileConstant::selectableStorageEngineMap()),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatSize(int $size): string
|
||||
{
|
||||
if ($size <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$index = 0;
|
||||
$value = (float) $size;
|
||||
while ($value >= 1024 && $index < count($units) - 1) {
|
||||
$value /= 1024;
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $index === 0 ? (string) (int) $value . ' ' . $units[$index] : number_format($value, 2) . ' ' . $units[$index];
|
||||
}
|
||||
|
||||
private function toOptions(array $map): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($map as $value => $label) {
|
||||
$options[] = [
|
||||
'label' => (string) $label,
|
||||
'value' => (int) $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function field(mixed $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (is_array($asset)) {
|
||||
return $asset[$key] ?? $default;
|
||||
}
|
||||
|
||||
if (is_object($asset) && isset($asset->{$key})) {
|
||||
return $asset->{$key};
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeAsset(mixed $asset): array
|
||||
{
|
||||
return $this->field($asset, 'id', null) === null ? [] : [
|
||||
'id' => (int) $this->field($asset, 'id', 0),
|
||||
'storage_engine' => (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL),
|
||||
'visibility' => (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE),
|
||||
'original_name' => (string) $this->field($asset, 'original_name', ''),
|
||||
'object_key' => (string) $this->field($asset, 'object_key', ''),
|
||||
'source_url' => (string) $this->field($asset, 'source_url', ''),
|
||||
'url' => (string) $this->field($asset, 'url', ''),
|
||||
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/service/file/FileRecordService.php
Normal file
64
app/service/file/FileRecordService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\service\file\storage\StorageManager;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件门面服务。
|
||||
*/
|
||||
class FileRecordService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordQueryService $queryService,
|
||||
protected FileRecordCommandService $commandService,
|
||||
protected StorageManager $storageManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
return $this->queryService->detail($id);
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
return $this->queryService->options();
|
||||
}
|
||||
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->upload($file, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->importRemote($remoteUrl, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
|
||||
public function previewResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
|
||||
return $this->storageManager->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
|
||||
return $this->storageManager->downloadResponse($asset);
|
||||
}
|
||||
}
|
||||
202
app/service/file/StorageConfigService.php
Normal file
202
app/service/file/StorageConfigService.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\FileConstant;
|
||||
|
||||
/**
|
||||
* 文件存储配置服务。
|
||||
*/
|
||||
class StorageConfigService extends BaseService
|
||||
{
|
||||
public function defaultEngine(): int
|
||||
{
|
||||
return $this->normalizeSelectableEngine((int) sys_config(FileConstant::CONFIG_DEFAULT_ENGINE, FileConstant::STORAGE_LOCAL));
|
||||
}
|
||||
|
||||
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 !== '') {
|
||||
return rtrim($siteUrl, '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function localPublicDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_DIR, 'storage/uploads'), "/ \t\n\r\0\x0B");
|
||||
|
||||
return $dir !== '' ? $dir : 'storage/uploads';
|
||||
}
|
||||
|
||||
public function localPrivateDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PRIVATE_DIR, 'storage/private'), "/ \t\n\r\0\x0B");
|
||||
|
||||
return $dir !== '' ? $dir : 'storage/private';
|
||||
}
|
||||
|
||||
public function uploadMaxSizeBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_UPLOAD_MAX_SIZE_MB, 20));
|
||||
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
public function remoteDownloadLimitBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_REMOTE_DOWNLOAD_LIMIT_MB, 10));
|
||||
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
public function allowedExtensions(): array
|
||||
{
|
||||
$raw = trim((string) sys_config(FileConstant::CONFIG_ALLOWED_EXTENSIONS, implode(',', FileConstant::defaultAllowedExtensions())));
|
||||
if ($raw === '') {
|
||||
return FileConstant::defaultAllowedExtensions();
|
||||
}
|
||||
|
||||
$extensions = array_filter(array_map(static fn (string $value): string => strtolower(trim($value)), explode(',', $raw)));
|
||||
|
||||
return array_values(array_unique($extensions));
|
||||
}
|
||||
|
||||
public function ossConfig(): array
|
||||
{
|
||||
return [
|
||||
'region' => trim((string) sys_config(FileConstant::CONFIG_OSS_REGION, '')),
|
||||
'endpoint' => trim((string) sys_config(FileConstant::CONFIG_OSS_ENDPOINT, '')),
|
||||
'bucket' => trim((string) sys_config(FileConstant::CONFIG_OSS_BUCKET, '')),
|
||||
'access_key_id' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_ID, '')),
|
||||
'access_key_secret' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_SECRET, '')),
|
||||
'public_domain' => trim((string) sys_config(FileConstant::CONFIG_OSS_PUBLIC_DOMAIN, '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function cosConfig(): array
|
||||
{
|
||||
return [
|
||||
'region' => trim((string) sys_config(FileConstant::CONFIG_COS_REGION, '')),
|
||||
'bucket' => trim((string) sys_config(FileConstant::CONFIG_COS_BUCKET, '')),
|
||||
'secret_id' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_ID, '')),
|
||||
'secret_key' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_KEY, '')),
|
||||
'public_domain' => trim((string) sys_config(FileConstant::CONFIG_COS_PUBLIC_DOMAIN, '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeScene(int|string|null $scene = null, string $originalName = '', string $mimeType = ''): int
|
||||
{
|
||||
$scene = (int) $scene;
|
||||
if ($scene === FileConstant::SCENE_IMAGE
|
||||
|| $scene === FileConstant::SCENE_CERTIFICATE
|
||||
|| $scene === FileConstant::SCENE_TEXT
|
||||
|| $scene === FileConstant::SCENE_OTHER
|
||||
) {
|
||||
return $scene;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($ext !== '') {
|
||||
if (isset(FileConstant::imageExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'image/')) {
|
||||
return FileConstant::SCENE_IMAGE;
|
||||
}
|
||||
|
||||
if (isset(FileConstant::certificateExtensionMap()[$ext])) {
|
||||
return FileConstant::SCENE_CERTIFICATE;
|
||||
}
|
||||
|
||||
if (isset(FileConstant::textExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'text/')) {
|
||||
return FileConstant::SCENE_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
return FileConstant::SCENE_OTHER;
|
||||
}
|
||||
|
||||
public function normalizeVisibility(int|string|null $visibility = null, int $scene = FileConstant::SCENE_OTHER): int
|
||||
{
|
||||
$visibility = (int) $visibility;
|
||||
if ($visibility === FileConstant::VISIBILITY_PUBLIC || $visibility === FileConstant::VISIBILITY_PRIVATE) {
|
||||
return $visibility;
|
||||
}
|
||||
|
||||
return $scene === FileConstant::SCENE_IMAGE
|
||||
? FileConstant::VISIBILITY_PUBLIC
|
||||
: FileConstant::VISIBILITY_PRIVATE;
|
||||
}
|
||||
|
||||
public function normalizeEngine(int|string|null $engine = null): int
|
||||
{
|
||||
$engine = (int) $engine;
|
||||
|
||||
return $this->normalizeSelectableEngine($engine);
|
||||
}
|
||||
|
||||
public function sceneFolder(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
FileConstant::SCENE_IMAGE => 'image',
|
||||
FileConstant::SCENE_CERTIFICATE => 'certificate',
|
||||
FileConstant::SCENE_TEXT => 'text',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
public function buildObjectKey(int $scene, int $visibility, string $extension): string
|
||||
{
|
||||
$extension = strtolower(trim($extension, ". \t\n\r\0\x0B"));
|
||||
$timestampPath = date('Y/m/d');
|
||||
$random = bin2hex(random_bytes(8));
|
||||
$name = date('YmdHis') . '_' . $random;
|
||||
if ($extension !== '') {
|
||||
$name .= '.' . $extension;
|
||||
}
|
||||
|
||||
$rootDir = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
? $this->localPublicDir()
|
||||
: $this->localPrivateDir();
|
||||
|
||||
return trim($rootDir . '/' . $this->sceneFolder($scene) . '/' . $timestampPath . '/' . $name, '/');
|
||||
}
|
||||
|
||||
public function buildLocalAbsolutePath(int $visibility, string $objectKey): string
|
||||
{
|
||||
$root = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
? public_path()
|
||||
: runtime_path();
|
||||
$relativePath = trim(str_replace('\\', '/', $objectKey), '/');
|
||||
|
||||
return rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
|
||||
}
|
||||
|
||||
public function buildLocalPublicUrl(string $objectKey): string
|
||||
{
|
||||
$path = trim(str_replace('\\', '/', $objectKey), '/');
|
||||
$baseUrl = $this->localPublicBaseUrl();
|
||||
|
||||
if ($baseUrl !== '') {
|
||||
return rtrim($baseUrl, '/') . '/' . $path;
|
||||
}
|
||||
|
||||
return '/' . $path;
|
||||
}
|
||||
|
||||
private function normalizeSelectableEngine(int $engine): int
|
||||
{
|
||||
return match ($engine) {
|
||||
FileConstant::STORAGE_LOCAL,
|
||||
FileConstant::STORAGE_ALIYUN_OSS,
|
||||
FileConstant::STORAGE_TENCENT_COS => $engine,
|
||||
default => FileConstant::STORAGE_LOCAL,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
app/service/file/storage/AbstractStorageDriver.php
Normal file
113
app/service/file/storage/AbstractStorageDriver.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\service\file\StorageConfigService;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动抽象基类。
|
||||
*/
|
||||
abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService
|
||||
) {
|
||||
}
|
||||
|
||||
protected function assetValue(array $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $asset[$key] ?? $default;
|
||||
}
|
||||
|
||||
protected function resolveLocalAbsolutePath(array $asset): string
|
||||
{
|
||||
$objectKey = trim((string) $this->assetValue($asset, 'object_key', ''));
|
||||
$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;
|
||||
}
|
||||
|
||||
protected function bodyResponse(string $body, string $mimeType = 'application/octet-stream', int $status = 200, array $headers = []): Response
|
||||
{
|
||||
$responseHeaders = array_merge([
|
||||
'Content-Type' => $mimeType !== '' ? $mimeType : 'application/octet-stream',
|
||||
], $headers);
|
||||
|
||||
return response($body, $status, $responseHeaders);
|
||||
}
|
||||
|
||||
protected function downloadBodyResponse(string $body, string $downloadName, string $mimeType = 'application/octet-stream'): Response
|
||||
{
|
||||
$response = $this->bodyResponse($body, $mimeType, 200, [
|
||||
'Content-Disposition' => 'attachment; filename="' . str_replace(['"', "\r", "\n", "\0"], '', $downloadName) . '"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function responseFromPath(string $path, string $downloadName = '', bool $attachment = false): Response
|
||||
{
|
||||
if ($attachment) {
|
||||
return response()->download($path, $downloadName);
|
||||
}
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
protected function localPreviewResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path);
|
||||
}
|
||||
|
||||
protected function localDownloadResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path, (string) $this->assetValue($asset, 'original_name', basename($path)), true);
|
||||
}
|
||||
|
||||
protected function scenePrefix(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
FileConstant::SCENE_IMAGE => 'image',
|
||||
FileConstant::SCENE_CERTIFICATE => 'certificate',
|
||||
FileConstant::SCENE_TEXT => 'text',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
}
|
||||
188
app/service/file/storage/CosStorageDriver.php
Normal file
188
app/service/file/storage/CosStorageDriver.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use Qcloud\Cos\Client as CosClient;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 腾讯云 COS 文件存储驱动。
|
||||
*/
|
||||
class CosStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_TENCENT_COS;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
foreach (['region', 'bucket', 'secret_id', 'secret_key'] as $key) {
|
||||
if (trim((string) ($config[$key] ?? '')) === '') {
|
||||
throw new BusinessStateException('腾讯云 COS 存储配置未完整');
|
||||
}
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$client->putObject([
|
||||
'Bucket' => (string) $config['bucket'],
|
||||
'Key' => $objectKey,
|
||||
'Body' => fopen($sourcePath, 'rb'),
|
||||
]);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'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 function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$client->deleteObject([
|
||||
'Bucket' => (string) $config['bucket'],
|
||||
'Key' => $objectKey,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$customDomain = trim((string) ($config['public_domain'] ?? ''));
|
||||
if ($customDomain !== '') {
|
||||
return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
$region = trim((string) ($config['region'] ?? ''));
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
if ($region === '' || $bucket === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://' . $bucket . '.cos.' . $region . '.myqcloud.com/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $client->getObjectUrl(
|
||||
(string) $config['bucket'],
|
||||
$objectKey
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
}
|
||||
|
||||
private function client(array $config): CosClient
|
||||
{
|
||||
return new CosClient([
|
||||
'region' => (string) $config['region'],
|
||||
'credentials' => [
|
||||
'secretId' => (string) $config['secret_id'],
|
||||
'secretKey' => (string) $config['secret_key'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$result = $client->getObject([
|
||||
'Bucket' => $bucket,
|
||||
'Key' => $objectKey,
|
||||
]);
|
||||
|
||||
$body = '';
|
||||
if (is_string($result)) {
|
||||
$body = $result;
|
||||
} elseif (is_object($result) && method_exists($result, '__toString')) {
|
||||
$body = (string) $result;
|
||||
} elseif (is_array($result)) {
|
||||
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
|
||||
}
|
||||
|
||||
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
|
||||
|
||||
if ($attachment) {
|
||||
return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType);
|
||||
}
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/service/file/storage/LocalStorageDriver.php
Normal file
121
app/service/file/storage/LocalStorageDriver.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 本地文件存储驱动。
|
||||
*/
|
||||
class LocalStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_LOCAL;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$absolutePath = $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
|
||||
$publicUrl = (string) ($context['public_url'] ?? '');
|
||||
|
||||
if ($objectKey === '' || $absolutePath === '') {
|
||||
throw new BusinessStateException('文件存储路径无效');
|
||||
}
|
||||
|
||||
$this->ensureDirectory(dirname($absolutePath));
|
||||
|
||||
if (@rename($sourcePath, $absolutePath) === false) {
|
||||
if (!@copy($sourcePath, $absolutePath)) {
|
||||
throw new BusinessStateException('本地文件保存失败');
|
||||
}
|
||||
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
|
||||
@chmod($absolutePath, 0666 & ~umask());
|
||||
|
||||
return [
|
||||
'storage_engine' => $this->engine(),
|
||||
'object_key' => $objectKey,
|
||||
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
'public_url' => $publicUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->localPreviewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->localDownloadResponse($asset);
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
$objectKey = trim((string) ($asset['object_key'] ?? ''));
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$id = (int) ($asset['id'] ?? 0);
|
||||
|
||||
return $id > 0 ? '/adminapi/file-asset/' . $id . '/preview' : '';
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $directory): void
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!@mkdir($directory, 0777, true) && !is_dir($directory)) {
|
||||
throw new BusinessStateException('文件目录创建失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
196
app/service/file/storage/OssStorageDriver.php
Normal file
196
app/service/file/storage/OssStorageDriver.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use AlibabaCloud\Oss\V2 as Oss;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS 文件存储驱动。
|
||||
*/
|
||||
class OssStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_ALIYUN_OSS;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
foreach (['region', 'bucket', 'access_key_id', 'access_key_secret'] as $key) {
|
||||
if (trim((string) ($config[$key] ?? '')) === '') {
|
||||
throw new BusinessStateException('阿里云 OSS 存储配置未完整');
|
||||
}
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$request = new Oss\Models\PutObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$request->body = Oss\Utils::streamFor(fopen($sourcePath, 'rb'));
|
||||
|
||||
$client->putObject($request);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'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 function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$request = new Oss\Models\DeleteObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$client->deleteObject($request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$customDomain = trim((string) ($config['public_domain'] ?? ''));
|
||||
if ($customDomain !== '') {
|
||||
return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
$endpoint = trim((string) ($config['endpoint'] ?? ''));
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
if ($endpoint === '' || $bucket === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$endpoint = preg_replace('#^https?://#i', '', $endpoint) ?: $endpoint;
|
||||
|
||||
return 'https://' . $bucket . '.' . ltrim($endpoint, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$result = $client->presign($request);
|
||||
|
||||
return (string) ($result->url ?? '');
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
}
|
||||
|
||||
private function client(array $config): Oss\Client
|
||||
{
|
||||
$provider = new Oss\Credentials\StaticCredentialsProvider(
|
||||
accessKeyId: (string) $config['access_key_id'],
|
||||
accessKeySecret: (string) $config['access_key_secret']
|
||||
);
|
||||
|
||||
$cfg = Oss\Config::loadDefault();
|
||||
$cfg->setCredentialsProvider(credentialsProvider: $provider);
|
||||
$cfg->setRegion(region: (string) $config['region']);
|
||||
|
||||
$endpoint = trim((string) ($config['endpoint'] ?? ''));
|
||||
if ($endpoint !== '') {
|
||||
$cfg->setEndpoint(endpoint: $endpoint);
|
||||
}
|
||||
|
||||
return new Oss\Client($cfg);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: $bucket,
|
||||
key: $objectKey
|
||||
);
|
||||
$result = $client->getObject($request);
|
||||
$body = (string) $result->body->getContents();
|
||||
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
|
||||
|
||||
if ($attachment) {
|
||||
return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType);
|
||||
}
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/service/file/storage/RemoteUrlStorageDriver.php
Normal file
53
app/service/file/storage/RemoteUrlStorageDriver.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 远程引用驱动。
|
||||
*/
|
||||
class RemoteUrlStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_REMOTE_URL;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
throw new BusinessStateException('远程引用模式不支持直接上传,请先下载后再入库');
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
if ($url === '') {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
}
|
||||
}
|
||||
25
app/service/file/storage/StorageDriverInterface.php
Normal file
25
app/service/file/storage/StorageDriverInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动接口。
|
||||
*/
|
||||
interface StorageDriverInterface
|
||||
{
|
||||
public function engine(): int;
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array;
|
||||
|
||||
public function delete(array $asset): bool;
|
||||
|
||||
public function previewResponse(array $asset): Response;
|
||||
|
||||
public function downloadResponse(array $asset): Response;
|
||||
|
||||
public function publicUrl(array $asset): string;
|
||||
|
||||
public function temporaryUrl(array $asset): string;
|
||||
}
|
||||
158
app/service/file/storage/StorageManager.php
Normal file
158
app/service/file/storage/StorageManager.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\service\file\StorageConfigService;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动管理器。
|
||||
*/
|
||||
class StorageManager
|
||||
{
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService,
|
||||
protected LocalStorageDriver $localStorageDriver,
|
||||
protected OssStorageDriver $ossStorageDriver,
|
||||
protected CosStorageDriver $cosStorageDriver,
|
||||
protected RemoteUrlStorageDriver $remoteUrlStorageDriver
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildContext(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
?int $scene = null,
|
||||
?int $visibility = null,
|
||||
?int $engine = null,
|
||||
?string $sourceUrl = null,
|
||||
string $sourceType = 'upload'
|
||||
): array {
|
||||
$mimeType = $this->guessMimeType($sourcePath, $originalName);
|
||||
$scene = $this->storageConfigService->normalizeScene($scene, $originalName, $mimeType);
|
||||
$visibility = $this->storageConfigService->normalizeVisibility($visibility, $scene);
|
||||
$engine = $this->storageConfigService->normalizeEngine($engine ?? $this->storageConfigService->defaultEngine());
|
||||
$ext = strtolower(trim(pathinfo($originalName, PATHINFO_EXTENSION)));
|
||||
if ($ext === '') {
|
||||
$ext = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
$objectKey = $this->storageConfigService->buildObjectKey($scene, $visibility, $ext);
|
||||
$publicUrl = $this->buildPublicUrlByEngine($engine, $visibility, $objectKey);
|
||||
|
||||
return [
|
||||
'scene' => $scene,
|
||||
'visibility' => $visibility,
|
||||
'storage_engine' => $engine,
|
||||
'source_type' => $sourceType === 'remote_url' ? FileConstant::SOURCE_REMOTE_URL : FileConstant::SOURCE_UPLOAD,
|
||||
'source_url' => (string) ($sourceUrl ?? ''),
|
||||
'original_name' => $originalName,
|
||||
'file_name' => basename($objectKey),
|
||||
'file_ext' => $ext,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => is_file($sourcePath) ? (int) filesize($sourcePath) : 0,
|
||||
'md5' => is_file($sourcePath) ? (string) md5_file($sourcePath) : '',
|
||||
'object_key' => $objectKey,
|
||||
'public_url' => $publicUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function storeFromPath(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
?int $scene = null,
|
||||
?int $visibility = null,
|
||||
?int $engine = null,
|
||||
?string $sourceUrl = null,
|
||||
string $sourceType = 'upload'
|
||||
): array {
|
||||
$context = $this->buildContext($sourcePath, $originalName, $scene, $visibility, $engine, $sourceUrl, $sourceType);
|
||||
$driver = $this->resolveDriver((int) $context['storage_engine']);
|
||||
|
||||
return array_merge($context, $driver->storeFromPath($sourcePath, $context));
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->delete($asset);
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->downloadResponse($asset);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->publicUrl($asset);
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->temporaryUrl($asset);
|
||||
}
|
||||
|
||||
public function resolveDriver(int $engine): StorageDriverInterface
|
||||
{
|
||||
return match ($engine) {
|
||||
FileConstant::STORAGE_LOCAL => $this->localStorageDriver,
|
||||
FileConstant::STORAGE_ALIYUN_OSS => $this->ossStorageDriver,
|
||||
FileConstant::STORAGE_TENCENT_COS => $this->cosStorageDriver,
|
||||
FileConstant::STORAGE_REMOTE_URL => $this->remoteUrlStorageDriver,
|
||||
default => $this->localStorageDriver,
|
||||
};
|
||||
}
|
||||
|
||||
private function buildPublicUrlByEngine(int $engine, int $visibility, string $objectKey): string
|
||||
{
|
||||
if ($engine === FileConstant::STORAGE_LOCAL && $visibility === FileConstant::VISIBILITY_PUBLIC) {
|
||||
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function guessMimeType(string $sourcePath, string $originalName): string
|
||||
{
|
||||
$mimeType = '';
|
||||
if (is_file($sourcePath) && function_exists('mime_content_type')) {
|
||||
$detected = @mime_content_type($sourcePath);
|
||||
if (is_string($detected)) {
|
||||
$mimeType = trim($detected);
|
||||
}
|
||||
}
|
||||
|
||||
if ($mimeType !== '') {
|
||||
return $mimeType;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
return match ($ext) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
'bmp' => 'image/bmp',
|
||||
'txt', 'log', 'md', 'ini', 'conf', 'yml', 'yaml' => 'text/plain',
|
||||
'json' => 'application/json',
|
||||
'xml' => 'application/xml',
|
||||
'csv' => 'text/csv',
|
||||
'pem' => 'application/x-pem-file',
|
||||
'crt', 'cer' => 'application/x-x509-ca-cert',
|
||||
'key' => 'application/octet-stream',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
}
|
||||
269
app/service/merchant/MerchantCommandService.php
Normal file
269
app/service/merchant/MerchantCommandService.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantGroup;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\merchant\base\MerchantGroupRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
use app\service\merchant\security\MerchantApiCredentialService;
|
||||
|
||||
/**
|
||||
* 商户命令服务。
|
||||
*
|
||||
* 负责商户创建、更新、删除、密码和登录元数据这类写操作。
|
||||
*/
|
||||
class MerchantCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected MerchantGroupRepository $merchantGroupRepository,
|
||||
protected MerchantQueryService $merchantQueryService,
|
||||
protected MerchantApiCredentialService $merchantApiCredentialService,
|
||||
protected MerchantAccountRepository $merchantAccountRepository,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected MerchantAccountService $merchantAccountService
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(array $data): Merchant
|
||||
{
|
||||
return $this->transaction(function () use ($data) {
|
||||
$merchantName = trim((string) ($data['merchant_name'] ?? ''));
|
||||
$contactName = trim((string) ($data['contact_name'] ?? ''));
|
||||
$contactPhone = trim((string) ($data['contact_phone'] ?? ''));
|
||||
$groupId = (int) ($data['group_id'] ?? 0);
|
||||
if ($merchantName === '') {
|
||||
throw new ValidationException('商户名称不能为空');
|
||||
}
|
||||
if ($groupId <= 0) {
|
||||
throw new ValidationException('请选择商户分组');
|
||||
}
|
||||
if ($contactName === '') {
|
||||
throw new ValidationException('联系人不能为空');
|
||||
}
|
||||
if ($contactPhone === '') {
|
||||
throw new ValidationException('联系电话不能为空');
|
||||
}
|
||||
if ($groupId > 0) {
|
||||
$this->ensureMerchantGroupEnabled($groupId);
|
||||
}
|
||||
|
||||
$merchantNo = $this->generateMerchantNo();
|
||||
$plainPassword = $this->generateTemporaryPassword();
|
||||
|
||||
$merchant = $this->merchantRepository->create([
|
||||
'merchant_no' => $merchantNo,
|
||||
'password_hash' => password_hash($plainPassword, PASSWORD_DEFAULT),
|
||||
'merchant_name' => $merchantName,
|
||||
'merchant_short_name' => trim((string) ($data['merchant_short_name'] ?? '')),
|
||||
'merchant_type' => (int) ($data['merchant_type'] ?? 0),
|
||||
'group_id' => $groupId,
|
||||
'risk_level' => (int) ($data['risk_level'] ?? 0),
|
||||
'contact_name' => $contactName,
|
||||
'contact_phone' => $contactPhone,
|
||||
'contact_email' => trim((string) ($data['contact_email'] ?? '')),
|
||||
'settlement_account_name' => trim((string) ($data['settlement_account_name'] ?? '')),
|
||||
'settlement_account_no' => trim((string) ($data['settlement_account_no'] ?? '')),
|
||||
'settlement_bank_name' => trim((string) ($data['settlement_bank_name'] ?? '')),
|
||||
'settlement_bank_branch' => trim((string) ($data['settlement_bank_branch'] ?? '')),
|
||||
'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED),
|
||||
'password_updated_at' => $this->now(),
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
]);
|
||||
|
||||
$merchant->plain_password = $plainPassword;
|
||||
|
||||
$this->merchantAccountService->ensureAccountInCurrentTransaction((int) $merchant->id);
|
||||
|
||||
return $merchant;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $merchantId, array $data): ?Merchant
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupId = array_key_exists('group_id', $data) ? (int) $data['group_id'] : (int) $merchant->group_id;
|
||||
if ($groupId > 0) {
|
||||
$this->ensureMerchantGroupEnabled($groupId);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'merchant_name' => (string) ($data['merchant_name'] ?? $merchant->merchant_name),
|
||||
'merchant_short_name' => (string) ($data['merchant_short_name'] ?? $merchant->merchant_short_name),
|
||||
'merchant_type' => (int) ($data['merchant_type'] ?? $merchant->merchant_type),
|
||||
'group_id' => $groupId,
|
||||
'risk_level' => (int) ($data['risk_level'] ?? $merchant->risk_level),
|
||||
'contact_name' => (string) ($data['contact_name'] ?? $merchant->contact_name),
|
||||
'contact_phone' => (string) ($data['contact_phone'] ?? $merchant->contact_phone),
|
||||
'contact_email' => (string) ($data['contact_email'] ?? $merchant->contact_email),
|
||||
'settlement_account_name' => (string) ($data['settlement_account_name'] ?? $merchant->settlement_account_name),
|
||||
'settlement_account_no' => (string) ($data['settlement_account_no'] ?? $merchant->settlement_account_no),
|
||||
'settlement_bank_name' => (string) ($data['settlement_bank_name'] ?? $merchant->settlement_bank_name),
|
||||
'settlement_bank_branch' => (string) ($data['settlement_bank_branch'] ?? $merchant->settlement_bank_branch),
|
||||
'status' => (int) ($data['status'] ?? $merchant->status),
|
||||
'remark' => (string) ($data['remark'] ?? $merchant->remark),
|
||||
];
|
||||
|
||||
if (!$this->merchantRepository->updateById($merchantId, $payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->merchantRepository->find($merchantId);
|
||||
}
|
||||
|
||||
public function delete(int $merchantId): bool
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$dependencies = [
|
||||
['count' => $this->paymentChannelRepository->countByMerchantId($merchantId), 'message' => '已配置支付通道'],
|
||||
['count' => $this->bizOrderRepository->countByMerchantId($merchantId), 'message' => '已存在支付订单'],
|
||||
['count' => $this->refundOrderRepository->countByMerchantId($merchantId), 'message' => '已存在退款订单'],
|
||||
['count' => $this->settlementOrderRepository->countByMerchantId($merchantId), 'message' => '已存在清算记录'],
|
||||
['count' => $this->merchantAccountRepository->countByMerchantId($merchantId), 'message' => '已存在资金账户'],
|
||||
['count' => $this->merchantApiCredentialRepository->countByMerchantId($merchantId), 'message' => '已开通接口凭证'],
|
||||
];
|
||||
|
||||
foreach ($dependencies as $dependency) {
|
||||
if ((int) $dependency['count'] > 0) {
|
||||
throw new BusinessStateException("当前商户{$dependency['message']},请先清理关联数据后再删除", [
|
||||
'merchant_id' => $merchantId,
|
||||
'message' => $dependency['message'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->merchantRepository->deleteById($merchantId);
|
||||
}
|
||||
|
||||
public function resetPassword(int $merchantId, string $password): Merchant
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$this->merchantRepository->updateById($merchantId, [
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'password_updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return $this->merchantRepository->find($merchantId);
|
||||
}
|
||||
|
||||
public function verifyPassword(Merchant $merchant, string $password): bool
|
||||
{
|
||||
return $password !== '' && password_verify($password, (string) $merchant->password_hash);
|
||||
}
|
||||
|
||||
public function touchLoginMeta(int $merchantId, string $ip = ''): void
|
||||
{
|
||||
$this->merchantRepository->updateById($merchantId, [
|
||||
'last_login_at' => $this->now(),
|
||||
'last_login_ip' => trim($ip),
|
||||
]);
|
||||
}
|
||||
|
||||
public function issueCredential(int $merchantId): array
|
||||
{
|
||||
$merchant = $this->merchantQueryService->findById($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId);
|
||||
$credential = $this->merchantApiCredentialService->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential_value' => $credentialValue,
|
||||
'credential' => $credential,
|
||||
];
|
||||
}
|
||||
|
||||
public function findEnabledMerchantByNo(string $merchantNo): Merchant
|
||||
{
|
||||
$merchant = $this->merchantRepository->findByMerchantNo($merchantNo);
|
||||
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_no' => $merchantNo]);
|
||||
}
|
||||
|
||||
if ((int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new BusinessStateException('商户已禁用', ['merchant_no' => $merchantNo]);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
public function ensureMerchantEnabled(int $merchantId): Merchant
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
if ((int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new BusinessStateException('商户已禁用', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
return $merchant;
|
||||
}
|
||||
|
||||
public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup
|
||||
{
|
||||
$group = $this->merchantGroupRepository->find($groupId);
|
||||
|
||||
if (!$group) {
|
||||
throw new ResourceNotFoundException('商户分组不存在', ['merchant_group_id' => $groupId]);
|
||||
}
|
||||
|
||||
if ((int) $group->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new BusinessStateException('商户分组已禁用', ['merchant_group_id' => $groupId]);
|
||||
}
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function generateMerchantNo(): string
|
||||
{
|
||||
do {
|
||||
$merchantNo = $this->generateNo('M');
|
||||
} while ($this->merchantRepository->findByMerchantNo($merchantNo) !== null);
|
||||
|
||||
return $merchantNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成商户初始临时密码。
|
||||
*/
|
||||
private function generateTemporaryPassword(): string
|
||||
{
|
||||
return bin2hex(random_bytes(8));
|
||||
}
|
||||
}
|
||||
130
app/service/merchant/MerchantOverviewQueryService.php
Normal file
130
app/service/merchant/MerchantOverviewQueryService.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupBindRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
|
||||
/**
|
||||
* 商户总览查询服务。
|
||||
*
|
||||
* 负责商户资料、接口凭证、资金、路由、通道和最近交易的总览拼装。
|
||||
*/
|
||||
class MerchantOverviewQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantQueryService $merchantQueryService,
|
||||
protected MerchantAccountRepository $merchantAccountRepository,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected PaymentPollGroupBindRepository $paymentPollGroupBindRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户总览。
|
||||
*/
|
||||
public function overview(int $merchantId): array
|
||||
{
|
||||
$merchant = $this->merchantQueryService->findById($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$account = $this->merchantAccountRepository->findByMerchantId($merchantId);
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
$channelSummary = $this->paymentChannelRepository->summaryByMerchantId($merchantId);
|
||||
|
||||
$bindSummary = [];
|
||||
if ((int) $merchant->group_id > 0) {
|
||||
$bindSummary = $this->paymentPollGroupBindRepository
|
||||
->listSummaryByMerchantGroupId((int) $merchant->group_id)
|
||||
->map(function ($row) {
|
||||
$row->status_text = (int) $row->status === CommonConstant::STATUS_ENABLED ? '启用' : '禁用';
|
||||
$routeModeMap = RouteConstant::routeModeMap();
|
||||
$row->route_mode_text = (string) ($routeModeMap[(int) ($row->route_mode ?? -1)] ?? '未知');
|
||||
|
||||
return $row;
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
$recentPayOrders = $this->payOrderRepository
|
||||
->recentByMerchantId($merchantId, 5)
|
||||
->map(function ($row) {
|
||||
$row->pay_amount_text = $this->formatAmount((int) $row->pay_amount);
|
||||
$row->status_text = match ((int) $row->status) {
|
||||
0 => '待创建',
|
||||
1 => '支付中',
|
||||
2 => '成功',
|
||||
3 => '失败',
|
||||
4 => '关闭',
|
||||
5 => '超时',
|
||||
default => '未知',
|
||||
};
|
||||
|
||||
return $row;
|
||||
})
|
||||
->all();
|
||||
|
||||
$recentSettlements = $this->settlementOrderRepository
|
||||
->recentByMerchantId($merchantId, 5)
|
||||
->map(function ($row) {
|
||||
$row->net_amount_text = $this->formatAmount((int) $row->net_amount);
|
||||
$row->status_text = match ((int) $row->status) {
|
||||
0 => '待处理',
|
||||
1 => '处理中',
|
||||
2 => '成功',
|
||||
3 => '失败',
|
||||
4 => '已冲正',
|
||||
default => '未知',
|
||||
};
|
||||
|
||||
return $row;
|
||||
})
|
||||
->all();
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'access' => [
|
||||
'login_identity' => (string) $merchant->merchant_no,
|
||||
'login_mode_text' => '商户号 + 密码',
|
||||
'has_credential' => $credential !== null,
|
||||
'credential_enabled' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED,
|
||||
'credential_status_text' => (int) ($credential->status ?? 0) === CommonConstant::STATUS_ENABLED ? '已开通' : '未开通',
|
||||
'sign_type_text' => $this->textFromMap((int) ($credential->sign_type ?? 0), \app\common\constant\AuthConstant::signTypeMap()),
|
||||
'credential_last_used_at' => $this->formatDateTime($credential->last_used_at ?? null),
|
||||
],
|
||||
'route' => [
|
||||
'merchant_group_id' => (int) $merchant->group_id,
|
||||
'merchant_group_name' => (string) ($merchant->group_name ?? '未分组'),
|
||||
'bind_count' => count($bindSummary),
|
||||
'binds' => $bindSummary,
|
||||
],
|
||||
'funds' => [
|
||||
'has_account' => $account !== null,
|
||||
'available_balance' => (int) ($account->available_balance ?? 0),
|
||||
'available_balance_text' => $this->formatAmount((int) ($account->available_balance ?? 0)),
|
||||
'frozen_balance' => (int) ($account->frozen_balance ?? 0),
|
||||
'frozen_balance_text' => $this->formatAmount((int) ($account->frozen_balance ?? 0)),
|
||||
],
|
||||
'channels' => [
|
||||
'total_count' => (int) ($channelSummary->total_count ?? 0),
|
||||
'enabled_count' => (int) ($channelSummary->enabled_count ?? 0),
|
||||
'self_count' => (int) ($channelSummary->self_count ?? 0),
|
||||
],
|
||||
'recent_pay_orders' => $recentPayOrders,
|
||||
'recent_settlements' => $recentSettlements,
|
||||
];
|
||||
}
|
||||
}
|
||||
235
app/service/merchant/MerchantQueryService.php
Normal file
235
app/service/merchant/MerchantQueryService.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantPolicy;
|
||||
use app\repository\merchant\base\MerchantGroupRepository;
|
||||
use app\repository\merchant\base\MerchantPolicyRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
|
||||
/**
|
||||
* 商户查询服务。
|
||||
*
|
||||
* 负责商户列表、详情和总览这类只读查询。
|
||||
*/
|
||||
class MerchantQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected MerchantGroupRepository $merchantGroupRepository,
|
||||
protected MerchantPolicyRepository $merchantPolicyRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->merchantRepository->query()
|
||||
->from('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'm.id',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'm.merchant_type',
|
||||
'm.group_id',
|
||||
'm.risk_level',
|
||||
'm.contact_name',
|
||||
'm.contact_phone',
|
||||
'm.contact_email',
|
||||
'm.settlement_account_name',
|
||||
'm.settlement_account_no',
|
||||
'm.settlement_bank_name',
|
||||
'm.settlement_bank_branch',
|
||||
'm.status',
|
||||
'm.last_login_at',
|
||||
'm.last_login_ip',
|
||||
'm.password_updated_at',
|
||||
'm.remark',
|
||||
'm.created_at',
|
||||
'm.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(g.group_name, '未分组') AS group_name")
|
||||
->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text")
|
||||
->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text")
|
||||
->selectRaw("CASE m.status WHEN 0 THEN '禁用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text");
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.contact_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.contact_phone', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$groupId = trim((string) ($filters['group_id'] ?? ''));
|
||||
if ($groupId !== '') {
|
||||
$query->where('m.group_id', (int) $groupId);
|
||||
}
|
||||
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$query->where('m.status', (int) $status);
|
||||
}
|
||||
|
||||
$merchantType = trim((string) ($filters['merchant_type'] ?? ''));
|
||||
if ($merchantType !== '') {
|
||||
$query->where('m.merchant_type', (int) $merchantType);
|
||||
}
|
||||
|
||||
$riskLevel = trim((string) ($filters['risk_level'] ?? ''));
|
||||
if ($riskLevel !== '') {
|
||||
$query->where('m.risk_level', (int) $riskLevel);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('m.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array
|
||||
{
|
||||
$paginator = $this->paginate($filters, $page, $pageSize);
|
||||
|
||||
return [
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
'groups' => $this->enabledGroupOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->merchantRepository->enabledList(['id', 'merchant_no', 'merchant_name'])
|
||||
->map(function (Merchant $merchant): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $merchant->merchant_name, (string) $merchant->merchant_no),
|
||||
'value' => (int) $merchant->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function enabledGroupOptions(): array
|
||||
{
|
||||
return $this->merchantGroupRepository->enabledList(['id', 'group_name'])
|
||||
->map(static function ($group): array {
|
||||
return [
|
||||
'label' => (string) $group->group_name,
|
||||
'value' => (int) $group->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$query = $this->merchantRepository->query()
|
||||
->from('ma_merchant as m')
|
||||
->where('m.status', CommonConstant::STATUS_ENABLED)
|
||||
->select([
|
||||
'm.id',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
]);
|
||||
|
||||
$ids = $this->normalizeIds($filters['ids'] ?? []);
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
|
||||
if (!empty($ids)) {
|
||||
$query->whereIn('m.id', $ids);
|
||||
} elseif ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('m.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
return [
|
||||
'list' => collect($paginator->items())
|
||||
->map(function ($merchant): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $merchant->merchant_name, (string) $merchant->merchant_no),
|
||||
'value' => (int) $merchant->id,
|
||||
'merchant_no' => (string) $merchant->merchant_no,
|
||||
'merchant_name' => (string) $merchant->merchant_name,
|
||||
'merchant_short_name' => (string) ($merchant->merchant_short_name ?? ''),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all(),
|
||||
'total' => (int) $paginator->total(),
|
||||
'page' => (int) $paginator->currentPage(),
|
||||
'size' => (int) $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
public function findById(int $merchantId): ?object
|
||||
{
|
||||
return $this->merchantRepository->query()
|
||||
->from('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'm.id',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'm.merchant_type',
|
||||
'm.group_id',
|
||||
'm.risk_level',
|
||||
'm.contact_name',
|
||||
'm.contact_phone',
|
||||
'm.contact_email',
|
||||
'm.settlement_account_name',
|
||||
'm.settlement_account_no',
|
||||
'm.settlement_bank_name',
|
||||
'm.settlement_bank_branch',
|
||||
'm.status',
|
||||
'm.last_login_at',
|
||||
'm.last_login_ip',
|
||||
'm.password_updated_at',
|
||||
'm.remark',
|
||||
'm.created_at',
|
||||
'm.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(g.group_name, '未分组') AS group_name")
|
||||
->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text")
|
||||
->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text")
|
||||
->selectRaw("CASE m.status WHEN 0 THEN '禁用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text")
|
||||
->where('m.id', $merchantId)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findPolicy(int $merchantId): ?MerchantPolicy
|
||||
{
|
||||
return $this->merchantPolicyRepository->findByMerchantId($merchantId);
|
||||
}
|
||||
|
||||
private function normalizeIds(array|string|int $ids): array
|
||||
{
|
||||
if (is_string($ids)) {
|
||||
$ids = array_filter(array_map('trim', explode(',', $ids)));
|
||||
} elseif (!is_array($ids)) {
|
||||
$ids = [$ids];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0));
|
||||
}
|
||||
}
|
||||
129
app/service/merchant/MerchantService.php
Normal file
129
app/service/merchant/MerchantService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantGroup;
|
||||
use app\model\merchant\MerchantPolicy;
|
||||
|
||||
/**
|
||||
* 商户基础服务门面。
|
||||
*
|
||||
* 仅保留现有控制器和其他服务依赖的统一入口。
|
||||
*/
|
||||
class MerchantService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantQueryService $queryService,
|
||||
protected MerchantCommandService $commandService,
|
||||
protected MerchantOverviewQueryService $overviewQueryService
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function paginateWithGroupOptions(array $filters = [], int $page = 1, int $pageSize = 10): array
|
||||
{
|
||||
return $this->queryService->paginateWithGroupOptions($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->queryService->enabledOptions();
|
||||
}
|
||||
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
return $this->queryService->searchOptions($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function findById(int $merchantId): ?object
|
||||
{
|
||||
return $this->queryService->findById($merchantId);
|
||||
}
|
||||
|
||||
public function create(array $data): Merchant
|
||||
{
|
||||
return $this->commandService->create($data);
|
||||
}
|
||||
|
||||
public function createWithDetail(array $data): ?object
|
||||
{
|
||||
$merchant = $this->create($data);
|
||||
$detail = $this->findById((int) $merchant->id);
|
||||
if ($detail && isset($merchant->plain_password)) {
|
||||
$detail->plain_password = (string) $merchant->plain_password;
|
||||
}
|
||||
|
||||
return $detail ?? $merchant;
|
||||
}
|
||||
|
||||
public function update(int $merchantId, array $data): ?Merchant
|
||||
{
|
||||
return $this->commandService->update($merchantId, $data);
|
||||
}
|
||||
|
||||
public function updateWithDetail(int $merchantId, array $data): ?object
|
||||
{
|
||||
$merchant = $this->update($merchantId, $data);
|
||||
if (!$merchant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findById($merchantId);
|
||||
}
|
||||
|
||||
public function delete(int $merchantId): bool
|
||||
{
|
||||
return $this->commandService->delete($merchantId);
|
||||
}
|
||||
|
||||
public function resetPassword(int $merchantId, string $password): Merchant
|
||||
{
|
||||
return $this->commandService->resetPassword($merchantId, $password);
|
||||
}
|
||||
|
||||
public function verifyPassword(Merchant $merchant, string $password): bool
|
||||
{
|
||||
return $this->commandService->verifyPassword($merchant, $password);
|
||||
}
|
||||
|
||||
public function touchLoginMeta(int $merchantId, string $ip = ''): void
|
||||
{
|
||||
$this->commandService->touchLoginMeta($merchantId, $ip);
|
||||
}
|
||||
|
||||
public function issueCredential(int $merchantId): array
|
||||
{
|
||||
return $this->commandService->issueCredential($merchantId);
|
||||
}
|
||||
|
||||
public function overview(int $merchantId): array
|
||||
{
|
||||
return $this->overviewQueryService->overview($merchantId);
|
||||
}
|
||||
|
||||
public function findEnabledMerchantByNo(string $merchantNo): Merchant
|
||||
{
|
||||
return $this->commandService->findEnabledMerchantByNo($merchantNo);
|
||||
}
|
||||
|
||||
public function ensureMerchantEnabled(int $merchantId): Merchant
|
||||
{
|
||||
return $this->commandService->ensureMerchantEnabled($merchantId);
|
||||
}
|
||||
|
||||
public function ensureMerchantGroupEnabled(int $groupId): MerchantGroup
|
||||
{
|
||||
return $this->commandService->ensureMerchantGroupEnabled($groupId);
|
||||
}
|
||||
|
||||
public function findPolicy(int $merchantId): ?MerchantPolicy
|
||||
{
|
||||
return $this->queryService->findPolicy($merchantId);
|
||||
}
|
||||
}
|
||||
181
app/service/merchant/auth/MerchantAuthService.php
Normal file
181
app/service/merchant/auth/MerchantAuthService.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\auth;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\util\JwtTokenManager;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\service\merchant\portal\MerchantPortalSupportService;
|
||||
|
||||
/**
|
||||
* 商户认证服务。
|
||||
*/
|
||||
class MerchantAuthService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected MerchantPortalSupportService $merchantPortalSupportService,
|
||||
protected JwtTokenManager $jwtTokenManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录商户的资料。
|
||||
*/
|
||||
public function profile(int $merchantId, string $merchantNo = ''): array
|
||||
{
|
||||
$merchant = $this->merchantPortalSupportService->merchantSummary($merchantId);
|
||||
$credential = $merchantId > 0 ? $this->merchantApiCredentialRepository->findByMerchantId($merchantId) : null;
|
||||
|
||||
$isCredentialEnabled = (int) ($credential->status ?? 0) === 1;
|
||||
$user = [
|
||||
'id' => $merchantId,
|
||||
'deptId' => (string) ($merchant['merchant_group_id'] ?? 0),
|
||||
'deptName' => (string) ($merchant['merchant_group_name'] ?? '未分组'),
|
||||
'userName' => (string) ($merchant['merchant_no'] !== '' ? $merchant['merchant_no'] : trim($merchantNo)),
|
||||
'nickName' => (string) ($merchant['merchant_name'] ?? '商户账号'),
|
||||
'email' => (string) ($merchant['contact_email'] ?? ''),
|
||||
'phone' => (string) ($merchant['contact_phone'] ?? ''),
|
||||
'sex' => 2,
|
||||
'avatar' => '',
|
||||
'status' => (int) ($merchant['status'] ?? 1),
|
||||
'description' => '商户主体账号(商户号 + 密码)',
|
||||
'roles' => [
|
||||
[
|
||||
'code' => 'common',
|
||||
'name' => '普通用户',
|
||||
'admin' => false,
|
||||
'disabled' => false,
|
||||
],
|
||||
],
|
||||
'loginIp' => (string) ($merchant['last_login_ip'] ?? ''),
|
||||
'loginDate' => (string) ($merchant['last_login_at'] ?? ''),
|
||||
'createBy' => '系统',
|
||||
'createTime' => (string) ($merchant['created_at'] ?? ''),
|
||||
'updateBy' => null,
|
||||
'updateTime' => (string) ($merchant['updated_at'] ?? ''),
|
||||
'admin' => false,
|
||||
'credential_status' => (int) ($credential->status ?? 0),
|
||||
'credential_status_text' => $isCredentialEnabled ? '已开通' : '未开通',
|
||||
'credential_last_used_at' => (string) ($credential->last_used_at ?? ''),
|
||||
'password_updated_at' => (string) ($merchant['password_updated_at'] ?? ''),
|
||||
];
|
||||
|
||||
return [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] !== '' ? $merchant['merchant_no'] : trim($merchantNo)),
|
||||
'merchant' => $merchant,
|
||||
'user' => $user,
|
||||
'roles' => ['common'],
|
||||
'permissions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户登录 token,并返回商户与登录态信息。
|
||||
*/
|
||||
public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?array
|
||||
{
|
||||
$result = $this->jwtTokenManager->verify('merchant', $token, $ip, $userAgent);
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$merchantId = (int) ($result['session']['merchant_id'] ?? $result['claims']['merchant_id'] ?? 0);
|
||||
if ($merchantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户登录凭证并签发 JWT。
|
||||
*/
|
||||
public function authenticateCredentials(string $merchantNo, string $password, string $ip = '', string $userAgent = ''): array
|
||||
{
|
||||
$merchantNo = trim($merchantNo);
|
||||
$password = trim($password);
|
||||
if ($merchantNo === '' || $password === '') {
|
||||
throw new ValidationException('商户号或密码错误');
|
||||
}
|
||||
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->findByMerchantNo($merchantNo);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('商户号或密码错误');
|
||||
}
|
||||
|
||||
if (!password_verify($password, (string) $merchant->password_hash)) {
|
||||
throw new ValidationException('商户号或密码错误');
|
||||
}
|
||||
|
||||
$this->merchantRepository->updateById((int) $merchant->id, [
|
||||
'last_login_at' => $this->now(),
|
||||
'last_login_ip' => trim($ip),
|
||||
]);
|
||||
|
||||
return $this->issueToken((int) $merchant->id, 86400, $ip, $userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销当前商户登录 token。
|
||||
*/
|
||||
public function revokeToken(string $token): bool
|
||||
{
|
||||
return $this->jwtTokenManager->revoke('merchant', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发新的商户登录 token。
|
||||
*/
|
||||
public function issueToken(int $merchantId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array
|
||||
{
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ValidationException('商户不存在');
|
||||
}
|
||||
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
$issued = $this->jwtTokenManager->issue('merchant', [
|
||||
'sub' => (string) $merchantId,
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_no' => (string) $merchant->merchant_no,
|
||||
], [
|
||||
'merchant_id' => (int) $merchant->id,
|
||||
'merchant_no' => (string) $merchant->merchant_no,
|
||||
'last_login_ip' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
], $ttlSeconds);
|
||||
|
||||
return [
|
||||
'token' => $issued['token'],
|
||||
'expires_in' => $issued['expires_in'],
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential ? [
|
||||
'status' => (int) ($credential->status ?? 0),
|
||||
'sign_type' => (int) ($credential->sign_type ?? 0),
|
||||
'last_used_at' => $credential->last_used_at,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
120
app/service/merchant/group/MerchantGroupService.php
Normal file
120
app/service/merchant/group/MerchantGroupService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\group;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\MerchantGroup;
|
||||
use app\repository\merchant\base\MerchantGroupRepository;
|
||||
|
||||
/**
|
||||
* 商户分组管理服务。
|
||||
*/
|
||||
class MerchantGroupService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantGroupRepository $merchantGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用中的商户分组选项。
|
||||
*
|
||||
* 前端筛选框直接使用 `label / value` 结构即可。
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->merchantGroupRepository->enabledList(['id', 'group_name'])
|
||||
->map(function (MerchantGroup $group): array {
|
||||
return [
|
||||
'label' => (string) $group->group_name,
|
||||
'value' => (int) $group->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户分组。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->merchantGroupRepository->query();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where('group_name', 'like', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
$groupName = trim((string) ($filters['group_name'] ?? ''));
|
||||
if ($groupName !== '') {
|
||||
$query->where('group_name', 'like', '%' . $groupName . '%');
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询商户分组。
|
||||
*/
|
||||
public function findById(int $id): ?MerchantGroup
|
||||
{
|
||||
return $this->merchantGroupRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增商户分组。
|
||||
*/
|
||||
public function create(array $data): MerchantGroup
|
||||
{
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''));
|
||||
return $this->merchantGroupRepository->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新商户分组。
|
||||
*/
|
||||
public function update(int $id, array $data): ?MerchantGroup
|
||||
{
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''), $id);
|
||||
if (!$this->merchantGroupRepository->updateById($id, $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->merchantGroupRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商户分组。
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->merchantGroupRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验商户分组名称唯一。
|
||||
*/
|
||||
private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void
|
||||
{
|
||||
$groupName = trim($groupName);
|
||||
if ($groupName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->merchantGroupRepository->existsByGroupName($groupName, $ignoreId)) {
|
||||
throw new ValidationException('分组名称已存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
app/service/merchant/policy/MerchantPolicyService.php
Normal file
161
app/service/merchant/policy/MerchantPolicyService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\policy;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\merchant\MerchantPolicy;
|
||||
use app\repository\merchant\base\MerchantPolicyRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
|
||||
/**
|
||||
* 商户策略服务。
|
||||
*/
|
||||
class MerchantPolicyService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPolicyRepository $merchantPolicyRepository,
|
||||
protected MerchantRepository $merchantRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户策略列表。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->merchantRepository->query()
|
||||
->from('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->leftJoin('ma_merchant_policy as p', 'm.id', '=', 'p.merchant_id')
|
||||
->select([
|
||||
'm.id as merchant_id',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'm.group_id',
|
||||
'm.status as merchant_status',
|
||||
'g.group_name',
|
||||
'p.id',
|
||||
'p.settlement_cycle_override',
|
||||
'p.auto_payout',
|
||||
'p.min_settlement_amount',
|
||||
'p.retry_policy_json',
|
||||
'p.route_policy_json',
|
||||
'p.fee_rule_override_json',
|
||||
'p.risk_policy_json',
|
||||
'p.remark',
|
||||
'p.created_at',
|
||||
'p.updated_at',
|
||||
]);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($merchantId = (int) ($filters['merchant_id'] ?? 0)) > 0) {
|
||||
$query->where('m.id', $merchantId);
|
||||
}
|
||||
|
||||
if (($groupId = (int) ($filters['group_id'] ?? 0)) > 0) {
|
||||
$query->where('m.group_id', $groupId);
|
||||
}
|
||||
|
||||
if (($hasPolicy = (string) ($filters['has_policy'] ?? '')) !== '') {
|
||||
if ((int) $hasPolicy === 1) {
|
||||
$query->whereNotNull('p.id');
|
||||
} else {
|
||||
$query->whereNull('p.id');
|
||||
}
|
||||
}
|
||||
|
||||
if (($settlementCycle = (string) ($filters['settlement_cycle_override'] ?? '')) !== '') {
|
||||
$query->where('p.settlement_cycle_override', (int) $settlementCycle);
|
||||
}
|
||||
|
||||
if (($autoPayout = (string) ($filters['auto_payout'] ?? '')) !== '') {
|
||||
$query->where('p.auto_payout', (int) $autoPayout);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('m.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->has_policy = $row->id ? 1 : 0;
|
||||
$row->has_policy_text = $row->id ? '已配置' : '未配置';
|
||||
$row->settlement_cycle_text = $this->settlementCycleText((int) ($row->settlement_cycle_override ?? 0));
|
||||
$row->auto_payout_text = (int) ($row->auto_payout ?? 0) === 1 ? '是' : '否';
|
||||
$row->min_settlement_amount_text = $this->formatAmount((int) ($row->min_settlement_amount ?? 0));
|
||||
$row->has_retry_policy = !empty((array) ($row->retry_policy_json ?? []));
|
||||
$row->has_route_policy = !empty((array) ($row->route_policy_json ?? []));
|
||||
$row->has_fee_rule_override = !empty((array) ($row->fee_rule_override_json ?? []));
|
||||
$row->has_risk_policy = !empty((array) ($row->risk_policy_json ?? []));
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单个商户的策略。
|
||||
*/
|
||||
public function findByMerchantId(int $merchantId): ?MerchantPolicy
|
||||
{
|
||||
return $this->merchantPolicyRepository->findByMerchantId($merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存商户策略。
|
||||
*/
|
||||
public function saveByMerchantId(int $merchantId, array $data): MerchantPolicy
|
||||
{
|
||||
if (!$this->merchantRepository->find($merchantId)) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
return $this->merchantPolicyRepository->updateOrCreate(
|
||||
['merchant_id' => $merchantId],
|
||||
[
|
||||
'merchant_id' => $merchantId,
|
||||
'settlement_cycle_override' => (int) ($data['settlement_cycle_override'] ?? 1),
|
||||
'auto_payout' => (int) ($data['auto_payout'] ?? 0),
|
||||
'min_settlement_amount' => (int) ($data['min_settlement_amount'] ?? 0),
|
||||
'retry_policy_json' => $data['retry_policy_json'] ?? [],
|
||||
'route_policy_json' => $data['route_policy_json'] ?? [],
|
||||
'fee_rule_override_json' => $data['fee_rule_override_json'] ?? [],
|
||||
'risk_policy_json' => $data['risk_policy_json'] ?? [],
|
||||
'remark' => (string) ($data['remark'] ?? ''),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商户策略。
|
||||
*/
|
||||
public function deleteByMerchantId(int $merchantId): bool
|
||||
{
|
||||
return $this->merchantPolicyRepository->deleteWhere(['merchant_id' => $merchantId]) > 0;
|
||||
}
|
||||
|
||||
private function settlementCycleText(int $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
0 => 'D0',
|
||||
1 => 'D1',
|
||||
2 => 'D7',
|
||||
3 => 'T1',
|
||||
4 => 'OTHER',
|
||||
default => '未设置',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
50
app/service/merchant/portal/MerchantPortalBalanceService.php
Normal file
50
app/service/merchant/portal/MerchantPortalBalanceService.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
use app\service\account\ledger\MerchantAccountLedgerService;
|
||||
|
||||
/**
|
||||
* 商户门户余额服务。
|
||||
*/
|
||||
class MerchantPortalBalanceService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected MerchantAccountService $merchantAccountService,
|
||||
protected MerchantAccountLedgerService $merchantAccountLedgerService
|
||||
) {
|
||||
}
|
||||
|
||||
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['withdrawable_balance_text'] = $snapshot['available_balance_text'];
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'snapshot' => $snapshot,
|
||||
];
|
||||
}
|
||||
|
||||
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
$filters['merchant_id'] = $merchantId;
|
||||
$paginator = $this->merchantAccountLedgerService->paginate($filters, $page, $pageSize);
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'snapshot' => $this->withdrawableBalance($merchantId)['snapshot'],
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
|
||||
/**
|
||||
* 商户门户通道查询服务。
|
||||
*
|
||||
* 负责商户通道列表查询和通道行格式化。
|
||||
*/
|
||||
class MerchantPortalChannelQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected PaymentChannelRepository $paymentChannelRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前商户的通道列表。
|
||||
*/
|
||||
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
->from('ma_payment_channel as c')
|
||||
->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.merchant_id',
|
||||
'c.name',
|
||||
'c.split_rate_bp',
|
||||
'c.cost_rate_bp',
|
||||
'c.channel_mode',
|
||||
'c.pay_type_id',
|
||||
'c.plugin_code',
|
||||
'c.api_config_id',
|
||||
'c.daily_limit_amount',
|
||||
'c.daily_limit_count',
|
||||
'c.min_amount',
|
||||
'c.max_amount',
|
||||
'c.remark',
|
||||
'c.status',
|
||||
'c.sort_no',
|
||||
'c.created_at',
|
||||
'c.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(t.code, '') AS pay_type_code")
|
||||
->selectRaw("COALESCE(t.name, '') AS pay_type_name")
|
||||
->where('c.merchant_id', $merchantId);
|
||||
|
||||
$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 . '%')
|
||||
->orWhere('t.name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$payTypeId = trim((string) ($filters['pay_type_id'] ?? ''));
|
||||
if ($payTypeId !== '') {
|
||||
$query->where('c.pay_type_id', (int) $payTypeId);
|
||||
}
|
||||
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$query->where('c.status', (int) $status);
|
||||
}
|
||||
|
||||
$channelMode = trim((string) ($filters['channel_mode'] ?? ''));
|
||||
if ($channelMode !== '') {
|
||||
$query->where('c.channel_mode', (int) $channelMode);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderBy('c.sort_no')
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateChannelRow($row);
|
||||
});
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'pay_types' => $this->supportService->enabledPayTypeOptions(),
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
29
app/service/merchant/portal/MerchantPortalChannelService.php
Normal file
29
app/service/merchant/portal/MerchantPortalChannelService.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户门户通道门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给通道查询和路由预览子服务。
|
||||
*/
|
||||
class MerchantPortalChannelService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalChannelQueryService $queryService,
|
||||
protected MerchantPortalRoutePreviewService $routePreviewService
|
||||
) {
|
||||
}
|
||||
|
||||
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->queryService->myChannels($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array
|
||||
{
|
||||
return $this->routePreviewService->routePreview($merchantId, $payTypeId, $payAmount, $statDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\service\merchant\security\MerchantApiCredentialService;
|
||||
|
||||
/**
|
||||
* 商户门户接口凭证命令服务。
|
||||
*/
|
||||
class MerchantPortalCredentialCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected MerchantApiCredentialService $merchantApiCredentialService
|
||||
) {
|
||||
}
|
||||
|
||||
public function issueCredential(int $merchantId): array
|
||||
{
|
||||
$merchant = $this->supportService->merchantSummary($merchantId);
|
||||
$credentialValue = $this->merchantApiCredentialService->issueCredential($merchantId);
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential_value' => $credentialValue,
|
||||
'credential' => $credential ? $this->formatCredential($credential, $merchant) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCredential(\app\model\merchant\MerchantApiCredential $credential, array $merchant): array
|
||||
{
|
||||
$signType = (int) $credential->sign_type;
|
||||
|
||||
return [
|
||||
'id' => (int) $credential->id,
|
||||
'merchant_id' => (int) $credential->merchant_id,
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'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),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
|
||||
/**
|
||||
* 商户门户接口凭证查询服务。
|
||||
*/
|
||||
class MerchantPortalCredentialQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function apiCredential(int $merchantId): array
|
||||
{
|
||||
$merchant = $this->supportService->merchantSummary($merchantId);
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'has_credential' => $credential !== null,
|
||||
'credential' => $credential ? $this->formatCredential($credential, $merchant) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatCredential(MerchantApiCredential $credential, array $merchant): array
|
||||
{
|
||||
$signType = (int) $credential->sign_type;
|
||||
$status = (int) $credential->status;
|
||||
|
||||
return [
|
||||
'id' => (int) $credential->id,
|
||||
'merchant_id' => (int) $credential->merchant_id,
|
||||
'merchant_no' => (string) ($merchant['merchant_no'] ?? ''),
|
||||
'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),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户门户接口凭证门面服务。
|
||||
*/
|
||||
class MerchantPortalCredentialService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalCredentialQueryService $queryService,
|
||||
protected MerchantPortalCredentialCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
public function apiCredential(int $merchantId): array
|
||||
{
|
||||
return $this->queryService->apiCredential($merchantId);
|
||||
}
|
||||
|
||||
public function issueCredential(int $merchantId): array
|
||||
{
|
||||
return $this->commandService->issueCredential($merchantId);
|
||||
}
|
||||
}
|
||||
39
app/service/merchant/portal/MerchantPortalFinanceService.php
Normal file
39
app/service/merchant/portal/MerchantPortalFinanceService.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户门户资金与清算门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给清算与余额子服务。
|
||||
*/
|
||||
class MerchantPortalFinanceService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSettlementService $settlementService,
|
||||
protected MerchantPortalBalanceService $balanceService
|
||||
) {
|
||||
}
|
||||
|
||||
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->settlementService->settlementRecords($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
|
||||
{
|
||||
return $this->settlementService->settlementRecordDetail($settleNo, $merchantId);
|
||||
}
|
||||
|
||||
public function withdrawableBalance(int $merchantId): array
|
||||
{
|
||||
return $this->balanceService->withdrawableBalance($merchantId);
|
||||
}
|
||||
|
||||
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->balanceService->balanceFlows($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
|
||||
/**
|
||||
* 商户门户资料命令服务。
|
||||
*/
|
||||
class MerchantPortalProfileCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected MerchantRepository $merchantRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function updateProfile(int $merchantId, array $data): array
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$this->merchantRepository->updateById($merchantId, [
|
||||
'merchant_short_name' => trim((string) ($data['merchant_short_name'] ?? $merchant->merchant_short_name)),
|
||||
'contact_name' => trim((string) ($data['contact_name'] ?? $merchant->contact_name)),
|
||||
'contact_phone' => trim((string) ($data['contact_phone'] ?? $merchant->contact_phone)),
|
||||
'contact_email' => trim((string) ($data['contact_email'] ?? $merchant->contact_email)),
|
||||
'settlement_account_name' => trim((string) ($data['settlement_account_name'] ?? $merchant->settlement_account_name)),
|
||||
'settlement_account_no' => trim((string) ($data['settlement_account_no'] ?? $merchant->settlement_account_no)),
|
||||
'settlement_bank_name' => trim((string) ($data['settlement_bank_name'] ?? $merchant->settlement_bank_name)),
|
||||
'settlement_bank_branch' => trim((string) ($data['settlement_bank_branch'] ?? $merchant->settlement_bank_branch)),
|
||||
]);
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'pay_types' => $this->supportService->enabledPayTypeOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
public function changePassword(int $merchantId, array $data): array
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$currentPassword = trim((string) ($data['current_password'] ?? ''));
|
||||
$newPassword = trim((string) ($data['password'] ?? ''));
|
||||
|
||||
if (!password_verify($currentPassword, (string) $merchant->password_hash)) {
|
||||
throw new ValidationException('当前密码不正确');
|
||||
}
|
||||
|
||||
$this->merchantRepository->updateById($merchantId, [
|
||||
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
|
||||
'password_updated_at' => $this->now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'updated' => true,
|
||||
'password_updated_at' => $this->supportService->formatDateTime($this->now()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户门户资料查询服务。
|
||||
*/
|
||||
class MerchantPortalProfileQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService
|
||||
) {
|
||||
}
|
||||
|
||||
public function profile(int $merchantId): array
|
||||
{
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'pay_types' => $this->supportService->enabledPayTypeOptions(),
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/service/merchant/portal/MerchantPortalProfileService.php
Normal file
32
app/service/merchant/portal/MerchantPortalProfileService.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户门户资料门面服务。
|
||||
*/
|
||||
class MerchantPortalProfileService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalProfileQueryService $queryService,
|
||||
protected MerchantPortalProfileCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
public function profile(int $merchantId): array
|
||||
{
|
||||
return $this->queryService->profile($merchantId);
|
||||
}
|
||||
|
||||
public function updateProfile(int $merchantId, array $data): array
|
||||
{
|
||||
return $this->commandService->updateProfile($merchantId, $data);
|
||||
}
|
||||
|
||||
public function changePassword(int $merchantId, array $data): array
|
||||
{
|
||||
return $this->commandService->changePassword($merchantId, $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\service\payment\runtime\PaymentRouteService;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 商户门户路由预览服务。
|
||||
*/
|
||||
class MerchantPortalRoutePreviewService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected PaymentRouteService $paymentRouteService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览当前商户的路由选择结果。
|
||||
*/
|
||||
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');
|
||||
|
||||
$response = [
|
||||
'merchant' => $merchant,
|
||||
'pay_types' => $this->supportService->enabledPayTypeOptions(),
|
||||
'pay_type_id' => $payTypeId,
|
||||
'pay_amount' => $payAmount,
|
||||
'pay_amount_text' => $this->supportService->formatAmount($payAmount),
|
||||
'stat_date' => $statDate,
|
||||
'available' => false,
|
||||
'reason' => '请选择支付方式和金额后预览路由',
|
||||
'merchant_group_id' => (int) ($merchant['merchant_group_id'] ?? 0),
|
||||
'merchant_group_name' => (string) ($merchant['merchant_group_name'] ?? ''),
|
||||
'bind' => null,
|
||||
'poll_group' => null,
|
||||
'selected_channel' => null,
|
||||
'candidates' => [],
|
||||
];
|
||||
|
||||
if ($payTypeId <= 0 || $payAmount <= 0) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ((int) $merchant['merchant_group_id'] <= 0) {
|
||||
$response['reason'] = '当前商户未配置商户分组,无法预览路由';
|
||||
return $response;
|
||||
}
|
||||
|
||||
try {
|
||||
$resolved = $this->paymentRouteService->resolveByMerchantGroup(
|
||||
(int) $merchant['merchant_group_id'],
|
||||
$payTypeId,
|
||||
$payAmount,
|
||||
['stat_date' => $statDate]
|
||||
);
|
||||
|
||||
$response['available'] = true;
|
||||
$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);
|
||||
|
||||
$response['candidates'] = array_values(array_map(
|
||||
fn (array $item) => $this->normalizePreviewCandidate($item),
|
||||
(array) ($resolved['candidates'] ?? [])
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
$response['reason'] = $e->getMessage() !== '' ? $e->getMessage() : '路由预览失败';
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function normalizeBind(mixed $bind): ?array
|
||||
{
|
||||
$data = $this->supportService->normalizeModel($bind);
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = (int) ($data['status'] ?? 0);
|
||||
|
||||
return [
|
||||
'merchant_group_id' => (int) ($data['merchant_group_id'] ?? 0),
|
||||
'pay_type_id' => (int) ($data['pay_type_id'] ?? 0),
|
||||
'poll_group_id' => (int) ($data['poll_group_id'] ?? 0),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePollGroup(mixed $pollGroup): ?array
|
||||
{
|
||||
$data = $this->supportService->normalizeModel($pollGroup);
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$routeMode = (int) ($data['route_mode'] ?? 0);
|
||||
$status = (int) ($data['status'] ?? 0);
|
||||
|
||||
return [
|
||||
'id' => (int) ($data['id'] ?? 0),
|
||||
'group_name' => (string) ($data['group_name'] ?? ''),
|
||||
'pay_type_id' => (int) ($data['pay_type_id'] ?? 0),
|
||||
'route_mode' => $routeMode,
|
||||
'route_mode_text' => (string) (RouteConstant::routeModeMap()[$routeMode] ?? '未知'),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePreviewCandidate(mixed $candidate): ?array
|
||||
{
|
||||
$data = is_array($candidate) ? $candidate : $this->supportService->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) ?? [];
|
||||
|
||||
$channelMode = (int) ($channel['channel_mode'] ?? 0);
|
||||
$status = (int) ($channel['status'] ?? 0);
|
||||
$payTypeId = (int) ($channel['pay_type_id'] ?? 0);
|
||||
|
||||
return [
|
||||
'channel_id' => (int) ($channel['id'] ?? 0),
|
||||
'channel_name' => (string) ($channel['name'] ?? ''),
|
||||
'pay_type_id' => $payTypeId,
|
||||
'pay_type_name' => $this->supportService->paymentTypeName($payTypeId),
|
||||
'channel_mode' => $channelMode,
|
||||
'channel_mode_text' => (string) (RouteConstant::channelModeMap()[$channelMode] ?? '未知'),
|
||||
'status' => $status,
|
||||
'status_text' => (string) (CommonConstant::statusMap()[$status] ?? '未知'),
|
||||
'plugin_code' => (string) ($channel['plugin_code'] ?? ''),
|
||||
'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)),
|
||||
'avg_latency_ms' => (int) ($dailyStat['avg_latency_ms'] ?? 0),
|
||||
'avg_latency_text' => $this->supportService->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)),
|
||||
'cost_rate_bp' => (int) ($channel['cost_rate_bp'] ?? 0),
|
||||
'cost_rate_text' => $this->supportService->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_count' => (int) ($channel['daily_limit_count'] ?? 0),
|
||||
'daily_limit_count_text' => $this->supportService->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)),
|
||||
'max_amount' => (int) ($channel['max_amount'] ?? 0),
|
||||
'max_amount_text' => $this->supportService->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),
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/service/merchant/portal/MerchantPortalService.php
Normal file
76
app/service/merchant/portal/MerchantPortalService.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 商户后台基础页面服务门面。
|
||||
*
|
||||
* 仅保留控制器依赖的统一入口,具体能力拆到资料、通道、凭证和资金子服务。
|
||||
*/
|
||||
class MerchantPortalService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalProfileService $profileService,
|
||||
protected MerchantPortalChannelService $channelService,
|
||||
protected MerchantPortalCredentialService $credentialService,
|
||||
protected MerchantPortalFinanceService $financeService
|
||||
) {
|
||||
}
|
||||
|
||||
public function profile(int $merchantId): array
|
||||
{
|
||||
return $this->profileService->profile($merchantId);
|
||||
}
|
||||
|
||||
public function updateProfile(int $merchantId, array $data): array
|
||||
{
|
||||
return $this->profileService->updateProfile($merchantId, $data);
|
||||
}
|
||||
|
||||
public function changePassword(int $merchantId, array $data): array
|
||||
{
|
||||
return $this->profileService->changePassword($merchantId, $data);
|
||||
}
|
||||
|
||||
public function myChannels(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->channelService->myChannels($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function routePreview(int $merchantId, int $payTypeId, int $payAmount, string $statDate = ''): array
|
||||
{
|
||||
return $this->channelService->routePreview($merchantId, $payTypeId, $payAmount, $statDate);
|
||||
}
|
||||
|
||||
public function apiCredential(int $merchantId): array
|
||||
{
|
||||
return $this->credentialService->apiCredential($merchantId);
|
||||
}
|
||||
|
||||
public function issueCredential(int $merchantId): array
|
||||
{
|
||||
return $this->credentialService->issueCredential($merchantId);
|
||||
}
|
||||
|
||||
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->financeService->settlementRecords($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
|
||||
{
|
||||
return $this->financeService->settlementRecordDetail($settleNo, $merchantId);
|
||||
}
|
||||
|
||||
public function withdrawableBalance(int $merchantId): array
|
||||
{
|
||||
return $this->financeService->withdrawableBalance($merchantId);
|
||||
}
|
||||
|
||||
public function balanceFlows(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
return $this->financeService->balanceFlows($filters, $merchantId, $page, $pageSize);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\service\payment\settlement\SettlementOrderQueryService;
|
||||
|
||||
/**
|
||||
* 商户门户清算服务。
|
||||
*/
|
||||
class MerchantPortalSettlementService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantPortalSupportService $supportService,
|
||||
protected SettlementOrderQueryService $settlementOrderQueryService
|
||||
) {
|
||||
}
|
||||
|
||||
public function settlementRecords(array $filters, int $merchantId, int $page, int $pageSize): array
|
||||
{
|
||||
$paginator = $this->settlementOrderQueryService->paginate($filters, $page, $pageSize, $merchantId);
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'list' => $paginator->items(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
public function settlementRecordDetail(string $settleNo, int $merchantId): ?array
|
||||
{
|
||||
try {
|
||||
$detail = $this->settlementOrderQueryService->detail($settleNo, $merchantId);
|
||||
} catch (ResourceNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant' => $this->supportService->merchantSummary($merchantId),
|
||||
'settlement_order' => $detail['settlement_order'] ?? null,
|
||||
'timeline' => $detail['timeline'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
201
app/service/merchant/portal/MerchantPortalSupportService.php
Normal file
201
app/service/merchant/portal/MerchantPortalSupportService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\portal;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
|
||||
/**
|
||||
* 商户门户公共支持服务。
|
||||
*
|
||||
* 统一承接商户门户里复用的商户摘要、支付方式和通用格式化能力。
|
||||
*/
|
||||
class MerchantPortalSupportService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantService $merchantService,
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected PaymentTypeService $paymentTypeService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前商户基础资料摘要。
|
||||
*/
|
||||
public function merchantSummary(int $merchantId): array
|
||||
{
|
||||
$this->merchantService->ensureMerchantEnabled($merchantId);
|
||||
|
||||
$row = $this->merchantRepository->query()
|
||||
->from('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->select([
|
||||
'm.id',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'm.merchant_type',
|
||||
'm.group_id',
|
||||
'm.risk_level',
|
||||
'm.contact_name',
|
||||
'm.contact_phone',
|
||||
'm.contact_email',
|
||||
'm.settlement_account_name',
|
||||
'm.settlement_account_no',
|
||||
'm.settlement_bank_name',
|
||||
'm.settlement_bank_branch',
|
||||
'm.status',
|
||||
'm.last_login_at',
|
||||
'm.last_login_ip',
|
||||
'm.password_updated_at',
|
||||
'm.remark',
|
||||
'm.created_at',
|
||||
'm.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(g.group_name, '未分组') AS merchant_group_name")
|
||||
->selectRaw("COALESCE(m.settlement_account_name, '') AS settlement_account_name_text")
|
||||
->selectRaw("CASE WHEN m.settlement_account_no IS NULL OR m.settlement_account_no = '' THEN '' ELSE CONCAT(LEFT(m.settlement_account_no, 4), '****', RIGHT(m.settlement_account_no, 4)) END AS settlement_account_no_masked")
|
||||
->selectRaw("COALESCE(m.settlement_bank_name, '') AS settlement_bank_name_text")
|
||||
->selectRaw("COALESCE(m.settlement_bank_branch, '') AS settlement_bank_branch_text")
|
||||
->selectRaw("CASE m.merchant_type WHEN 0 THEN '个人' WHEN 1 THEN '企业' ELSE '其他' END AS merchant_type_text")
|
||||
->selectRaw("CASE m.risk_level WHEN 0 THEN '低' WHEN 1 THEN '中' WHEN 2 THEN '高' ELSE '未知' END AS risk_level_text")
|
||||
->selectRaw("CASE m.status WHEN 0 THEN '停用' WHEN 1 THEN '启用' ELSE '未知' END AS status_text")
|
||||
->where('m.id', $merchantId)
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'merchant_id' => (int) $row->id,
|
||||
'merchant_no' => (string) $row->merchant_no,
|
||||
'merchant_name' => (string) $row->merchant_name,
|
||||
'merchant_short_name' => (string) $row->merchant_short_name,
|
||||
'merchant_type' => (int) $row->merchant_type,
|
||||
'merchant_type_text' => (string) $row->merchant_type_text,
|
||||
'merchant_group_id' => (int) $row->group_id,
|
||||
'merchant_group_name' => (string) $row->merchant_group_name,
|
||||
'risk_level' => (int) $row->risk_level,
|
||||
'risk_level_text' => (string) $row->risk_level_text,
|
||||
'contact_name' => (string) $row->contact_name,
|
||||
'contact_phone' => (string) $row->contact_phone,
|
||||
'contact_email' => (string) $row->contact_email,
|
||||
'settlement_account_name' => (string) $row->settlement_account_name,
|
||||
'settlement_account_no' => (string) $row->settlement_account_no,
|
||||
'settlement_bank_name' => (string) $row->settlement_bank_name,
|
||||
'settlement_bank_branch' => (string) $row->settlement_bank_branch,
|
||||
'settlement_account_name_text' => (string) $row->settlement_account_name_text,
|
||||
'settlement_account_no_masked' => (string) $row->settlement_account_no_masked,
|
||||
'settlement_bank_name_text' => (string) $row->settlement_bank_name_text,
|
||||
'settlement_bank_branch_text' => (string) $row->settlement_bank_branch_text,
|
||||
'status' => (int) $row->status,
|
||||
'status_text' => (string) $row->status_text,
|
||||
'last_login_at' => $this->formatDateTime($row->last_login_at ?? null),
|
||||
'last_login_ip' => (string) ($row->last_login_ip ?? ''),
|
||||
'password_updated_at' => $this->formatDateTime($row->password_updated_at ?? null),
|
||||
'remark' => (string) $row->remark,
|
||||
'created_at' => $this->formatDateTime($row->created_at ?? null),
|
||||
'updated_at' => $this->formatDateTime($row->updated_at ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用的支付方式选项。
|
||||
*/
|
||||
public function enabledPayTypeOptions(): array
|
||||
{
|
||||
return $this->paymentTypeService->enabledOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 获取名称。
|
||||
*/
|
||||
public function paymentTypeName(int $payTypeId): string
|
||||
{
|
||||
foreach ($this->paymentTypeService->enabledOptions() as $option) {
|
||||
if ((int) ($option['value'] ?? 0) === $payTypeId) {
|
||||
return (string) ($option['label'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名类型文案。
|
||||
*/
|
||||
public function signTypeText(int $signType): string
|
||||
{
|
||||
return $this->textFromMap($signType, AuthConstant::signTypeMap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\security;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
|
||||
/**
|
||||
* 商户接口凭证查询服务。
|
||||
*
|
||||
* 负责凭证列表和详情展示,不承载验签和写入逻辑。
|
||||
*/
|
||||
class MerchantApiCredentialQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户接口凭证。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery(true);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.api_key', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('c.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$status = (string) ($filters['status'] ?? '');
|
||||
if ($status !== '') {
|
||||
$query->where('c.status', (int) $status);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap());
|
||||
$row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用';
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户接口凭证详情。
|
||||
*/
|
||||
public function findById(int $id): ?MerchantApiCredential
|
||||
{
|
||||
$row = $this->baseQuery(false)->where('c.id', $id)->first();
|
||||
return $this->decorateRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户对应的接口凭证详情。
|
||||
*/
|
||||
public function findByMerchantId(int $merchantId): ?MerchantApiCredential
|
||||
{
|
||||
$row = $this->baseQuery(false)->where('c.merchant_id', $merchantId)->first();
|
||||
return $this->decorateRow($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一构造查询对象。
|
||||
*/
|
||||
private function baseQuery(bool $maskCredentialValue = false)
|
||||
{
|
||||
$query = $this->merchantApiCredentialRepository->query()
|
||||
->from('ma_merchant_api_credential as c')
|
||||
->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.merchant_id',
|
||||
'c.sign_type',
|
||||
'c.status',
|
||||
'c.last_used_at',
|
||||
'c.created_at',
|
||||
'c.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name");
|
||||
|
||||
if ($maskCredentialValue) {
|
||||
$query->selectRaw("CASE WHEN c.api_key IS NULL OR c.api_key = '' THEN '' ELSE CONCAT(LEFT(c.api_key, 4), '****', RIGHT(c.api_key, 4)) END AS api_key_preview");
|
||||
} else {
|
||||
$query->addSelect('c.api_key');
|
||||
$query->selectRaw("COALESCE(c.api_key, '') AS api_key_full");
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给详情行补充展示字段。
|
||||
*/
|
||||
private function decorateRow(mixed $row): ?MerchantApiCredential
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row->api_key_preview = $this->maskCredentialValue((string) ($row->api_key ?? ''), false);
|
||||
$row->sign_type_text = $this->textFromMap((int) $row->sign_type, AuthConstant::signTypeMap());
|
||||
$row->status_text = (int) $row->status === AuthConstant::LOGIN_STATUS_ENABLED ? '启用' : '禁用';
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
265
app/service/merchant/security/MerchantApiCredentialService.php
Normal file
265
app/service/merchant/security/MerchantApiCredentialService.php
Normal file
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\merchant\security;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\merchant\Merchant;
|
||||
use app\model\merchant\MerchantApiCredential;
|
||||
use app\repository\merchant\credential\MerchantApiCredentialRepository;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
|
||||
/**
|
||||
* 商户对外接口凭证与签名校验服务。
|
||||
*
|
||||
* 负责外部支付接口的签名验证、接口凭证发放和最近使用时间更新。
|
||||
*/
|
||||
class MerchantApiCredentialService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected MerchantApiCredentialRepository $merchantApiCredentialRepository,
|
||||
protected MerchantApiCredentialQueryService $merchantApiCredentialQueryService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户接口凭证。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->merchantApiCredentialQueryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验外部支付接口的 MD5 签名。
|
||||
*
|
||||
* @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential}
|
||||
*/
|
||||
public function verifyMd5Sign(array $payload): array
|
||||
{
|
||||
$merchantId = (int) ($payload['pid'] ?? $payload['merchant_id'] ?? 0);
|
||||
$sign = trim((string) ($payload['sign'] ?? ''));
|
||||
$signType = strtoupper((string) ($payload['sign_type'] ?? 'MD5'));
|
||||
$providedKey = trim((string) ($payload['key'] ?? ''));
|
||||
|
||||
if ($merchantId <= 0 || $sign === '') {
|
||||
throw new ValidationException('pid/sign 参数缺失');
|
||||
}
|
||||
|
||||
if ($signType !== 'MD5') {
|
||||
throw new ValidationException('仅支持 MD5 签名');
|
||||
}
|
||||
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
/** @var MerchantApiCredential|null $credential */
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户接口凭证未开通');
|
||||
}
|
||||
|
||||
if ($providedKey !== '' && !hash_equals((string) $credential->api_key, $providedKey)) {
|
||||
throw new ValidationException('商户接口凭证错误');
|
||||
}
|
||||
|
||||
$params = $payload;
|
||||
unset($params['sign'], $params['sign_type'], $params['key']);
|
||||
foreach ($params as $paramKey => $paramValue) {
|
||||
if ($paramValue === '' || $paramValue === null) {
|
||||
unset($params[$paramKey]);
|
||||
}
|
||||
}
|
||||
ksort($params);
|
||||
|
||||
$key = (string) $credential->api_key;
|
||||
$query = [];
|
||||
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('签名验证失败');
|
||||
}
|
||||
|
||||
$credential->last_used_at = $this->now();
|
||||
$credential->save();
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为商户生成并保存一份新的接口凭证。
|
||||
*
|
||||
* 返回值是明文接口凭证值,只会在调用时完整出现一次,后续仅保存脱敏展示。
|
||||
*/
|
||||
public function issueCredential(int $merchantId): string
|
||||
{
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$credentialValue = $this->generateCredentialValue();
|
||||
$this->merchantApiCredentialRepository->updateOrCreate(
|
||||
['merchant_id' => $merchantId],
|
||||
[
|
||||
'merchant_id' => $merchantId,
|
||||
'sign_type' => AuthConstant::API_SIGN_TYPE_MD5,
|
||||
'api_key' => $credentialValue,
|
||||
'status' => AuthConstant::LOGIN_STATUS_ENABLED,
|
||||
]
|
||||
);
|
||||
|
||||
return $credentialValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户接口凭证详情。
|
||||
*/
|
||||
public function findById(int $id): ?MerchantApiCredential
|
||||
{
|
||||
return $this->merchantApiCredentialQueryService->findById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询商户对应的接口凭证详情。
|
||||
*/
|
||||
public function findByMerchantId(int $merchantId): ?MerchantApiCredential
|
||||
{
|
||||
return $this->merchantApiCredentialQueryService->findByMerchantId($merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增或更新商户接口凭证。
|
||||
*/
|
||||
public function create(array $data): MerchantApiCredential
|
||||
{
|
||||
$merchantId = (int) ($data['merchant_id'] ?? 0);
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
$current = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if ($current) {
|
||||
$updated = $this->update((int) $current->id, $data);
|
||||
if ($updated) {
|
||||
return $updated;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->merchantApiCredentialRepository->create($this->normalizePayload($data, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改商户接口凭证。
|
||||
*/
|
||||
public function update(int $id, array $data): ?MerchantApiCredential
|
||||
{
|
||||
$current = $this->merchantApiCredentialRepository->find($id);
|
||||
if (!$current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $this->normalizePayload($data, true, $current);
|
||||
if (!$this->merchantApiCredentialRepository->updateById($id, $payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除商户接口凭证。
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->merchantApiCredentialRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用商户 ID 和接口凭证直接进行身份校验。
|
||||
*
|
||||
* 该方法用于兼容 epay 风格的查询接口,不涉及签名串验签。
|
||||
*
|
||||
* @return array{merchant:\app\model\merchant\Merchant,credential:\app\model\merchant\MerchantApiCredential}
|
||||
*/
|
||||
public function authenticateByKey(int $merchantId, string $key): array
|
||||
{
|
||||
if ($merchantId <= 0 || $key === '') {
|
||||
throw new ValidationException('pid/key 参数缺失');
|
||||
}
|
||||
|
||||
/** @var Merchant|null $merchant */
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant || (int) $merchant->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ResourceNotFoundException('商户不存在', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
|
||||
/** @var MerchantApiCredential|null $credential */
|
||||
$credential = $this->merchantApiCredentialRepository->findByMerchantId($merchantId);
|
||||
if (!$credential || (int) $credential->status !== AuthConstant::LOGIN_STATUS_ENABLED) {
|
||||
throw new ValidationException('商户接口凭证未开通');
|
||||
}
|
||||
|
||||
if (!hash_equals((string) $credential->api_key, $key)) {
|
||||
throw new ValidationException('商户接口凭证错误');
|
||||
}
|
||||
|
||||
$credential->last_used_at = $this->now();
|
||||
$credential->save();
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'credential' => $credential,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 整理写入字段。
|
||||
*/
|
||||
private function normalizePayload(array $data, bool $isUpdate, ?MerchantApiCredential $current = null): array
|
||||
{
|
||||
$merchantId = (int) ($current?->merchant_id ?? ($data['merchant_id'] ?? 0));
|
||||
$payload = [
|
||||
'merchant_id' => $merchantId,
|
||||
'sign_type' => (int) ($data['sign_type'] ?? AuthConstant::API_SIGN_TYPE_MD5),
|
||||
'status' => (int) ($data['status'] ?? AuthConstant::LOGIN_STATUS_ENABLED),
|
||||
];
|
||||
|
||||
$apiKey = trim((string) ($data['api_key'] ?? ''));
|
||||
if ($apiKey !== '') {
|
||||
$payload['api_key'] = $apiKey;
|
||||
} elseif (!$isUpdate) {
|
||||
$payload['api_key'] = $this->generateCredentialValue();
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成新的接口凭证值。
|
||||
*/
|
||||
private function generateCredentialValue(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
154
app/service/ops/log/ChannelNotifyLogService.php
Normal file
154
app/service/ops/log/ChannelNotifyLogService.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\ops\log;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\model\admin\ChannelNotifyLog;
|
||||
use app\repository\ops\log\ChannelNotifyLogRepository;
|
||||
|
||||
/**
|
||||
* 渠道通知日志查询服务。
|
||||
*/
|
||||
class ChannelNotifyLogService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入渠道通知日志仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelNotifyLogRepository $channelNotifyLogRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询渠道通知日志。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('n.notify_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.biz_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.pay_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.channel_request_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.channel_trade_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('n.last_error', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('p.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$channelId = (string) ($filters['channel_id'] ?? '');
|
||||
if ($channelId !== '') {
|
||||
$query->where('n.channel_id', (int) $channelId);
|
||||
}
|
||||
|
||||
$notifyType = (string) ($filters['notify_type'] ?? '');
|
||||
if ($notifyType !== '') {
|
||||
$query->where('n.notify_type', (int) $notifyType);
|
||||
}
|
||||
|
||||
$verifyStatus = (string) ($filters['verify_status'] ?? '');
|
||||
if ($verifyStatus !== '') {
|
||||
$query->where('n.verify_status', (int) $verifyStatus);
|
||||
}
|
||||
|
||||
$processStatus = (string) ($filters['process_status'] ?? '');
|
||||
if ($processStatus !== '') {
|
||||
$query->where('n.process_status', (int) $processStatus);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('n.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询详情。
|
||||
*/
|
||||
public function findById(int $id): ?ChannelNotifyLog
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('n.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条记录。
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
$row->notify_type_text = (string) (NotifyConstant::notifyTypeMap()[(int) $row->notify_type] ?? '未知');
|
||||
$row->verify_status_text = (string) (NotifyConstant::verifyStatusMap()[(int) $row->verify_status] ?? '未知');
|
||||
$row->process_status_text = (string) (NotifyConstant::processStatusMap()[(int) $row->process_status] ?? '未知');
|
||||
$row->next_retry_at_text = $this->formatDateTime($row->next_retry_at ?? null);
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->updated_at_text = $this->formatDateTime($row->updated_at ?? null);
|
||||
$row->raw_payload_text = $this->formatJson($row->raw_payload ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询。
|
||||
*/
|
||||
private function baseQuery()
|
||||
{
|
||||
return $this->channelNotifyLogRepository->query()
|
||||
->from('ma_channel_notify_log as n')
|
||||
->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'n.pay_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'n.channel_id')
|
||||
->select([
|
||||
'n.id',
|
||||
'n.notify_no',
|
||||
'n.channel_id',
|
||||
'n.notify_type',
|
||||
'n.biz_no',
|
||||
'n.pay_no',
|
||||
'n.channel_request_no',
|
||||
'n.channel_trade_no',
|
||||
'n.raw_payload',
|
||||
'n.verify_status',
|
||||
'n.process_status',
|
||||
'n.retry_count',
|
||||
'n.next_retry_at',
|
||||
'n.last_error',
|
||||
'n.created_at',
|
||||
'n.updated_at',
|
||||
'p.merchant_id',
|
||||
'p.merchant_order_no',
|
||||
'p.subject',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name")
|
||||
->selectRaw("COALESCE(c.name, '') AS channel_name")
|
||||
->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code");
|
||||
}
|
||||
|
||||
}
|
||||
141
app/service/ops/log/PayCallbackLogService.php
Normal file
141
app/service/ops/log/PayCallbackLogService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\ops\log;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\model\admin\PayCallbackLog;
|
||||
use app\repository\ops\log\PayCallbackLogRepository;
|
||||
|
||||
/**
|
||||
* 支付回调日志查询服务。
|
||||
*/
|
||||
class PayCallbackLogService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入支付回调日志仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayCallbackLogRepository $payCallbackLogRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付回调日志。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('l.pay_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('p.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$channelId = (string) ($filters['channel_id'] ?? '');
|
||||
if ($channelId !== '') {
|
||||
$query->where('l.channel_id', (int) $channelId);
|
||||
}
|
||||
|
||||
$callbackType = (string) ($filters['callback_type'] ?? '');
|
||||
if ($callbackType !== '') {
|
||||
$query->where('l.callback_type', (int) $callbackType);
|
||||
}
|
||||
|
||||
$verifyStatus = (string) ($filters['verify_status'] ?? '');
|
||||
if ($verifyStatus !== '') {
|
||||
$query->where('l.verify_status', (int) $verifyStatus);
|
||||
}
|
||||
|
||||
$processStatus = (string) ($filters['process_status'] ?? '');
|
||||
if ($processStatus !== '') {
|
||||
$query->where('l.process_status', (int) $processStatus);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('l.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询详情。
|
||||
*/
|
||||
public function findById(int $id): ?PayCallbackLog
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('l.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条记录。
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
$row->callback_type_text = (string) (NotifyConstant::callbackTypeMap()[(int) $row->callback_type] ?? '未知');
|
||||
$row->verify_status_text = (string) (NotifyConstant::verifyStatusMap()[(int) $row->verify_status] ?? '未知');
|
||||
$row->process_status_text = (string) (NotifyConstant::processStatusMap()[(int) $row->process_status] ?? '未知');
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->request_data_text = $this->formatJson($row->request_data ?? null);
|
||||
$row->process_result_text = $this->formatJson($row->process_result ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询。
|
||||
*/
|
||||
private function baseQuery()
|
||||
{
|
||||
return $this->payCallbackLogRepository->query()
|
||||
->from('ma_pay_callback_log as l')
|
||||
->leftJoin('ma_pay_order as p', 'p.pay_no', '=', 'l.pay_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'p.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'm.group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'l.channel_id')
|
||||
->select([
|
||||
'l.id',
|
||||
'l.pay_no',
|
||||
'l.channel_id',
|
||||
'l.callback_type',
|
||||
'l.request_data',
|
||||
'l.verify_status',
|
||||
'l.process_status',
|
||||
'l.process_result',
|
||||
'l.created_at',
|
||||
'p.merchant_id',
|
||||
'p.merchant_order_no',
|
||||
'p.subject',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name")
|
||||
->selectRaw("COALESCE(c.name, '') AS channel_name")
|
||||
->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code");
|
||||
}
|
||||
|
||||
}
|
||||
132
app/service/ops/stat/ChannelDailyStatService.php
Normal file
132
app/service/ops/stat/ChannelDailyStatService.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\ops\stat;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\admin\ChannelDailyStat;
|
||||
use app\repository\ops\stat\ChannelDailyStatRepository;
|
||||
|
||||
/**
|
||||
* 通道日统计查询服务。
|
||||
*/
|
||||
class ChannelDailyStatService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入通道日统计仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelDailyStatRepository $channelDailyStatRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询通道日统计。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->baseQuery();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('s.stat_date', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('s.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$channelId = (string) ($filters['channel_id'] ?? '');
|
||||
if ($channelId !== '') {
|
||||
$query->where('s.channel_id', (int) $channelId);
|
||||
}
|
||||
|
||||
$statDate = trim((string) ($filters['stat_date'] ?? ''));
|
||||
if ($statDate !== '') {
|
||||
$query->where('s.stat_date', $statDate);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('s.stat_date')
|
||||
->orderByDesc('s.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询详情。
|
||||
*/
|
||||
public function findById(int $id): ?ChannelDailyStat
|
||||
{
|
||||
$row = $this->baseQuery()
|
||||
->where('s.id', $id)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条统计记录。
|
||||
*/
|
||||
private function decorateRow(object $row): object
|
||||
{
|
||||
$row->pay_amount_text = $this->formatAmount((int) $row->pay_amount);
|
||||
$row->refund_amount_text = $this->formatAmount((int) $row->refund_amount);
|
||||
$row->success_rate_text = $this->formatRate((int) $row->success_rate_bp);
|
||||
$row->avg_latency_ms_text = $this->formatLatency((int) $row->avg_latency_ms);
|
||||
$row->stat_date_text = $this->formatDate($row->stat_date ?? null);
|
||||
$row->created_at_text = $this->formatDateTime($row->created_at ?? null);
|
||||
$row->updated_at_text = $this->formatDateTime($row->updated_at ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基础查询。
|
||||
*/
|
||||
private function baseQuery()
|
||||
{
|
||||
return $this->channelDailyStatRepository->query()
|
||||
->from('ma_channel_daily_stat as s')
|
||||
->leftJoin('ma_merchant as m', 's.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_merchant_group as g', 's.merchant_group_id', '=', 'g.id')
|
||||
->leftJoin('ma_payment_channel as c', 's.channel_id', '=', 'c.id')
|
||||
->select([
|
||||
's.id',
|
||||
's.merchant_id',
|
||||
's.merchant_group_id',
|
||||
's.channel_id',
|
||||
's.stat_date',
|
||||
's.pay_success_count',
|
||||
's.pay_fail_count',
|
||||
's.pay_amount',
|
||||
's.refund_count',
|
||||
's.refund_amount',
|
||||
's.avg_latency_ms',
|
||||
's.success_rate_bp',
|
||||
's.health_score',
|
||||
's.created_at',
|
||||
's.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(m.merchant_short_name, '') AS merchant_short_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name")
|
||||
->selectRaw("COALESCE(c.name, '') AS channel_name")
|
||||
->selectRaw("COALESCE(c.plugin_code, '') AS channel_plugin_code");
|
||||
}
|
||||
|
||||
}
|
||||
571
app/service/payment/compat/EpayCompatService.php
Normal file
571
app/service/payment/compat/EpayCompatService.php
Normal file
@@ -0,0 +1,571 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\compat;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\account\balance\MerchantAccountRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\merchant\security\MerchantApiCredentialService;
|
||||
use app\service\payment\config\PaymentTypeService;
|
||||
use app\service\payment\order\PayOrderService;
|
||||
use app\service\payment\order\RefundService;
|
||||
use app\service\payment\runtime\PaymentPluginManager;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
class EpayCompatService extends BaseService
|
||||
{
|
||||
private const API_ACTIONS = ['query', 'settle', 'order', 'orders', 'refund'];
|
||||
|
||||
public function __construct(
|
||||
protected MerchantApiCredentialService $merchantApiCredentialService,
|
||||
protected PaymentTypeService $paymentTypeService,
|
||||
protected PayOrderService $payOrderService,
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected MerchantAccountRepository $merchantAccountRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected RefundService $refundService
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(array $payload, Request $request): Response
|
||||
{
|
||||
try {
|
||||
$attempt = $this->prepareSubmitAttempt($payload, $request);
|
||||
$targetUrl = (string) ($attempt['cashier_url'] ?? '');
|
||||
|
||||
if ($targetUrl === '') {
|
||||
throw new ValidationException('收银台跳转地址生成失败');
|
||||
}
|
||||
|
||||
return redirect($targetUrl);
|
||||
} catch (Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $this->normalizeErrorMessage($e, '提交失败'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function mapi(array $payload, Request $request): array
|
||||
{
|
||||
try {
|
||||
$attempt = $this->prepareSubmitAttempt($payload, $request);
|
||||
return $this->buildMapiResponse($attempt);
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '提交失败')];
|
||||
}
|
||||
}
|
||||
|
||||
public function api(array $payload): array
|
||||
{
|
||||
$act = strtolower(trim((string) ($payload['act'] ?? '')));
|
||||
if (!in_array($act, self::API_ACTIONS, true)) {
|
||||
return ['code' => 0, 'msg' => '不支持的操作类型'];
|
||||
}
|
||||
|
||||
return match ($act) {
|
||||
'query' => $this->queryMerchantInfo($payload),
|
||||
'settle' => $this->querySettlementList($payload),
|
||||
'order' => $this->queryOrder($payload),
|
||||
'orders' => $this->queryOrders($payload),
|
||||
'refund' => $this->createRefund($payload),
|
||||
};
|
||||
}
|
||||
|
||||
public function queryMerchantInfo(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$auth = $this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$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();
|
||||
$todayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $todayDate)->count();
|
||||
$lastDayOrders = (int) $this->payOrderRepository->query()->where('merchant_id', $merchantId)->whereDate('created_at', $lastDayDate)->count();
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'pid' => (int) $merchant->id,
|
||||
'key' => (string) $credential->api_key,
|
||||
'active' => (int) $merchant->status,
|
||||
'money' => FormatHelper::amount((int) ($account->available_balance ?? 0)),
|
||||
'type' => $this->resolveMerchantSettlementType($merchant),
|
||||
'account' => (string) $merchant->settlement_account_no,
|
||||
'username' => (string) $merchant->settlement_account_name,
|
||||
'orders' => $totalOrders,
|
||||
'order_today' => $todayOrders,
|
||||
'order_lastday' => $lastDayOrders,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')];
|
||||
}
|
||||
}
|
||||
|
||||
public function querySettlementList(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$rows = $this->settlementOrderRepository->query()->where('merchant_id', $merchantId)->orderByDesc('id')->get();
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
'data' => $rows->map(function ($row): array {
|
||||
return [
|
||||
'settle_no' => (string) $row->settle_no,
|
||||
'cycle_type' => (int) $row->cycle_type,
|
||||
'cycle_key' => (string) $row->cycle_key,
|
||||
'status' => (int) $row->status,
|
||||
'gross_amount' => FormatHelper::amount((int) $row->gross_amount),
|
||||
'net_amount' => FormatHelper::amount((int) $row->net_amount),
|
||||
'accounted_amount' => FormatHelper::amount((int) $row->accounted_amount),
|
||||
'created_at' => FormatHelper::dateTime($row->created_at ?? null),
|
||||
'completed_at' => FormatHelper::dateTime($row->completed_at ?? null),
|
||||
];
|
||||
})->all(),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')];
|
||||
}
|
||||
}
|
||||
|
||||
public function queryOrder(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' => 0, 'msg' => '订单不存在'];
|
||||
}
|
||||
|
||||
return ['code' => 1, 'msg' => '查询订单号成功!'] + $this->formatEpayOrderRow($context['pay_order'], $context['biz_order']);
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')];
|
||||
}
|
||||
}
|
||||
|
||||
public function queryOrders(array $payload): array
|
||||
{
|
||||
try {
|
||||
$merchantId = (int) ($payload['pid'] ?? 0);
|
||||
$key = trim((string) ($payload['key'] ?? ''));
|
||||
$this->merchantApiCredentialService->authenticateByKey($merchantId, $key);
|
||||
$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);
|
||||
|
||||
return [
|
||||
'code' => 1,
|
||||
'msg' => '查询结算记录成功!',
|
||||
'data' => array_map(function ($row): array {
|
||||
return $this->formatEpayOrderRow($row, $this->bizOrderRepository->findByBizNo((string) $row->biz_no));
|
||||
}, $paginator->items()),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 0, 'msg' => $this->normalizeErrorMessage($e, '查询失败')];
|
||||
}
|
||||
}
|
||||
|
||||
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' => '订单不存在'];
|
||||
}
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $context['pay_order'];
|
||||
$refundAmount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
if ($refundAmount <= 0) {
|
||||
return ['code' => 1, 'msg' => '退款金额不合法'];
|
||||
}
|
||||
|
||||
$refundOrder = $this->refundService->createRefund([
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'merchant_refund_no' => trim((string) ($payload['refund_no'] ?? $payload['merchant_refund_no'] ?? '')),
|
||||
'refund_amount' => $refundAmount,
|
||||
'reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
'ext_json' => ['source' => 'epay'],
|
||||
]);
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
$pluginResult = $plugin->refund([
|
||||
'order_id' => (string) $payOrder->pay_no,
|
||||
'pay_no' => (string) $payOrder->pay_no,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'chan_order_no' => (string) $payOrder->channel_order_no,
|
||||
'chan_trade_no' => (string) $payOrder->channel_trade_no,
|
||||
'out_trade_no' => (string) $payOrder->channel_order_no,
|
||||
'refund_no' => (string) $refundOrder->refund_no,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_reason' => trim((string) ($payload['reason'] ?? '')),
|
||||
'extra' => (array) ($payOrder->ext_json ?? []),
|
||||
]);
|
||||
|
||||
if (!$this->isPluginSuccess($pluginResult)) {
|
||||
$this->refundService->markRefundFailed((string) $refundOrder->refund_no, [
|
||||
'failed_at' => $this->now(),
|
||||
'last_error' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败'),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
'ext_json' => ['source' => 'epay'],
|
||||
]);
|
||||
|
||||
return ['code' => 1, 'msg' => (string) ($pluginResult['msg'] ?? $pluginResult['message'] ?? '退款失败')];
|
||||
}
|
||||
|
||||
$this->refundService->markRefundSuccess((string) $refundOrder->refund_no, [
|
||||
'succeeded_at' => $this->now(),
|
||||
'channel_refund_no' => $this->resolveRefundChannelNo($pluginResult),
|
||||
'ext_json' => ['source' => 'epay'],
|
||||
]);
|
||||
|
||||
return ['code' => 0, 'msg' => '退款成功'];
|
||||
} catch (Throwable $e) {
|
||||
return ['code' => 1, 'msg' => $this->normalizeErrorMessage($e, '退款失败')];
|
||||
}
|
||||
}
|
||||
|
||||
private function prepareSubmitAttempt(array $payload, Request $request): array
|
||||
{
|
||||
$normalized = $this->normalizeSubmitPayload($payload, $request);
|
||||
$result = $this->payOrderService->preparePayAttempt($normalized);
|
||||
$payOrder = $result['pay_order'];
|
||||
$payParams = (array) ($result['pay_params'] ?? []);
|
||||
|
||||
return [
|
||||
'normalized_payload' => $normalized,
|
||||
'result' => $result,
|
||||
'pay_order' => $payOrder,
|
||||
'pay_params' => $payParams,
|
||||
'cashier_url' => $this->buildCashierUrl((string) $payOrder->pay_no),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeSubmitPayload(array $payload, Request $request): array
|
||||
{
|
||||
$this->merchantApiCredentialService->verifyMd5Sign($payload);
|
||||
$typeCode = trim((string) ($payload['type'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$subject = trim((string) ($payload['name'] ?? ''));
|
||||
$amount = $this->parseMoneyToAmount((string) ($payload['money'] ?? '0'));
|
||||
$paymentType = $this->resolveSubmitPaymentType($typeCode);
|
||||
|
||||
if ($merchantOrderNo === '') {
|
||||
throw new ValidationException('out_trade_no 参数不能为空');
|
||||
}
|
||||
if ($subject === '') {
|
||||
throw new ValidationException('name 参数不能为空');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new ValidationException('money 参数不合法');
|
||||
}
|
||||
|
||||
$extJson = [
|
||||
'epay_type' => $typeCode,
|
||||
'resolved_type' => (string) $paymentType->code,
|
||||
'notify_url' => trim((string) ($payload['notify_url'] ?? '')),
|
||||
'return_url' => trim((string) ($payload['return_url'] ?? '')),
|
||||
'param' => $this->normalizePayloadValue($payload['param'] ?? null),
|
||||
'clientip' => $this->resolveClientIp($payload, $request),
|
||||
'device' => $this->normalizeDeviceCode((string) ($payload['device'] ?? 'pc')),
|
||||
'sign_type' => strtoupper((string) ($payload['sign_type'] ?? 'MD5')),
|
||||
'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',
|
||||
];
|
||||
|
||||
return [
|
||||
'merchant_id' => (int) ($payload['pid'] ?? 0),
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_type_id' => (int) $paymentType->id,
|
||||
'pay_amount' => $amount,
|
||||
'subject' => $subject,
|
||||
'body' => $subject,
|
||||
'ext_json' => $extJson,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveSubmitPaymentType(string $typeCode): PaymentType
|
||||
{
|
||||
$typeCode = trim($typeCode);
|
||||
if ($typeCode === '') {
|
||||
return $this->paymentTypeService->resolveEnabledType('');
|
||||
}
|
||||
|
||||
$paymentType = $this->paymentTypeService->findByCode($typeCode);
|
||||
if (!$paymentType || (int) $paymentType->status !== 1) {
|
||||
throw new ValidationException('支付方式不支持');
|
||||
}
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
private function buildMapiResponse(array $attempt): array
|
||||
{
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $attempt['pay_order'];
|
||||
$payParams = (array) ($attempt['pay_params'] ?? []);
|
||||
$cashierUrl = (string) ($attempt['cashier_url'] ?? $this->buildCashierUrl((string) $payOrder->pay_no));
|
||||
$payNo = (string) $payOrder->pay_no;
|
||||
$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 !== '') {
|
||||
$response['qrcode'] = $qrcode;
|
||||
$response['payurl'] = $cashierUrl;
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'urlscheme') {
|
||||
$urlscheme = $this->stringifyValue($payParams['urlscheme'] ?? $payParams['order_str'] ?? '');
|
||||
if ($urlscheme !== '') {
|
||||
$response['urlscheme'] = $urlscheme;
|
||||
$response['payurl'] = $cashierUrl;
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'url') {
|
||||
$payUrl = $this->stringifyValue($payParams['payurl'] ?? '');
|
||||
if ($payUrl !== '') {
|
||||
$response['payurl'] = $cashierUrl;
|
||||
$response['origin_payurl'] = $payUrl;
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'form' && $this->stringifyValue($payParams['html'] ?? '') !== '') {
|
||||
$response['payurl'] = $cashierUrl;
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($type === 'jsapi') {
|
||||
$urlscheme = $this->stringifyValue($payParams['urlscheme'] ?? $payParams['order_str'] ?? '');
|
||||
if ($urlscheme !== '') {
|
||||
$response['urlscheme'] = $urlscheme;
|
||||
$response['payurl'] = $cashierUrl;
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = $cashierUrl;
|
||||
if ($fallback !== '') {
|
||||
$response['payurl'] = $fallback;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function formatEpayOrderRow(PayOrder $payOrder, ?BizOrder $bizOrder = null): array
|
||||
{
|
||||
$bizOrder ??= $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$extJson = (array) (($bizOrder?->ext_json) ?? []);
|
||||
|
||||
return [
|
||||
'trade_no' => (string) $payOrder->pay_no,
|
||||
'out_trade_no' => (string) ($bizOrder?->merchant_order_no ?? $extJson['merchant_order_no'] ?? ''),
|
||||
'api_trade_no' => (string) ($payOrder->channel_trade_no ?: $payOrder->channel_order_no ?: ''),
|
||||
'type' => $this->resolvePaymentTypeCode((int) $payOrder->pay_type_id),
|
||||
'pid' => (int) $payOrder->merchant_id,
|
||||
'addtime' => FormatHelper::dateTime($payOrder->created_at),
|
||||
'endtime' => FormatHelper::dateTime($payOrder->paid_at),
|
||||
'name' => (string) ($bizOrder?->subject ?? $extJson['subject'] ?? ''),
|
||||
'money' => FormatHelper::amount((int) $payOrder->pay_amount),
|
||||
'status' => (int) $payOrder->status === TradeConstant::ORDER_STATUS_SUCCESS ? 1 : 0,
|
||||
'param' => $this->stringifyValue($extJson['param'] ?? ''),
|
||||
'buyer' => $this->stringifyValue($extJson['buyer'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePayOrderContext(int $merchantId, array $payload): ?array
|
||||
{
|
||||
$payNo = trim((string) ($payload['trade_no'] ?? ''));
|
||||
$merchantOrderNo = trim((string) ($payload['out_trade_no'] ?? ''));
|
||||
$payOrder = null;
|
||||
$bizOrder = null;
|
||||
|
||||
if ($payNo !== '') {
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if ($payOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$payOrder && $merchantOrderNo !== '') {
|
||||
$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;
|
||||
}
|
||||
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
}
|
||||
|
||||
return ['pay_order' => $payOrder, 'biz_order' => $bizOrder];
|
||||
}
|
||||
|
||||
private function resolvePaymentTypeCode(int $payTypeId): string
|
||||
{
|
||||
return $this->paymentTypeService->resolveCodeById($payTypeId);
|
||||
}
|
||||
|
||||
private function resolveMerchantSettlementType(mixed $merchant): int
|
||||
{
|
||||
$bankName = strtolower(trim((string) ($merchant->settlement_bank_name ?? '')));
|
||||
$accountName = strtolower(trim((string) ($merchant->settlement_account_name ?? '')));
|
||||
$accountNo = strtolower(trim((string) ($merchant->settlement_account_no ?? '')));
|
||||
|
||||
if (str_contains($accountName, '支付宝') || str_contains($bankName, 'alipay') || str_contains($accountNo, 'alipay')) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (str_contains($accountName, '微信') || str_contains($bankName, 'wechat') || str_contains($accountNo, 'wechat')) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (str_contains($accountName, 'qq') || str_contains($bankName, 'qq') || str_contains($accountNo, 'qq')) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if ($bankName !== '' || $accountNo !== '') {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
private function parseMoneyToAmount(string $money): int
|
||||
{
|
||||
$money = trim($money);
|
||||
if ($money === '' || !preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round(((float) $money) * 100);
|
||||
}
|
||||
|
||||
private function resolveClientIp(array $payload, Request $request): string
|
||||
{
|
||||
$clientIp = trim((string) ($payload['clientip'] ?? ''));
|
||||
if ($clientIp !== '') {
|
||||
return $clientIp;
|
||||
}
|
||||
|
||||
return trim((string) $request->getRealIp());
|
||||
}
|
||||
|
||||
private function normalizeDeviceCode(string $device): string
|
||||
{
|
||||
$device = strtolower(trim($device));
|
||||
return $device !== '' ? $device : 'pc';
|
||||
}
|
||||
|
||||
private function normalizePayloadValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_object($value) && method_exists($value, 'toArray')) {
|
||||
$data = $value->toArray();
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
return is_scalar($value) ? (string) $value : null;
|
||||
}
|
||||
|
||||
private function normalizeRequestSnapshot(array $payload): array
|
||||
{
|
||||
$snapshot = $payload;
|
||||
unset($snapshot['sign'], $snapshot['key']);
|
||||
unset($snapshot['submit_mode']);
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function buildCashierUrl(string $payNo): string
|
||||
{
|
||||
return (string) sys_config('site_url') . '/pay/' . rawurlencode($payNo) . '/payment';
|
||||
}
|
||||
|
||||
private function normalizeErrorMessage(Throwable $e, string $fallback): string
|
||||
{
|
||||
$message = trim((string) $e->getMessage());
|
||||
return $message !== '' ? $message : $fallback;
|
||||
}
|
||||
|
||||
private function isPluginSuccess(array $pluginResult): bool
|
||||
{
|
||||
return !array_key_exists('success', $pluginResult) || (bool) $pluginResult['success'];
|
||||
}
|
||||
|
||||
private function resolveRefundChannelNo(array $pluginResult, string $default = ''): string
|
||||
{
|
||||
foreach (['chan_refund_no', 'refund_no', 'trade_no', 'out_request_no'] as $key) {
|
||||
if (array_key_exists($key, $pluginResult)) {
|
||||
$value = $this->stringifyValue($pluginResult[$key]);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function stringifyValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return trim($value);
|
||||
}
|
||||
if (is_int($value) || is_float($value) || is_bool($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $json !== false ? $json : '';
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
}
|
||||
119
app/service/payment/config/PaymentChannelCommandService.php
Normal file
119
app/service/payment/config/PaymentChannelCommandService.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\repository\merchant\base\MerchantRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 支付通道命令服务。
|
||||
*/
|
||||
class PaymentChannelCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentChannel
|
||||
{
|
||||
$this->assertChannelNameUnique((string) ($data['name'] ?? ''));
|
||||
$this->assertMerchantExists($data);
|
||||
$this->assertPluginSupportsPayType($data);
|
||||
|
||||
return $this->paymentChannelRepository->create($data);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentChannel
|
||||
{
|
||||
$this->assertChannelNameUnique((string) ($data['name'] ?? ''), $id);
|
||||
$this->assertMerchantExists($data);
|
||||
$this->assertPluginSupportsPayType($data);
|
||||
|
||||
if (!$this->paymentChannelRepository->updateById($id, $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentChannelRepository->deleteById($id);
|
||||
}
|
||||
|
||||
private function assertMerchantExists(array $data): void
|
||||
{
|
||||
if (!array_key_exists('merchant_id', $data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$merchantId = (int) $data['merchant_id'];
|
||||
if ($merchantId === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->merchantRepository->find($merchantId)) {
|
||||
throw new PaymentException('所属商户不存在', 40209, [
|
||||
'merchant_id' => $merchantId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertPluginSupportsPayType(array $data): void
|
||||
{
|
||||
$pluginCode = trim((string) ($data['plugin_code'] ?? ''));
|
||||
$payTypeId = (int) ($data['pay_type_id'] ?? 0);
|
||||
|
||||
if ($pluginCode === '' || $payTypeId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plugin = $this->paymentPluginRepository->findByCode($pluginCode);
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
|
||||
if (!$plugin || !$paymentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
if ($payTypeCode === '' || !in_array($payTypeCode, $payTypeCodes, true)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40210, [
|
||||
'plugin_code' => $pluginCode,
|
||||
'pay_type_code' => $payTypeCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertChannelNameUnique(string $name, int $ignoreId = 0): void
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->paymentChannelRepository->existsByName($name, $ignoreId)) {
|
||||
throw new PaymentException('通道名称已存在', 40215, [
|
||||
'name' => $name,
|
||||
'ignore_id' => $ignoreId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
228
app/service/payment/config/PaymentChannelQueryService.php
Normal file
228
app/service/payment/config/PaymentChannelQueryService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
|
||||
/**
|
||||
* 支付通道查询服务。
|
||||
*/
|
||||
class PaymentChannelQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $paymentChannelRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->paymentChannelRepository->query()
|
||||
->from('ma_payment_channel as c')
|
||||
->where('c.status', CommonConstant::STATUS_ENABLED)
|
||||
->orderBy('c.sort_no')
|
||||
->orderByDesc('c.id')
|
||||
->get([
|
||||
'c.id',
|
||||
'c.name',
|
||||
])
|
||||
->map(function (PaymentChannel $channel): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id),
|
||||
'value' => (int) $channel->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
->from('ma_payment_channel as c')
|
||||
->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.name',
|
||||
'c.merchant_id',
|
||||
'c.channel_mode',
|
||||
'c.pay_type_id',
|
||||
'c.plugin_code',
|
||||
'c.status',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(t.name, '') AS pay_type_name");
|
||||
|
||||
$ids = $this->normalizeIds($filters['ids'] ?? []);
|
||||
if (!empty($ids)) {
|
||||
$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 . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('c.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '' && $filters['merchant_id'] !== null) {
|
||||
$query->where('c.merchant_id', (int) $filters['merchant_id']);
|
||||
}
|
||||
|
||||
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '' && $filters['channel_mode'] !== null) {
|
||||
$query->where('c.channel_mode', (int) $filters['channel_mode']);
|
||||
}
|
||||
|
||||
$excludeIds = $this->normalizeIds($filters['exclude_ids'] ?? []);
|
||||
if (!empty($excludeIds)) {
|
||||
$query->whereNotIn('c.id', $excludeIds);
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderBy('c.sort_no')
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
return [
|
||||
'list' => collect($paginator->items())
|
||||
->map(function ($channel): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id),
|
||||
'value' => (int) $channel->id,
|
||||
'merchant_id' => (int) $channel->merchant_id,
|
||||
'merchant_no' => (string) ($channel->merchant_no ?? ''),
|
||||
'merchant_name' => (string) ($channel->merchant_name ?? ''),
|
||||
'channel_mode' => (int) $channel->channel_mode,
|
||||
'pay_type_id' => (int) $channel->pay_type_id,
|
||||
'pay_type_name' => (string) ($channel->pay_type_name ?? ''),
|
||||
'plugin_code' => (string) $channel->plugin_code,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all(),
|
||||
'total' => (int) $paginator->total(),
|
||||
'page' => (int) $paginator->currentPage(),
|
||||
'size' => (int) $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
public function routeOptions(array $filters = []): array
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
->from('ma_payment_channel as c')
|
||||
->leftJoin('ma_payment_type as t', 'c.pay_type_id', '=', 't.id')
|
||||
->where('c.status', CommonConstant::STATUS_ENABLED)
|
||||
->select([
|
||||
'c.id',
|
||||
'c.name',
|
||||
'c.merchant_id',
|
||||
'c.channel_mode',
|
||||
'c.pay_type_id',
|
||||
'c.plugin_code',
|
||||
])
|
||||
->selectRaw("COALESCE(t.name, '') AS pay_type_name");
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('c.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '') {
|
||||
$query->where('c.merchant_id', (int) $filters['merchant_id']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('c.sort_no')
|
||||
->orderByDesc('c.id')
|
||||
->get()
|
||||
->map(function (PaymentChannel $channel): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', (string) $channel->name, (int) $channel->id),
|
||||
'value' => (int) $channel->id,
|
||||
'merchant_id' => (int) $channel->merchant_id,
|
||||
'channel_mode' => (int) $channel->channel_mode,
|
||||
'pay_type_id' => (int) $channel->pay_type_id,
|
||||
'plugin_code' => (string) $channel->plugin_code,
|
||||
'pay_type_name' => (string) ($channel->pay_type_name ?? ''),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentChannelRepository->query()
|
||||
->from('ma_payment_channel as c')
|
||||
->leftJoin('ma_merchant as m', 'c.merchant_id', '=', 'm.id')
|
||||
->select([
|
||||
'c.*',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name");
|
||||
|
||||
$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 . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (array_key_exists('merchant_id', $filters) && $filters['merchant_id'] !== '') {
|
||||
$query->where('c.merchant_id', (int) $filters['merchant_id']);
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('c.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
$pluginCode = trim((string) ($filters['plugin_code'] ?? ''));
|
||||
if ($pluginCode !== '') {
|
||||
$query->where('c.plugin_code', 'like', '%' . $pluginCode . '%');
|
||||
}
|
||||
|
||||
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') {
|
||||
$query->where('c.channel_mode', (int) $filters['channel_mode']);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('c.status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('c.sort_no')
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->paymentChannelRepository->find($id);
|
||||
}
|
||||
|
||||
private function normalizeIds(array|string|int $ids): array
|
||||
{
|
||||
if (is_string($ids)) {
|
||||
$ids = array_filter(array_map('trim', explode(',', $ids)));
|
||||
} elseif (!is_array($ids)) {
|
||||
$ids = [$ids];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(static fn ($id) => (int) $id, $ids), static fn ($id) => $id > 0));
|
||||
}
|
||||
}
|
||||
58
app/service/payment/config/PaymentChannelService.php
Normal file
58
app/service/payment/config/PaymentChannelService.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付通道门面服务。
|
||||
*/
|
||||
class PaymentChannelService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentChannelQueryService $queryService,
|
||||
protected PaymentChannelCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->queryService->enabledOptions();
|
||||
}
|
||||
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
return $this->queryService->searchOptions($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function routeOptions(array $filters = []): array
|
||||
{
|
||||
return $this->queryService->routeOptions($filters);
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentChannel
|
||||
{
|
||||
return $this->queryService->findById($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentChannel
|
||||
{
|
||||
return $this->commandService->create($data);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentChannel
|
||||
{
|
||||
return $this->commandService->update($id, $data);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
}
|
||||
228
app/service/payment/config/PaymentPluginConfService.php
Normal file
228
app/service/payment/config/PaymentPluginConfService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPluginConf;
|
||||
use app\repository\payment\config\PaymentPluginConfRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
|
||||
/**
|
||||
* 支付插件配置服务。
|
||||
*
|
||||
* 负责插件公共配置的增删改查和下拉选项输出。
|
||||
*/
|
||||
class PaymentPluginConfService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
protected PaymentPluginRepository $paymentPluginRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询插件配置。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentPluginConfRepository->query()
|
||||
->from('ma_payment_plugin_conf as c')
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.plugin_code',
|
||||
'c.config',
|
||||
'c.settlement_cycle_type',
|
||||
'c.settlement_cutoff_time',
|
||||
'c.remark',
|
||||
'c.created_at',
|
||||
'c.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name");
|
||||
|
||||
$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 . '%')
|
||||
->orWhere('p.name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$pluginCode = trim((string) ($filters['plugin_code'] ?? ''));
|
||||
if ($pluginCode !== '') {
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('c.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询插件配置。
|
||||
*/
|
||||
public function findById(int $id): ?PaymentPluginConf
|
||||
{
|
||||
return $this->paymentPluginConfRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增插件配置。
|
||||
*/
|
||||
public function create(array $data): PaymentPluginConf
|
||||
{
|
||||
$payload = $this->normalizePayload($data);
|
||||
$this->assertPluginExists((string) $payload['plugin_code']);
|
||||
|
||||
return $this->paymentPluginConfRepository->create($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改插件配置。
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentPluginConf
|
||||
{
|
||||
$payload = $this->normalizePayload($data);
|
||||
$this->assertPluginExists((string) $payload['plugin_code']);
|
||||
|
||||
if (!$this->paymentPluginConfRepository->updateById($id, $payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentPluginConfRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件配置。
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPluginConfRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询插件配置下拉选项。
|
||||
*/
|
||||
public function options(?string $pluginCode = null): array
|
||||
{
|
||||
$pluginCode = trim((string) $pluginCode);
|
||||
|
||||
$query = $this->paymentPluginConfRepository->query()
|
||||
->from('ma_payment_plugin_conf as c')
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.plugin_code',
|
||||
])
|
||||
->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name")
|
||||
->orderByDesc('c.id');
|
||||
|
||||
if ($pluginCode !== '') {
|
||||
$query->where('c.plugin_code', $pluginCode);
|
||||
}
|
||||
|
||||
return $query->get()->map(function ($item): array {
|
||||
$pluginName = trim((string) ($item->plugin_name ?? ''));
|
||||
$pluginCode = trim((string) ($item->plugin_code ?? ''));
|
||||
$label = $pluginName !== '' ? $pluginName : $pluginCode;
|
||||
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', $label, (int) $item->id),
|
||||
'value' => (int) $item->id,
|
||||
'plugin_code' => $pluginCode,
|
||||
'plugin_name' => $pluginName !== '' ? $pluginName : $pluginCode,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程查询插件配置选择项。
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$query = $this->paymentPluginConfRepository->query()
|
||||
->from('ma_payment_plugin_conf as c')
|
||||
->leftJoin('ma_payment_plugin as p', 'c.plugin_code', '=', 'p.code')
|
||||
->select([
|
||||
'c.id',
|
||||
'c.plugin_code',
|
||||
])
|
||||
->selectRaw("COALESCE(NULLIF(p.name, ''), c.plugin_code) AS plugin_name")
|
||||
->orderByDesc('c.id');
|
||||
|
||||
$ids = $filters['ids'] ?? [];
|
||||
if (is_array($ids) && $ids !== []) {
|
||||
$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 !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('c.plugin_code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('p.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.remark', 'like', '%' . $keyword . '%');
|
||||
|
||||
if (ctype_digit($keyword)) {
|
||||
$builder->orWhere('c.id', (int) $keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
return [
|
||||
'list' => collect($paginator->items())->map(function ($item): array {
|
||||
$pluginName = trim((string) ($item->plugin_name ?? ''));
|
||||
$pluginCode = trim((string) ($item->plugin_code ?? ''));
|
||||
$label = $pluginName !== '' ? $pluginName : $pluginCode;
|
||||
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', $label, (int) $item->id),
|
||||
'value' => (int) $item->id,
|
||||
'plugin_code' => $pluginCode,
|
||||
'plugin_name' => $pluginName !== '' ? $pluginName : $pluginCode,
|
||||
];
|
||||
})->values()->all(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化写入数据。
|
||||
*/
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
return [
|
||||
'plugin_code' => trim((string) ($data['plugin_code'] ?? '')),
|
||||
'config' => is_array($data['config'] ?? null) ? $data['config'] : [],
|
||||
'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'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验插件是否存在。
|
||||
*/
|
||||
private function assertPluginExists(string $pluginCode): void
|
||||
{
|
||||
if ($pluginCode === '') {
|
||||
throw new PaymentException('插件编码不能为空', 40230);
|
||||
}
|
||||
|
||||
if (!$this->paymentPluginRepository->findByCode($pluginCode)) {
|
||||
throw new PaymentException('支付插件不存在', 40231, [
|
||||
'plugin_code' => $pluginCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/service/payment/config/PaymentPluginService.php
Normal file
207
app/service/payment/config/PaymentPluginService.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPlugin;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
|
||||
/**
|
||||
* 支付插件管理服务。
|
||||
*
|
||||
* 负责插件目录同步、插件列表查询,以及 JSON 字段写入前的归一化。
|
||||
*/
|
||||
class PaymentPluginService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入支付插件仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentPluginSyncService $paymentPluginSyncService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付插件。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentPluginRepository->query();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('class_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$code = trim((string) ($filters['code'] ?? ''));
|
||||
if ($code !== '') {
|
||||
$query->where('code', 'like', '%' . $code . '%');
|
||||
}
|
||||
|
||||
$name = trim((string) ($filters['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$query->where('name', 'like', '%' . $name . '%');
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('code')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询启用中的支付插件选项。
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->paymentPluginRepository->enabledList(['code', 'name'])
|
||||
->map(function (PaymentPlugin $plugin): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code),
|
||||
'value' => (string) $plugin->code,
|
||||
'code' => (string) $plugin->code,
|
||||
'name' => (string) $plugin->name,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程查询支付插件选择项。
|
||||
*/
|
||||
public function searchOptions(array $filters = [], int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$query = $this->paymentPluginRepository->query()
|
||||
->where('status', 1)
|
||||
->select(['code', 'name', 'pay_types'])
|
||||
->orderBy('code');
|
||||
|
||||
$ids = $filters['ids'] ?? [];
|
||||
if (is_array($ids) && $ids !== []) {
|
||||
$query->whereIn('code', array_values(array_filter(array_map('strval', $ids))));
|
||||
} else {
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$payTypeCode = trim((string) ($filters['pay_type_code'] ?? ''));
|
||||
if ($payTypeCode !== '') {
|
||||
$query->whereJsonContains('pay_types', $payTypeCode);
|
||||
}
|
||||
}
|
||||
|
||||
$paginator = $query->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
return [
|
||||
'list' => collect($paginator->items())->map(function (PaymentPlugin $plugin): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code),
|
||||
'value' => (string) $plugin->code,
|
||||
'code' => (string) $plugin->code,
|
||||
'name' => (string) $plugin->name,
|
||||
'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [],
|
||||
];
|
||||
})->values()->all(),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询通道配置场景使用的支付插件选项。
|
||||
*/
|
||||
public function channelOptions(): array
|
||||
{
|
||||
return $this->paymentPluginRepository->enabledList([
|
||||
'code',
|
||||
'name',
|
||||
'pay_types',
|
||||
])
|
||||
->map(function (PaymentPlugin $plugin): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%s)', (string) $plugin->name, (string) $plugin->code),
|
||||
'value' => (string) $plugin->code,
|
||||
'code' => (string) $plugin->code,
|
||||
'name' => (string) $plugin->name,
|
||||
'pay_types' => is_array($plugin->pay_types) ? array_values($plugin->pay_types) : [],
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按插件编码查询插件。
|
||||
*/
|
||||
public function findByCode(string $code): ?PaymentPlugin
|
||||
{
|
||||
return $this->paymentPluginRepository->findByCode($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询插件配置结构。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSchema(string $code): array
|
||||
{
|
||||
$plugin = $this->paymentPluginRepository->findByCode($code);
|
||||
if (!$plugin) {
|
||||
throw new PaymentException('支付插件不存在', 404, [
|
||||
'plugin_code' => $code,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'config_schema' => is_array($plugin->config_schema) ? array_values($plugin->config_schema) : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付插件。
|
||||
*/
|
||||
public function update(string $code, array $data): ?PaymentPlugin
|
||||
{
|
||||
$payload = [];
|
||||
if (array_key_exists('status', $data)) {
|
||||
$payload['status'] = (int) $data['status'];
|
||||
}
|
||||
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$payload['remark'] = trim((string) $data['remark']);
|
||||
}
|
||||
|
||||
if ($payload === []) {
|
||||
return $this->paymentPluginRepository->findByCode($code);
|
||||
}
|
||||
|
||||
if (!$this->paymentPluginRepository->updateByKey($code, $payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentPluginRepository->findByCode($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从插件目录刷新并同步支付插件定义。
|
||||
*/
|
||||
public function refreshFromClasses(): array
|
||||
{
|
||||
return $this->paymentPluginSyncService->refreshFromClasses();
|
||||
}
|
||||
}
|
||||
122
app/service/payment/config/PaymentPluginSyncService.php
Normal file
122
app/service/payment/config/PaymentPluginSyncService.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPlugin;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
|
||||
/**
|
||||
* 支付插件同步服务。
|
||||
*
|
||||
* 负责扫描插件目录、实例化插件类并同步数据库定义。
|
||||
*/
|
||||
class PaymentPluginSyncService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 从插件目录刷新并同步支付插件定义。
|
||||
*/
|
||||
public function refreshFromClasses(): array
|
||||
{
|
||||
$directory = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'payment';
|
||||
$files = glob($directory . DIRECTORY_SEPARATOR . '*.php') ?: [];
|
||||
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
$shortClassName = pathinfo($file, PATHINFO_FILENAME);
|
||||
$className = 'app\\common\\payment\\' . $shortClassName;
|
||||
$plugin = $this->instantiatePlugin($className);
|
||||
if (!$plugin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$code = trim((string) $plugin->getCode());
|
||||
if ($code === '') {
|
||||
throw new PaymentException('支付插件编码不能为空', 40220, ['class_name' => $className]);
|
||||
}
|
||||
|
||||
if (isset($rows[$code])) {
|
||||
throw new PaymentException('支付插件编码重复', 40221, [
|
||||
'plugin_code' => $code,
|
||||
'class_name' => $className,
|
||||
]);
|
||||
}
|
||||
|
||||
$rows[$code] = [
|
||||
'code' => $plugin->getCode(),
|
||||
'name' => $plugin->getName(),
|
||||
'class_name' => $shortClassName,
|
||||
'config_schema' => $plugin->getConfigSchema(),
|
||||
'pay_types' => $plugin->getEnabledPayTypes(),
|
||||
'transfer_types' => $plugin->getEnabledTransferTypes(),
|
||||
'version' => $plugin->getVersion(),
|
||||
'author' => $plugin->getAuthorName(),
|
||||
'link' => $plugin->getAuthorLink(),
|
||||
];
|
||||
}
|
||||
|
||||
ksort($rows);
|
||||
|
||||
$existing = $this->paymentPluginRepository->query()
|
||||
->get()
|
||||
->keyBy('code')
|
||||
->all();
|
||||
|
||||
$this->transaction(function () use ($rows, $existing) {
|
||||
foreach ($rows as $code => $row) {
|
||||
/** @var PaymentPlugin|null $current */
|
||||
$current = $existing[$code] ?? null;
|
||||
$payload = array_merge($row, [
|
||||
'status' => (int) ($current->status ?? 1),
|
||||
'remark' => (string) ($current->remark ?? ''),
|
||||
]);
|
||||
|
||||
if ($current) {
|
||||
$current->fill($payload);
|
||||
$current->save();
|
||||
unset($existing[$code]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->paymentPluginRepository->create($payload);
|
||||
}
|
||||
|
||||
foreach ($existing as $plugin) {
|
||||
$plugin->delete();
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'count' => count($rows),
|
||||
'plugins' => $this->paymentPluginRepository->query()
|
||||
->orderBy('code')
|
||||
->get()
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 实例化插件类并过滤非支付插件类。
|
||||
*/
|
||||
private function instantiatePlugin(string $className): null|(PaymentInterface & PayPluginInterface)
|
||||
{
|
||||
if (!class_exists($className)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = container_make($className, []);
|
||||
if (!$instance instanceof PayPluginInterface || !$instance instanceof PaymentInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
162
app/service/payment/config/PaymentPollGroupBindService.php
Normal file
162
app/service/payment/config/PaymentPollGroupBindService.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPollGroupBind;
|
||||
use app\repository\merchant\base\MerchantGroupRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupBindRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 商户分组路由绑定服务。
|
||||
*/
|
||||
class PaymentPollGroupBindService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupBindRepository $paymentPollGroupBindRepository,
|
||||
protected MerchantGroupRepository $merchantGroupRepository,
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询商户分组路由绑定。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentPollGroupBindRepository->query()
|
||||
->from('ma_payment_poll_group_bind as b')
|
||||
->leftJoin('ma_merchant_group as mg', 'mg.id', '=', 'b.merchant_group_id')
|
||||
->leftJoin('ma_payment_type as t', 't.id', '=', 'b.pay_type_id')
|
||||
->leftJoin('ma_payment_poll_group as pg', 'pg.id', '=', 'b.poll_group_id')
|
||||
->select([
|
||||
'b.id',
|
||||
'b.merchant_group_id',
|
||||
'b.pay_type_id',
|
||||
'b.poll_group_id',
|
||||
'b.status',
|
||||
'b.remark',
|
||||
'b.created_at',
|
||||
'b.updated_at',
|
||||
'mg.group_name as merchant_group_name',
|
||||
't.name as pay_type_name',
|
||||
'pg.group_name as poll_group_name',
|
||||
'pg.route_mode',
|
||||
]);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('mg.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('t.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('pg.group_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($merchantGroupId = (int) ($filters['merchant_group_id'] ?? 0)) > 0) {
|
||||
$query->where('b.merchant_group_id', $merchantGroupId);
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('b.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (($pollGroupId = (int) ($filters['poll_group_id'] ?? 0)) > 0) {
|
||||
$query->where('b.poll_group_id', $pollGroupId);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('b.status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('b.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentPollGroupBind
|
||||
{
|
||||
return $this->paymentPollGroupBindRepository->find($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentPollGroupBind
|
||||
{
|
||||
$this->assertBindingUnique((int) $data['merchant_group_id'], (int) $data['pay_type_id']);
|
||||
$this->assertPollGroupMatchesPayType($data);
|
||||
|
||||
return $this->paymentPollGroupBindRepository->create($this->normalizePayload($data));
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentPollGroupBind
|
||||
{
|
||||
$current = $this->paymentPollGroupBindRepository->find($id);
|
||||
if (!$current) {
|
||||
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);
|
||||
$this->assertPollGroupMatchesPayType(array_merge($current->toArray(), $data));
|
||||
|
||||
if (!$this->paymentPollGroupBindRepository->updateById($id, $this->normalizePayload($data))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentPollGroupBindRepository->find($id);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupBindRepository->deleteById($id);
|
||||
}
|
||||
|
||||
private function normalizePayload(array $data): array
|
||||
{
|
||||
return [
|
||||
'merchant_group_id' => (int) $data['merchant_group_id'],
|
||||
'pay_type_id' => (int) $data['pay_type_id'],
|
||||
'poll_group_id' => (int) $data['poll_group_id'],
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertBindingUnique(int $merchantGroupId, int $payTypeId, int $ignoreId = 0): void
|
||||
{
|
||||
$query = $this->paymentPollGroupBindRepository->query()
|
||||
->where('merchant_group_id', $merchantGroupId)
|
||||
->where('pay_type_id', $payTypeId);
|
||||
|
||||
if ($ignoreId > 0) {
|
||||
$query->where('id', '<>', $ignoreId);
|
||||
}
|
||||
|
||||
if ($query->exists()) {
|
||||
throw new PaymentException('当前商户分组与支付方式已绑定轮询组', 40232, [
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if ((int) $pollGroup->pay_type_id !== $payTypeId) {
|
||||
throw new PaymentException('轮询组与支付方式不一致', 40233, [
|
||||
'poll_group_id' => $pollGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
app/service/payment/config/PaymentPollGroupChannelService.php
Normal file
184
app/service/payment/config/PaymentPollGroupChannelService.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPollGroupChannel;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 轮询组通道编排服务。
|
||||
*/
|
||||
class PaymentPollGroupChannelService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupChannelRepository $paymentPollGroupChannelRepository,
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询轮询组通道编排。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentPollGroupChannelRepository->query()
|
||||
->from('ma_payment_poll_group_channel as pgc')
|
||||
->leftJoin('ma_payment_poll_group as pg', 'pg.id', '=', 'pgc.poll_group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'pgc.channel_id')
|
||||
->leftJoin('ma_payment_type as t', 't.id', '=', 'pg.pay_type_id')
|
||||
->select([
|
||||
'pgc.id',
|
||||
'pgc.poll_group_id',
|
||||
'pgc.channel_id',
|
||||
'pgc.sort_no',
|
||||
'pgc.weight',
|
||||
'pgc.is_default',
|
||||
'pgc.status',
|
||||
'pgc.remark',
|
||||
'pgc.created_at',
|
||||
'pgc.updated_at',
|
||||
'pg.group_name as poll_group_name',
|
||||
'pg.pay_type_id',
|
||||
'c.name as channel_name',
|
||||
'c.merchant_id',
|
||||
'c.channel_mode',
|
||||
'c.plugin_code',
|
||||
't.name as pay_type_name',
|
||||
]);
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('pg.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.plugin_code', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($pollGroupId = (int) ($filters['poll_group_id'] ?? 0)) > 0) {
|
||||
$query->where('pgc.poll_group_id', $pollGroupId);
|
||||
}
|
||||
|
||||
if (($channelId = (int) ($filters['channel_id'] ?? 0)) > 0) {
|
||||
$query->where('pgc.channel_id', $channelId);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('pgc.status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('pgc.poll_group_id')
|
||||
->orderBy('pgc.sort_no')
|
||||
->orderByDesc('pgc.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentPollGroupChannel
|
||||
{
|
||||
return $this->paymentPollGroupChannelRepository->find($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentPollGroupChannel
|
||||
{
|
||||
$this->assertPairUnique((int) $data['poll_group_id'], (int) $data['channel_id']);
|
||||
$this->assertChannelMatchesPollGroup($data);
|
||||
$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']);
|
||||
}
|
||||
|
||||
return $this->paymentPollGroupChannelRepository->create($payload);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentPollGroupChannel
|
||||
{
|
||||
$current = $this->paymentPollGroupChannelRepository->find($id);
|
||||
if (!$current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pollGroupId = (int) ($data['poll_group_id'] ?? $current->poll_group_id);
|
||||
$channelId = (int) ($data['channel_id'] ?? $current->channel_id);
|
||||
$this->assertPairUnique($pollGroupId, $channelId, $id);
|
||||
$this->assertChannelMatchesPollGroup(array_merge($current->toArray(), $data));
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
if (!$this->paymentPollGroupChannelRepository->updateById($id, $payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentPollGroupChannelRepository->find($id);
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupChannelRepository->deleteById($id);
|
||||
}
|
||||
|
||||
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),
|
||||
'weight' => max(1, (int) ($data['weight'] ?? 100)),
|
||||
'is_default' => (int) ($data['is_default'] ?? 0),
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertPairUnique(int $pollGroupId, int $channelId, int $ignoreId = 0): void
|
||||
{
|
||||
$query = $this->paymentPollGroupChannelRepository->query()
|
||||
->where('poll_group_id', $pollGroupId)
|
||||
->where('channel_id', $channelId);
|
||||
|
||||
if ($ignoreId > 0) {
|
||||
$query->where('id', '<>', $ignoreId);
|
||||
}
|
||||
|
||||
if ($query->exists()) {
|
||||
throw new PaymentException('该轮询组已添加当前支付通道', 40230, [
|
||||
'poll_group_id' => $pollGroupId,
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertChannelMatchesPollGroup(array $data): void
|
||||
{
|
||||
$pollGroupId = (int) ($data['poll_group_id'] ?? 0);
|
||||
$channelId = (int) ($data['channel_id'] ?? 0);
|
||||
|
||||
$pollGroup = $this->paymentPollGroupRepository->find($pollGroupId);
|
||||
$channel = $this->paymentChannelRepository->find($channelId);
|
||||
|
||||
if (!$pollGroup || !$channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $pollGroup->pay_type_id !== (int) $channel->pay_type_id) {
|
||||
throw new PaymentException('轮询组与支付通道的支付方式不一致', 40231, [
|
||||
'poll_group_id' => $pollGroupId,
|
||||
'channel_id' => $channelId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 支付轮询组命令服务。
|
||||
*/
|
||||
class PaymentPollGroupCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentPollGroup
|
||||
{
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''));
|
||||
return $this->paymentPollGroupRepository->create($data);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentPollGroup
|
||||
{
|
||||
$this->assertGroupNameUnique((string) ($data['group_name'] ?? ''), $id);
|
||||
if (!$this->paymentPollGroupRepository->updateById($id, $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentPollGroupRepository->find($id);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentPollGroupRepository->deleteById($id);
|
||||
}
|
||||
|
||||
private function assertGroupNameUnique(string $groupName, int $ignoreId = 0): void
|
||||
{
|
||||
$groupName = trim($groupName);
|
||||
if ($groupName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->paymentPollGroupRepository->existsByGroupName($groupName, $ignoreId)) {
|
||||
throw new PaymentException('轮询组名称已存在', 40234, [
|
||||
'group_name' => $groupName,
|
||||
'ignore_id' => $ignoreId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
app/service/payment/config/PaymentPollGroupQueryService.php
Normal file
79
app/service/payment/config/PaymentPollGroupQueryService.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
|
||||
/**
|
||||
* 支付轮询组查询服务。
|
||||
*/
|
||||
class PaymentPollGroupQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupRepository $paymentPollGroupRepository
|
||||
) {
|
||||
}
|
||||
|
||||
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 . '%');
|
||||
}
|
||||
|
||||
$groupName = trim((string) ($filters['group_name'] ?? ''));
|
||||
if ($groupName !== '') {
|
||||
$query->where('group_name', 'like', '%' . $groupName . '%');
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (array_key_exists('route_mode', $filters) && $filters['route_mode'] !== '') {
|
||||
$query->where('route_mode', (int) $filters['route_mode']);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByDesc('id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('group_name')
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'group_name', 'pay_type_id', 'route_mode'])
|
||||
->map(function (PaymentPollGroup $pollGroup): array {
|
||||
return [
|
||||
'label' => sprintf('%s(%d)', (string) $pollGroup->group_name, (int) $pollGroup->id),
|
||||
'value' => (int) $pollGroup->id,
|
||||
'pay_type_id' => (int) $pollGroup->pay_type_id,
|
||||
'route_mode' => (int) $pollGroup->route_mode,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentPollGroup
|
||||
{
|
||||
return $this->paymentPollGroupRepository->find($id);
|
||||
}
|
||||
}
|
||||
48
app/service/payment/config/PaymentPollGroupService.php
Normal file
48
app/service/payment/config/PaymentPollGroupService.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
|
||||
/**
|
||||
* 支付轮询组门面服务。
|
||||
*/
|
||||
class PaymentPollGroupService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupQueryService $queryService,
|
||||
protected PaymentPollGroupCommandService $commandService
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function enabledOptions(array $filters = []): array
|
||||
{
|
||||
return $this->queryService->enabledOptions($filters);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentPollGroup
|
||||
{
|
||||
return $this->queryService->findById($id);
|
||||
}
|
||||
|
||||
public function create(array $data): PaymentPollGroup
|
||||
{
|
||||
return $this->commandService->create($data);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): ?PaymentPollGroup
|
||||
{
|
||||
return $this->commandService->update($id, $data);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
}
|
||||
150
app/service/payment/config/PaymentTypeService.php
Normal file
150
app/service/payment/config/PaymentTypeService.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 支付方式字典服务。
|
||||
*
|
||||
* 负责支付方式的基础列表查询、新增、修改、删除和下拉选项输出。
|
||||
*/
|
||||
class PaymentTypeService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入支付方式仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付方式。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->paymentTypeRepository->query();
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('code', 'like', '%' . $keyword . '%')
|
||||
->orWhere('name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$code = trim((string) ($filters['code'] ?? ''));
|
||||
if ($code !== '') {
|
||||
$query->where('code', 'like', '%' . $code . '%');
|
||||
}
|
||||
|
||||
$name = trim((string) ($filters['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$query->where('name', 'like', '%' . $name . '%');
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderBy('sort_no')
|
||||
->orderByDesc('id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询启用中的支付方式选项。
|
||||
*/
|
||||
public function enabledOptions(): array
|
||||
{
|
||||
return $this->paymentTypeRepository->enabledList(['id', 'code', 'name'])
|
||||
->map(function (PaymentType $paymentType): array {
|
||||
return [
|
||||
'label' => (string) $paymentType->name,
|
||||
'value' => (int) $paymentType->id,
|
||||
'code' => (string) $paymentType->code,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析启用中的支付方式,优先按编码匹配,未命中则取首个启用项。
|
||||
*/
|
||||
public function resolveEnabledType(string $code = ''): PaymentType
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code !== '') {
|
||||
$paymentType = $this->paymentTypeRepository->findByCode($code);
|
||||
if ($paymentType && (int) $paymentType->status === 1) {
|
||||
return $paymentType;
|
||||
}
|
||||
}
|
||||
|
||||
$paymentType = $this->paymentTypeRepository->enabledList()->first();
|
||||
if (!$paymentType) {
|
||||
throw new ValidationException('未配置可用支付方式');
|
||||
}
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式编码查询字典。
|
||||
*/
|
||||
public function findByCode(string $code): ?PaymentType
|
||||
{
|
||||
return $this->paymentTypeRepository->findByCode(trim($code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付方式 ID 解析支付方式编码。
|
||||
*/
|
||||
public function resolveCodeById(int $id): string
|
||||
{
|
||||
$paymentType = $this->paymentTypeRepository->find($id);
|
||||
return $paymentType ? (string) $paymentType->code : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 查询支付方式。
|
||||
*/
|
||||
public function findById(int $id): ?PaymentType
|
||||
{
|
||||
return $this->paymentTypeRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增支付方式。
|
||||
*/
|
||||
public function create(array $data): PaymentType
|
||||
{
|
||||
return $this->paymentTypeRepository->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付方式。
|
||||
*/
|
||||
public function update(int $id, array $data): ?PaymentType
|
||||
{
|
||||
if (!$this->paymentTypeRepository->updateById($id, $data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->paymentTypeRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付方式。
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->paymentTypeRepository->deleteById($id);
|
||||
}
|
||||
}
|
||||
256
app/service/payment/order/PayOrderAttemptService.php
Normal file
256
app/service/payment/order/PayOrderAttemptService.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ConflictException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
use app\service\merchant\MerchantService;
|
||||
use app\service\payment\runtime\PaymentRouteService;
|
||||
|
||||
/**
|
||||
* 支付单发起服务。
|
||||
*
|
||||
* 负责支付单预创建、通道路由选择、第三方装单和首轮状态落库。
|
||||
*/
|
||||
class PayOrderAttemptService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantService $merchantService,
|
||||
protected PaymentRouteService $paymentRouteService,
|
||||
protected MerchantAccountService $merchantAccountService,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository,
|
||||
protected PayOrderChannelDispatchService $payOrderChannelDispatchService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 预创建支付尝试。
|
||||
*
|
||||
* 该方法会完成商户、支付方式、路由、通道限额、串行尝试和自有通道手续费预占的完整预检查。
|
||||
*
|
||||
* @param array $input 支付请求参数
|
||||
* @return array{merchant:mixed,biz_order:mixed,pay_order:mixed,route:array,payment_result:array,pay_params:array}
|
||||
*/
|
||||
public function preparePayAttempt(array $input): array
|
||||
{
|
||||
$merchantId = (int) ($input['merchant_id'] ?? 0);
|
||||
$merchantOrderNo = trim((string) ($input['merchant_order_no'] ?? ''));
|
||||
$payTypeId = (int) ($input['pay_type_id'] ?? 0);
|
||||
$payAmount = (int) ($input['pay_amount'] ?? 0);
|
||||
|
||||
if ($merchantId <= 0 || $merchantOrderNo === '' || $payTypeId <= 0 || $payAmount <= 0) {
|
||||
throw new ValidationException('支付入参不完整');
|
||||
}
|
||||
|
||||
$merchant = $this->merchantService->ensureMerchantEnabled($merchantId);
|
||||
$merchantGroupId = (int) $merchant->group_id;
|
||||
if ($merchantGroupId <= 0) {
|
||||
throw new ValidationException('商户未配置分组', ['merchant_id' => $merchantId]);
|
||||
}
|
||||
$this->merchantService->ensureMerchantGroupEnabled($merchantGroupId);
|
||||
|
||||
/** @var PaymentType|null $paymentType */
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
if (!$paymentType || (int) $paymentType->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new BusinessStateException('支付方式不支持', ['pay_type_id' => $payTypeId]);
|
||||
}
|
||||
|
||||
$route = $this->paymentRouteService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $input);
|
||||
$selected = $route['selected_channel'];
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $selected['channel'];
|
||||
|
||||
$payNo = $this->generateNo('PAY');
|
||||
$channelRequestNo = $this->generateNo('REQ');
|
||||
|
||||
$prepared = $this->transactionRetry(function () use (
|
||||
$input,
|
||||
$merchant,
|
||||
$merchantId,
|
||||
$merchantGroupId,
|
||||
$merchantOrderNo,
|
||||
$payTypeId,
|
||||
$payAmount,
|
||||
$route,
|
||||
$channel,
|
||||
$payNo,
|
||||
$channelRequestNo
|
||||
) {
|
||||
$existingBizOrder = $this->bizOrderRepository->findForUpdateByMerchantAndOrderNo($merchantId, $merchantOrderNo);
|
||||
$bizTraceNo = '';
|
||||
|
||||
if ($existingBizOrder) {
|
||||
if ((int) $existingBizOrder->order_amount !== $payAmount) {
|
||||
throw new ValidationException('同一商户订单号金额不一致', [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array((int) $existingBizOrder->status, [
|
||||
TradeConstant::ORDER_STATUS_SUCCESS,
|
||||
TradeConstant::ORDER_STATUS_CLOSED,
|
||||
TradeConstant::ORDER_STATUS_TIMEOUT,
|
||||
], true)) {
|
||||
throw new BusinessStateException('支付单状态不允许重复创建', [
|
||||
'biz_no' => (string) $existingBizOrder->biz_no,
|
||||
'status' => (int) $existingBizOrder->status,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($existingBizOrder->active_pay_no)) {
|
||||
$activePayOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $existingBizOrder->active_pay_no);
|
||||
if ($activePayOrder && in_array((int) $activePayOrder->status, [TradeConstant::ORDER_STATUS_CREATED, TradeConstant::ORDER_STATUS_PAYING], true)) {
|
||||
throw new ConflictException('重复请求', [
|
||||
'biz_no' => (string) $existingBizOrder->biz_no,
|
||||
'active_pay_no' => (string) $existingBizOrder->active_pay_no,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$bizOrder = $existingBizOrder;
|
||||
$bizTraceNo = trim((string) ($bizOrder->trace_no ?? ''));
|
||||
if ($bizTraceNo === '') {
|
||||
$bizTraceNo = (string) $bizOrder->biz_no;
|
||||
$bizOrder->trace_no = $bizTraceNo;
|
||||
}
|
||||
$attemptNo = (int) $bizOrder->attempt_count + 1;
|
||||
} else {
|
||||
$bizOrder = $this->bizOrderRepository->create([
|
||||
'biz_no' => $this->generateNo('BIZ'),
|
||||
'trace_no' => $this->generateNo('TRC'),
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'poll_group_id' => (int) $route['poll_group']->id,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'subject' => (string) ($input['subject'] ?? ''),
|
||||
'body' => (string) ($input['body'] ?? ''),
|
||||
'order_amount' => $payAmount,
|
||||
'paid_amount' => 0,
|
||||
'refund_amount' => 0,
|
||||
'status' => TradeConstant::ORDER_STATUS_CREATED,
|
||||
'attempt_count' => 0,
|
||||
'ext_json' => $input['ext_json'] ?? [],
|
||||
]);
|
||||
$bizTraceNo = (string) $bizOrder->trace_no;
|
||||
$attemptNo = 1;
|
||||
}
|
||||
|
||||
$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,
|
||||
$payNo,
|
||||
'PAY_FREEZE:' . $payNo,
|
||||
[
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'pay_type_id' => $payTypeId,
|
||||
'channel_id' => (int) $channel->id,
|
||||
'remark' => '自有通道手续费预占',
|
||||
],
|
||||
$bizTraceNo
|
||||
);
|
||||
}
|
||||
|
||||
$payOrder = $this->payOrderRepository->create([
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
'trace_no' => $bizTraceNo,
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'poll_group_id' => (int) $route['poll_group']->id,
|
||||
'attempt_no' => (int) $attemptNo,
|
||||
'channel_id' => (int) $channel->id,
|
||||
'pay_type_id' => $payTypeId,
|
||||
'plugin_code' => (string) $channel->plugin_code,
|
||||
'channel_type' => (int) $channel->channel_mode,
|
||||
'channel_mode' => (int) $channel->channel_mode,
|
||||
'pay_amount' => $payAmount,
|
||||
'fee_rate_bp_snapshot' => $feeRateBp,
|
||||
'split_rate_bp_snapshot' => $splitRateBp,
|
||||
'fee_estimated_amount' => $feeEstimated,
|
||||
'fee_actual_amount' => 0,
|
||||
'status' => TradeConstant::ORDER_STATUS_PAYING,
|
||||
'fee_status' => (int) $channel->channel_mode === RouteConstant::CHANNEL_MODE_SELF ? TradeConstant::FEE_STATUS_FROZEN : TradeConstant::FEE_STATUS_NONE,
|
||||
'settlement_status' => TradeConstant::SETTLEMENT_STATUS_NONE,
|
||||
'channel_request_no' => $channelRequestNo,
|
||||
'request_at' => $this->now(),
|
||||
'callback_status' => NotifyConstant::PROCESS_STATUS_PENDING,
|
||||
'callback_times' => 0,
|
||||
'ext_json' => array_merge($input['ext_json'] ?? [], [
|
||||
'merchant_no' => (string) $merchant->merchant_no,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'poll_group_id' => (int) $route['poll_group']->id,
|
||||
'channel_id' => (int) $channel->id,
|
||||
'channel_mode' => (int) $channel->channel_mode,
|
||||
'trace_no' => $bizTraceNo,
|
||||
]),
|
||||
]);
|
||||
|
||||
$bizOrder->active_pay_no = (string) $payOrder->pay_no;
|
||||
$bizOrder->attempt_count = (int) $attemptNo;
|
||||
$bizOrder->status = TradeConstant::ORDER_STATUS_PAYING;
|
||||
$bizOrder->merchant_group_id = $merchantGroupId;
|
||||
$bizOrder->poll_group_id = (int) $route['poll_group']->id;
|
||||
if ($bizTraceNo !== '' && (string) ($bizOrder->trace_no ?? '') === '') {
|
||||
$bizOrder->trace_no = $bizTraceNo;
|
||||
}
|
||||
$bizOrder->save();
|
||||
|
||||
return [
|
||||
'merchant' => $merchant,
|
||||
'biz_order' => $bizOrder->refresh(),
|
||||
'pay_order' => $payOrder,
|
||||
'route' => $route,
|
||||
];
|
||||
});
|
||||
|
||||
/** @var PayOrder $payOrder */
|
||||
$payOrder = $prepared['pay_order'];
|
||||
/** @var BizOrder $bizOrder */
|
||||
$bizOrder = $prepared['biz_order'];
|
||||
/** @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'];
|
||||
$prepared['payment_result'] = $channelDispatchResult['payment_result'];
|
||||
$prepared['pay_params'] = $channelDispatchResult['pay_params'];
|
||||
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算手续费金额。
|
||||
*/
|
||||
private function calculateAmountByBp(int $amount, int $bp): int
|
||||
{
|
||||
if ($amount <= 0 || $bp <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) floor($amount * $bp / 10000);
|
||||
}
|
||||
}
|
||||
139
app/service/payment/order/PayOrderCallbackService.php
Normal file
139
app/service/payment/order/PayOrderCallbackService.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\exception\PaymentException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\payment\runtime\NotifyService;
|
||||
use app\service\payment\runtime\PaymentPluginManager;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付单回调服务。
|
||||
*
|
||||
* 负责渠道回调日志记录、插件回调解析和支付状态分发。
|
||||
*/
|
||||
class PayOrderCallbackService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected NotifyService $notifyService,
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected PayOrderLifecycleService $payOrderLifecycleService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理渠道回调。
|
||||
*/
|
||||
public function handleChannelCallback(array $input): PayOrder
|
||||
{
|
||||
$payNo = trim((string) ($input['pay_no'] ?? ''));
|
||||
if ($payNo === '') {
|
||||
throw new \InvalidArgumentException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
$this->notifyService->recordPayCallback([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) ($input['channel_id'] ?? 0),
|
||||
'callback_type' => (int) ($input['callback_type'] ?? NotifyConstant::CALLBACK_TYPE_ASYNC),
|
||||
'request_data' => $input['request_data'] ?? [],
|
||||
'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN),
|
||||
'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING),
|
||||
'process_result' => $input['process_result'] ?? [],
|
||||
]);
|
||||
|
||||
$success = (bool) ($input['success'] ?? false);
|
||||
if ($success) {
|
||||
return $this->payOrderLifecycleService->markPaySuccess($payNo, $input);
|
||||
}
|
||||
|
||||
return $this->payOrderLifecycleService->markPayFailed($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按支付单号处理真实第三方回调。
|
||||
*/
|
||||
public function handlePluginCallback(string $payNo, Request $request): string|Response
|
||||
{
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$plugin = $this->paymentPluginManager->createByPayOrder($payOrder, true);
|
||||
|
||||
try {
|
||||
$result = $plugin->notify($request);
|
||||
$status = (string) ($result['status'] ?? '');
|
||||
$success = array_key_exists('success', $result)
|
||||
? (bool) $result['success']
|
||||
: in_array($status, ['success', 'paid'], true);
|
||||
|
||||
$callbackPayload = [
|
||||
'pay_no' => $payNo,
|
||||
'success' => $success,
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC,
|
||||
'request_data' => array_merge($request->get(), $request->post()),
|
||||
'verify_status' => NotifyConstant::VERIFY_STATUS_SUCCESS,
|
||||
'process_status' => $success ? NotifyConstant::PROCESS_STATUS_SUCCESS : NotifyConstant::PROCESS_STATUS_FAILED,
|
||||
'process_result' => $result,
|
||||
'channel_trade_no' => (string) ($result['chan_trade_no'] ?? ''),
|
||||
'channel_order_no' => (string) ($result['chan_order_no'] ?? ''),
|
||||
'paid_at' => $result['paid_at'] ?? null,
|
||||
'channel_error_code' => (string) ($result['channel_error_code'] ?? ''),
|
||||
'channel_error_msg' => (string) ($result['channel_error_msg'] ?? ''),
|
||||
'ext_json' => [
|
||||
'plugin_code' => (string) $payOrder->plugin_code,
|
||||
'notify_status' => $status,
|
||||
],
|
||||
];
|
||||
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,
|
||||
'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC,
|
||||
'request_data' => array_merge($request->get(), $request->post()),
|
||||
'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED,
|
||||
'process_status' => NotifyConstant::PROCESS_STATUS_FAILED,
|
||||
'process_result' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $plugin->notifyFail();
|
||||
} catch (\Throwable $e) {
|
||||
$this->notifyService->recordPayCallback([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
'callback_type' => NotifyConstant::CALLBACK_TYPE_ASYNC,
|
||||
'request_data' => array_merge($request->get(), $request->post()),
|
||||
'verify_status' => NotifyConstant::VERIFY_STATUS_FAILED,
|
||||
'process_status' => NotifyConstant::PROCESS_STATUS_FAILED,
|
||||
'process_result' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => 'PLUGIN_NOTIFY_ERROR',
|
||||
],
|
||||
]);
|
||||
|
||||
return $plugin->notifyFail();
|
||||
}
|
||||
}
|
||||
}
|
||||
134
app/service/payment/order/PayOrderChannelDispatchService.php
Normal file
134
app/service/payment/order/PayOrderChannelDispatchService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\PaymentException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\payment\runtime\PaymentPluginManager;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 支付渠道单据拉起服务。
|
||||
*
|
||||
* 负责调用第三方插件、写回渠道订单号,并在失败时推进支付失败状态。
|
||||
*/
|
||||
class PayOrderChannelDispatchService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PaymentPluginManager $paymentPluginManager,
|
||||
protected PaymentTypeRepository $paymentTypeRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected PayOrderLifecycleService $payOrderLifecycleService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起第三方支付单并回写渠道响应。
|
||||
*
|
||||
* @return array{pay_order:PayOrder,payment_result:array,pay_params:array}
|
||||
*/
|
||||
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,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'trace_no' => (string) $payOrder->trace_no,
|
||||
'channel_request_no' => (string) $payOrder->channel_request_no,
|
||||
'merchant_id' => (int) $payOrder->merchant_id,
|
||||
'merchant_no' => (string) ($extJson['merchant_no'] ?? ''),
|
||||
'pay_type_id' => (int) $payOrder->pay_type_id,
|
||||
'pay_type_code' => (string) ($paymentType->code ?? ''),
|
||||
'amount' => (int) $payOrder->pay_amount,
|
||||
'subject' => (string) ($bizOrder->subject ?? ''),
|
||||
'body' => (string) ($bizOrder->body ?? ''),
|
||||
'callback_url' => $callbackUrl,
|
||||
'return_url' => (string) ($extJson['return_url'] ?? ''),
|
||||
'_env' => (string) (($extJson['device'] ?? '') ?: 'pc'),
|
||||
'extra' => $extJson,
|
||||
]);
|
||||
|
||||
$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]);
|
||||
}
|
||||
|
||||
$latest->channel_order_no = (string) ($channelResult['chan_order_no'] ?? $latest->channel_order_no ?? '');
|
||||
$latest->channel_trade_no = (string) ($channelResult['chan_trade_no'] ?? $latest->channel_trade_no ?? '');
|
||||
$latest->ext_json = array_merge((array) $latest->ext_json, [
|
||||
'pay_params_type' => (string) (($channelResult['pay_params']['type'] ?? '') ?: ''),
|
||||
'pay_product' => (string) ($channelResult['pay_product'] ?? ''),
|
||||
'pay_action' => (string) ($channelResult['pay_action'] ?? ''),
|
||||
'pay_params_snapshot' => $this->normalizePayParamsSnapshot($channelResult['pay_params'] ?? []),
|
||||
]);
|
||||
$latest->save();
|
||||
|
||||
return $latest->refresh();
|
||||
});
|
||||
} catch (PaymentException $e) {
|
||||
$this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [
|
||||
'channel_error_msg' => $e->getMessage(),
|
||||
'channel_error_code' => (string) $e->getCode(),
|
||||
'ext_json' => [
|
||||
'plugin_code' => (string) $payOrder->plugin_code,
|
||||
],
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->payOrderLifecycleService->markPayFailed((string) $payOrder->pay_no, [
|
||||
'channel_error_msg' => $e->getMessage(),
|
||||
'channel_error_code' => 'PLUGIN_CREATE_ORDER_ERROR',
|
||||
'ext_json' => [
|
||||
'plugin_code' => (string) $payOrder->plugin_code,
|
||||
],
|
||||
]);
|
||||
|
||||
throw new PaymentException('创建第三方支付订单失败:' . $e->getMessage(), 40215);
|
||||
}
|
||||
|
||||
return [
|
||||
'pay_order' => $payOrder,
|
||||
'payment_result' => $channelResult,
|
||||
'pay_params' => $channelResult['pay_params'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化支付参数快照,便于后续页面渲染和排障。
|
||||
*/
|
||||
private function normalizePayParamsSnapshot(mixed $payParams): array
|
||||
{
|
||||
if (is_array($payParams)) {
|
||||
return $payParams;
|
||||
}
|
||||
|
||||
if (is_object($payParams) && method_exists($payParams, 'toArray')) {
|
||||
$data = $payParams->toArray();
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
140
app/service/payment/order/PayOrderFeeService.php
Normal file
140
app/service/payment/order/PayOrderFeeService.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
|
||||
/**
|
||||
* 支付单手续费处理服务。
|
||||
*
|
||||
* 负责支付成功时的手续费结算,以及终态时的冻结手续费释放。
|
||||
*/
|
||||
class PayOrderFeeService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected MerchantAccountService $merchantAccountService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理支付成功后的手续费结算。
|
||||
*/
|
||||
public function settleSuccessFee(PayOrder $payOrder, int $actualFee, string $payNo, string $traceNo): void
|
||||
{
|
||||
if ((int) $payOrder->channel_type !== RouteConstant::CHANNEL_MODE_SELF) {
|
||||
return;
|
||||
}
|
||||
|
||||
$estimated = (int) $payOrder->fee_estimated_amount;
|
||||
if ($actualFee > $estimated) {
|
||||
if ($estimated > 0) {
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$estimated,
|
||||
$payNo,
|
||||
'PAY_DEDUCT:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => '自有通道手续费扣减',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
|
||||
$diff = $actualFee - $estimated;
|
||||
if ($diff > 0) {
|
||||
$this->merchantAccountService->debitAvailableAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$diff,
|
||||
$payNo,
|
||||
'PAY_DEDUCT_DIFF:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => '自有通道手续费差额扣减',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($actualFee < $estimated) {
|
||||
if ($actualFee > 0) {
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$actualFee,
|
||||
$payNo,
|
||||
'PAY_DEDUCT:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => '自有通道手续费扣减',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
|
||||
$diff = $estimated - $actualFee;
|
||||
if ($diff > 0) {
|
||||
$this->merchantAccountService->releaseFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$diff,
|
||||
$payNo,
|
||||
'PAY_RELEASE:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => '自有通道手续费释放差额',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ($actualFee > 0) {
|
||||
$this->merchantAccountService->deductFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
$actualFee,
|
||||
$payNo,
|
||||
'PAY_DEDUCT:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => '自有通道手续费扣减',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放支付单已冻结的手续费。
|
||||
*/
|
||||
public function releaseFrozenFeeIfNeeded(PayOrder $payOrder, string $payNo, string $traceNo, string $remark): void
|
||||
{
|
||||
if ((int) $payOrder->channel_type !== RouteConstant::CHANNEL_MODE_SELF) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $payOrder->fee_status !== TradeConstant::FEE_STATUS_FROZEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->merchantAccountService->releaseFrozenAmountInCurrentTransaction(
|
||||
(int) $payOrder->merchant_id,
|
||||
(int) $payOrder->fee_estimated_amount,
|
||||
$payNo,
|
||||
'PAY_RELEASE:' . $payNo,
|
||||
[
|
||||
'pay_no' => $payNo,
|
||||
'remark' => $remark,
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
}
|
||||
322
app/service/payment/order/PayOrderLifecycleService.php
Normal file
322
app/service/payment/order/PayOrderLifecycleService.php
Normal file
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
|
||||
/**
|
||||
* 支付单生命周期服务。
|
||||
*
|
||||
* 负责支付单状态推进、关闭、超时和手续费处理。
|
||||
*/
|
||||
class PayOrderLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderFeeService $payOrderFeeService,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付成功。
|
||||
*
|
||||
* 用于支付回调或主动查单成功后的状态推进;自有通道在这里完成手续费正式扣减。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return PayOrder
|
||||
*/
|
||||
public function markPaySuccess(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($payNo, $input) {
|
||||
return $this->markPaySuccessInCurrentTransaction($payNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付成功。
|
||||
*
|
||||
* 该方法只处理状态推进和资金动作,不负责外部通道请求。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return PayOrder
|
||||
*/
|
||||
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $payOrder->status;
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('支付单状态不允许当前操作', [
|
||||
'pay_no' => $payNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$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;
|
||||
$payOrder->settlement_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT
|
||||
? TradeConstant::SETTLEMENT_STATUS_PENDING
|
||||
: TradeConstant::SETTLEMENT_STATUS_NONE;
|
||||
$payOrder->callback_status = NotifyConstant::PROCESS_STATUS_SUCCESS;
|
||||
$payOrder->channel_trade_no = (string) ($input['channel_trade_no'] ?? $payOrder->channel_trade_no ?? '');
|
||||
$payOrder->channel_order_no = (string) ($input['channel_order_no'] ?? $payOrder->channel_order_no ?? '');
|
||||
$payOrder->channel_error_code = '';
|
||||
$payOrder->channel_error_msg = '';
|
||||
$payOrder->callback_times = (int) $payOrder->callback_times + 1;
|
||||
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
$this->syncBizOrderAfterSuccess($payOrder, $traceNo);
|
||||
|
||||
return $payOrder->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付失败。
|
||||
*/
|
||||
public function markPayFailed(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($payNo, $input) {
|
||||
return $this->markPayFailedInCurrentTransaction($payNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付失败。
|
||||
*/
|
||||
public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $payOrder->status;
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_FAILED) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('支付单状态不允许当前操作', [
|
||||
'pay_no' => $payNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付失败释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_FAILED;
|
||||
$payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF
|
||||
? TradeConstant::FEE_STATUS_RELEASED
|
||||
: TradeConstant::FEE_STATUS_NONE;
|
||||
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE;
|
||||
$payOrder->callback_status = NotifyConstant::PROCESS_STATUS_FAILED;
|
||||
$payOrder->channel_error_code = (string) ($input['channel_error_code'] ?? $payOrder->channel_error_code ?? '');
|
||||
$payOrder->channel_error_msg = (string) ($input['channel_error_msg'] ?? $payOrder->channel_error_msg ?? '支付失败');
|
||||
$payOrder->failed_at = $input['failed_at'] ?? $this->now();
|
||||
$payOrder->callback_times = (int) $payOrder->callback_times + 1;
|
||||
$payOrder->ext_json = array_merge((array) $payOrder->ext_json, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_FAILED, 'failed_at');
|
||||
|
||||
return $payOrder->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭支付单。
|
||||
*/
|
||||
public function closePayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($payNo, $input) {
|
||||
return $this->closePayOrderInCurrentTransaction($payNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中关闭支付单。
|
||||
*/
|
||||
public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $payOrder->status;
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_CLOSED) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('支付单状态不允许当前操作', [
|
||||
'pay_no' => $payNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付关闭释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_CLOSED;
|
||||
$payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF
|
||||
? TradeConstant::FEE_STATUS_RELEASED
|
||||
: TradeConstant::FEE_STATUS_NONE;
|
||||
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE;
|
||||
$payOrder->closed_at = $input['closed_at'] ?? $this->now();
|
||||
$extJson = (array) $payOrder->ext_json;
|
||||
$reason = trim((string) ($input['reason'] ?? ''));
|
||||
if ($reason !== '') {
|
||||
$extJson['close_reason'] = $reason;
|
||||
}
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付超时。
|
||||
*/
|
||||
public function timeoutPayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($payNo, $input) {
|
||||
return $this->timeoutPayOrderInCurrentTransaction($payNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付超时。
|
||||
*/
|
||||
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $payOrder->status;
|
||||
if ($currentStatus === TradeConstant::ORDER_STATUS_TIMEOUT) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isOrderTerminalStatus($currentStatus)) {
|
||||
return $payOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::orderMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('支付单状态不允许当前操作', [
|
||||
'pay_no' => $payNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
$this->payOrderFeeService->releaseFrozenFeeIfNeeded($payOrder, $payNo, $traceNo, '支付超时释放手续费');
|
||||
|
||||
$payOrder->status = TradeConstant::ORDER_STATUS_TIMEOUT;
|
||||
$payOrder->fee_status = (int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_SELF
|
||||
? TradeConstant::FEE_STATUS_RELEASED
|
||||
: TradeConstant::FEE_STATUS_NONE;
|
||||
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_NONE;
|
||||
$payOrder->timeout_at = $input['timeout_at'] ?? $this->now();
|
||||
$extJson = (array) $payOrder->ext_json;
|
||||
$reason = trim((string) ($input['reason'] ?? ''));
|
||||
if ($reason !== '') {
|
||||
$extJson['timeout_reason'] = $reason;
|
||||
}
|
||||
$payOrder->ext_json = array_merge($extJson, $input['ext_json'] ?? []);
|
||||
$payOrder->save();
|
||||
|
||||
$this->syncBizOrderAfterTerminalStatus($payOrder, $payNo, $traceNo, TradeConstant::ORDER_STATUS_TIMEOUT, 'timeout_at');
|
||||
|
||||
return $payOrder->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步支付成功后的业务单状态。
|
||||
*/
|
||||
private function syncBizOrderAfterSuccess(PayOrder $payOrder, string $traceNo): void
|
||||
{
|
||||
$bizOrder = $this->bizOrderRepository->findForUpdateByBizNo((string) $payOrder->biz_no);
|
||||
if (!$bizOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bizOrder->status = TradeConstant::ORDER_STATUS_SUCCESS;
|
||||
$bizOrder->paid_amount = (int) $bizOrder->paid_amount + (int) $payOrder->pay_amount;
|
||||
$bizOrder->active_pay_no = null;
|
||||
$bizOrder->paid_at = $payOrder->paid_at;
|
||||
if (empty($bizOrder->trace_no)) {
|
||||
$bizOrder->trace_no = $traceNo;
|
||||
}
|
||||
$bizOrder->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步支付终态后的业务单状态。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
$bizOrder->status = $status;
|
||||
$bizOrder->active_pay_no = null;
|
||||
$bizOrder->{$timestampField} = $payOrder->{$timestampField};
|
||||
if (empty($bizOrder->trace_no)) {
|
||||
$bizOrder->trace_no = $traceNo;
|
||||
}
|
||||
$bizOrder->save();
|
||||
}
|
||||
|
||||
}
|
||||
253
app/service/payment/order/PayOrderQueryService.php
Normal file
253
app/service/payment/order/PayOrderQueryService.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentType;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
|
||||
/**
|
||||
* 支付单查询服务。
|
||||
*
|
||||
* 只负责支付单列表类查询与展示格式化,不承载状态推进逻辑。
|
||||
*/
|
||||
class PayOrderQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository,
|
||||
protected PayOrderReportService $payOrderReportService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付订单列表。
|
||||
*
|
||||
* 后台和商户后台共用同一套查询逻辑,商户侧会额外限制当前商户 ID。
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
|
||||
{
|
||||
$query = $this->payOrderRepository->query()
|
||||
->from('ma_pay_order as po')
|
||||
->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'po.biz_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'po.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'po.merchant_group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'po.channel_id')
|
||||
->leftJoin('ma_payment_type as t', 't.id', '=', 'po.pay_type_id')
|
||||
->select([
|
||||
'po.id',
|
||||
'po.pay_no',
|
||||
'po.biz_no',
|
||||
'po.trace_no',
|
||||
'po.merchant_id',
|
||||
'po.merchant_group_id',
|
||||
'po.poll_group_id',
|
||||
'po.attempt_no',
|
||||
'po.channel_id',
|
||||
'po.pay_type_id',
|
||||
'po.plugin_code',
|
||||
'po.channel_type',
|
||||
'po.channel_mode',
|
||||
'po.pay_amount',
|
||||
'po.fee_rate_bp_snapshot',
|
||||
'po.split_rate_bp_snapshot',
|
||||
'po.fee_estimated_amount',
|
||||
'po.fee_actual_amount',
|
||||
'po.status',
|
||||
'po.fee_status',
|
||||
'po.settlement_status',
|
||||
'po.channel_request_no',
|
||||
'po.channel_order_no',
|
||||
'po.channel_trade_no',
|
||||
'po.channel_error_code',
|
||||
'po.channel_error_msg',
|
||||
'po.request_at',
|
||||
'po.paid_at',
|
||||
'po.expire_at',
|
||||
'po.closed_at',
|
||||
'po.failed_at',
|
||||
'po.timeout_at',
|
||||
'po.callback_status',
|
||||
'po.callback_times',
|
||||
'po.ext_json',
|
||||
'po.created_at',
|
||||
'po.updated_at',
|
||||
'bo.merchant_order_no',
|
||||
'bo.subject',
|
||||
'bo.body',
|
||||
'bo.order_amount as biz_order_amount',
|
||||
'bo.paid_amount as biz_paid_amount',
|
||||
'bo.refund_amount as biz_refund_amount',
|
||||
'bo.status as biz_status',
|
||||
'bo.active_pay_no',
|
||||
'bo.attempt_count as biz_attempt_count',
|
||||
'bo.expire_at as biz_expire_at',
|
||||
'bo.paid_at as biz_paid_at',
|
||||
'bo.closed_at as biz_closed_at',
|
||||
'bo.failed_at as biz_failed_at',
|
||||
'bo.timeout_at as biz_timeout_at',
|
||||
'bo.ext_json as biz_ext_json',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'g.group_name as merchant_group_name',
|
||||
'c.name as channel_name',
|
||||
'c.plugin_code as channel_plugin_code',
|
||||
't.code as pay_type_code',
|
||||
't.name as pay_type_name',
|
||||
't.icon as pay_type_icon',
|
||||
]);
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0) {
|
||||
$query->where('po.merchant_id', $merchantId);
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('po.pay_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('po.biz_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('po.trace_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('po.channel_request_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('po.channel_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('po.channel_trade_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('t.name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) {
|
||||
$query->where('po.merchant_id', $merchantFilter);
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('po.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('po.status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') {
|
||||
$query->where('po.channel_mode', (int) $filters['channel_mode']);
|
||||
}
|
||||
|
||||
if (array_key_exists('callback_status', $filters) && $filters['callback_status'] !== '') {
|
||||
$query->where('po.callback_status', (int) $filters['callback_status']);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('po.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$list = [];
|
||||
foreach ($paginator->items() as $item) {
|
||||
$list[] = $this->payOrderReportService->formatPayOrderRow((array) $item);
|
||||
}
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
'pay_types' => $this->payTypeOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付订单详情。
|
||||
*
|
||||
* @param string $payNo 支付单号
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{pay_order:mixed,biz_order:mixed,timeline:array,account_ledgers:mixed}
|
||||
*/
|
||||
public function detail(string $payNo, ?int $merchantId = null): array
|
||||
{
|
||||
$payNo = trim($payNo);
|
||||
if ($payNo === '') {
|
||||
throw new ValidationException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
$payOrder = $this->payOrderRepository->findByPayNo($payNo);
|
||||
if (!$payOrder) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0 && (int) $payOrder->merchant_id !== $merchantId) {
|
||||
throw new ResourceNotFoundException('支付单不存在', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo((string) $payOrder->biz_no);
|
||||
$timeline = $this->payOrderReportService->buildPayTimeline($payOrder);
|
||||
$accountLedgers = $this->loadPayLedgers($payOrder);
|
||||
|
||||
return [
|
||||
'pay_order' => $payOrder,
|
||||
'biz_order' => $bizOrder,
|
||||
'timeline' => $timeline,
|
||||
'account_ledgers' => $accountLedgers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载支付相关资金流水。
|
||||
*/
|
||||
private function loadPayLedgers(PayOrder $payOrder)
|
||||
{
|
||||
$traceNo = trim((string) ($payOrder->trace_no ?: $payOrder->biz_no));
|
||||
$ledgers = $traceNo !== ''
|
||||
? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo)
|
||||
: collect();
|
||||
|
||||
if ($ledgers->isEmpty()) {
|
||||
$ledgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $payOrder->pay_no);
|
||||
}
|
||||
|
||||
return $ledgers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回启用的支付方式选项,供列表筛选使用。
|
||||
*/
|
||||
private function payTypeOptions(): array
|
||||
{
|
||||
return $this->paymentTypeRepository->query()
|
||||
->where('status', CommonConstant::STATUS_ENABLED)
|
||||
->orderBy('sort_no')
|
||||
->orderByDesc('id')
|
||||
->get(['id', 'name'])
|
||||
->map(function (PaymentType $payType): array {
|
||||
return [
|
||||
'label' => (string) $payType->name,
|
||||
'value' => (int) $payType->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
92
app/service/payment/order/PayOrderReportService.php
Normal file
92
app/service/payment/order/PayOrderReportService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\model\payment\PayOrder;
|
||||
|
||||
/**
|
||||
* 支付单结果组装服务。
|
||||
*
|
||||
* 负责支付单列表和详情页的展示字段格式化。
|
||||
*/
|
||||
class PayOrderReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 格式化支付订单行,统一输出前端需要的中文字段。
|
||||
*/
|
||||
public function formatPayOrderRow(array $row): array
|
||||
{
|
||||
$row['merchant_group_name'] = trim((string) ($row['merchant_group_name'] ?? '')) ?: '未分组';
|
||||
$row['merchant_name'] = trim((string) ($row['merchant_name'] ?? '')) ?: '未知商户';
|
||||
$row['merchant_short_name'] = trim((string) ($row['merchant_short_name'] ?? ''));
|
||||
$row['pay_type_name'] = trim((string) ($row['pay_type_name'] ?? '')) ?: '未知方式';
|
||||
$row['channel_name'] = trim((string) ($row['channel_name'] ?? '')) ?: '未知通道';
|
||||
$row['biz_status_text'] = $this->textFromMap((int) ($row['biz_status'] ?? -1), TradeConstant::orderStatusMap());
|
||||
|
||||
$row['status_text'] = $this->textFromMap((int) ($row['status'] ?? -1), TradeConstant::orderStatusMap());
|
||||
$row['fee_status_text'] = $this->textFromMap((int) ($row['fee_status'] ?? -1), TradeConstant::feeStatusMap());
|
||||
$row['settlement_status_text'] = $this->textFromMap((int) ($row['settlement_status'] ?? -1), TradeConstant::settlementStatusMap());
|
||||
$row['callback_status_text'] = $this->textFromMap((int) ($row['callback_status'] ?? -1), NotifyConstant::processStatusMap());
|
||||
$row['channel_type_text'] = $this->textFromMap((int) ($row['channel_type'] ?? -1), RouteConstant::channelTypeMap());
|
||||
$row['channel_mode_text'] = $this->textFromMap((int) ($row['channel_mode'] ?? -1), RouteConstant::channelModeMap());
|
||||
|
||||
$row['pay_amount_text'] = $this->formatAmount((int) ($row['pay_amount'] ?? 0));
|
||||
$row['fee_estimated_amount_text'] = $this->formatAmount((int) ($row['fee_estimated_amount'] ?? 0));
|
||||
$row['fee_actual_amount_text'] = $this->formatAmount((int) ($row['fee_actual_amount'] ?? 0));
|
||||
$row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0));
|
||||
$row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0));
|
||||
$row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0));
|
||||
|
||||
$row['request_at_text'] = $this->formatDateTime($row['request_at'] ?? null, '—');
|
||||
$row['paid_at_text'] = $this->formatDateTime($row['paid_at'] ?? null, '—');
|
||||
$row['expire_at_text'] = $this->formatDateTime($row['expire_at'] ?? null, '—');
|
||||
$row['closed_at_text'] = $this->formatDateTime($row['closed_at'] ?? null, '—');
|
||||
$row['failed_at_text'] = $this->formatDateTime($row['failed_at'] ?? null, '—');
|
||||
$row['timeout_at_text'] = $this->formatDateTime($row['timeout_at'] ?? null, '—');
|
||||
$row['biz_expire_at_text'] = $this->formatDateTime($row['biz_expire_at'] ?? null, '—');
|
||||
$row['biz_paid_at_text'] = $this->formatDateTime($row['biz_paid_at'] ?? null, '—');
|
||||
$row['biz_closed_at_text'] = $this->formatDateTime($row['biz_closed_at'] ?? null, '—');
|
||||
$row['biz_failed_at_text'] = $this->formatDateTime($row['biz_failed_at'] ?? null, '—');
|
||||
$row['biz_timeout_at_text'] = $this->formatDateTime($row['biz_timeout_at'] ?? null, '—');
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造支付时间线。
|
||||
*/
|
||||
public function buildPayTimeline(PayOrder $payOrder): array
|
||||
{
|
||||
$extJson = (array) ($payOrder->ext_json ?? []);
|
||||
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'status' => 'created',
|
||||
'at' => $this->formatDateTime($payOrder->request_at ?? $payOrder->created_at ?? null, '—'),
|
||||
],
|
||||
$payOrder->paid_at ? [
|
||||
'status' => 'success',
|
||||
'at' => $this->formatDateTime($payOrder->paid_at, '—'),
|
||||
] : null,
|
||||
$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 ? [
|
||||
'status' => 'timeout',
|
||||
'at' => $this->formatDateTime($payOrder->timeout_at, '—'),
|
||||
'reason' => (string) ($extJson['timeout_reason'] ?? ''),
|
||||
] : null,
|
||||
]));
|
||||
}
|
||||
}
|
||||
131
app/service/payment/order/PayOrderService.php
Normal file
131
app/service/payment/order/PayOrderService.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\PayOrder;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付单门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给查询、发起、生命周期和回调四个子服务。
|
||||
*/
|
||||
class PayOrderService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderQueryService $queryService,
|
||||
protected PayOrderAttemptService $attemptService,
|
||||
protected PayOrderLifecycleService $lifecycleService,
|
||||
protected PayOrderCallbackService $callbackService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询支付订单列表。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize, $merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询支付订单详情。
|
||||
*/
|
||||
public function detail(string $payNo, ?int $merchantId = null): array
|
||||
{
|
||||
return $this->queryService->detail($payNo, $merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预创建支付尝试。
|
||||
*/
|
||||
public function preparePayAttempt(array $input): array
|
||||
{
|
||||
return $this->attemptService->preparePayAttempt($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付成功。
|
||||
*/
|
||||
public function markPaySuccess(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->markPaySuccess($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付成功。
|
||||
*/
|
||||
public function markPaySuccessInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->markPaySuccessInCurrentTransaction($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付失败。
|
||||
*/
|
||||
public function markPayFailed(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->markPayFailed($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付失败。
|
||||
*/
|
||||
public function markPayFailedInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->markPayFailedInCurrentTransaction($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭支付单。
|
||||
*/
|
||||
public function closePayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->closePayOrder($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中关闭支付单。
|
||||
*/
|
||||
public function closePayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->closePayOrderInCurrentTransaction($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付超时。
|
||||
*/
|
||||
public function timeoutPayOrder(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->timeoutPayOrder($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记支付超时。
|
||||
*/
|
||||
public function timeoutPayOrderInCurrentTransaction(string $payNo, array $input = []): PayOrder
|
||||
{
|
||||
return $this->lifecycleService->timeoutPayOrderInCurrentTransaction($payNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理渠道回调。
|
||||
*/
|
||||
public function handleChannelCallback(array $input): PayOrder
|
||||
{
|
||||
return $this->callbackService->handleChannelCallback($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按支付单号处理真实第三方回调。
|
||||
*/
|
||||
public function handlePluginCallback(string $payNo, Request $request): string|Response
|
||||
{
|
||||
return $this->callbackService->handlePluginCallback($payNo, $request);
|
||||
}
|
||||
}
|
||||
116
app/service/payment/order/RefundCreationService.php
Normal file
116
app/service/payment/order/RefundCreationService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ConflictException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\RefundOrder;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
|
||||
/**
|
||||
* 退款单创建服务。
|
||||
*
|
||||
* 负责退款单创建和幂等校验,不承载状态推进逻辑。
|
||||
*/
|
||||
class RefundCreationService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建退款单。
|
||||
*
|
||||
* 当前仅支持整单全额退款,且同一支付单只能创建一张退款单。
|
||||
*
|
||||
* @param array $input 退款请求参数
|
||||
* @return RefundOrder
|
||||
*/
|
||||
public function createRefund(array $input): RefundOrder
|
||||
{
|
||||
$payNo = trim((string) ($input['pay_no'] ?? ''));
|
||||
if ($payNo === '') {
|
||||
throw new ValidationException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
$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,
|
||||
'status' => (int) $payOrder->status,
|
||||
]);
|
||||
}
|
||||
|
||||
$refundAmount = array_key_exists('refund_amount', $input)
|
||||
? (int) $input['refund_amount']
|
||||
: (int) $payOrder->pay_amount;
|
||||
|
||||
if ($refundAmount !== (int) $payOrder->pay_amount) {
|
||||
throw new BusinessStateException('当前仅支持整单全额退款');
|
||||
}
|
||||
|
||||
$merchantRefundNo = trim((string) ($input['merchant_refund_no'] ?? ''));
|
||||
if ($merchantRefundNo !== '') {
|
||||
$existingByMerchantNo = $this->refundOrderRepository->findByMerchantRefundNo((int) $payOrder->merchant_id, $merchantRefundNo);
|
||||
if ($existingByMerchantNo) {
|
||||
if ((string) $existingByMerchantNo->pay_no !== $payNo || (int) $existingByMerchantNo->refund_amount !== $refundAmount) {
|
||||
throw new ConflictException('幂等冲突', [
|
||||
'refund_no' => (string) $existingByMerchantNo->refund_no,
|
||||
'pay_no' => (string) $existingByMerchantNo->pay_no,
|
||||
'merchant_refund_no' => $merchantRefundNo,
|
||||
]);
|
||||
}
|
||||
|
||||
return $existingByMerchantNo;
|
||||
}
|
||||
}
|
||||
|
||||
if ($existingByPayNo = $this->refundOrderRepository->findByPayNo($payNo)) {
|
||||
if ($merchantRefundNo !== '' && (string) $existingByPayNo->merchant_refund_no !== $merchantRefundNo) {
|
||||
throw new ConflictException('重复退款', ['pay_no' => $payNo]);
|
||||
}
|
||||
|
||||
return $existingByPayNo;
|
||||
}
|
||||
|
||||
$traceNo = (string) ($payOrder->trace_no ?: $payOrder->biz_no);
|
||||
|
||||
return $this->refundOrderRepository->create([
|
||||
'refund_no' => $this->generateNo('RFD'),
|
||||
'merchant_id' => (int) $payOrder->merchant_id,
|
||||
'merchant_group_id' => (int) $payOrder->merchant_group_id,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'trace_no' => $traceNo,
|
||||
'pay_no' => $payNo,
|
||||
'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,
|
||||
'status' => TradeConstant::REFUND_STATUS_CREATED,
|
||||
'channel_request_no' => $this->generateNo('RQR'),
|
||||
'reason' => (string) ($input['reason'] ?? ''),
|
||||
'request_at' => $this->now(),
|
||||
'processing_at' => null,
|
||||
'retry_count' => 0,
|
||||
'last_error' => '',
|
||||
'ext_json' => array_merge($input['ext_json'] ?? [], [
|
||||
'trace_no' => $traceNo,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
251
app/service/payment/order/RefundLifecycleService.php
Normal file
251
app/service/payment/order/RefundLifecycleService.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\payment\RefundOrder;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
|
||||
/**
|
||||
* 退款单生命周期服务。
|
||||
*
|
||||
* 负责退款单创建、处理中、成功、失败和重试等状态推进。
|
||||
*/
|
||||
class RefundLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected MerchantAccountService $merchantAccountService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记退款处理中。
|
||||
*/
|
||||
public function markRefundProcessing(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($refundNo, $input) {
|
||||
return $this->markRefundProcessingInCurrentTransaction($refundNo, $input, false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款重试。
|
||||
*/
|
||||
public function retryRefund(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($refundNo, $input) {
|
||||
return $this->markRefundProcessingInCurrentTransaction($refundNo, $input, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款处理中或重试。
|
||||
*/
|
||||
public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder
|
||||
{
|
||||
$refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo);
|
||||
if (!$refundOrder) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $refundOrder->status;
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_PROCESSING) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isRefundTerminalStatus($currentStatus)) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if ($currentStatus !== TradeConstant::REFUND_STATUS_CREATED && $currentStatus !== TradeConstant::REFUND_STATUS_FAILED) {
|
||||
throw new BusinessStateException('退款单状态不允许当前操作', [
|
||||
'refund_no' => $refundNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED && !$isRetry) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if ($isRetry && $currentStatus !== TradeConstant::REFUND_STATUS_FAILED) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
$refundOrder->status = TradeConstant::REFUND_STATUS_PROCESSING;
|
||||
$refundOrder->processing_at = $input['processing_at'] ?? $this->now();
|
||||
if (empty($refundOrder->request_at)) {
|
||||
$refundOrder->request_at = $input['request_at'] ?? $refundOrder->processing_at;
|
||||
}
|
||||
$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');
|
||||
}
|
||||
|
||||
$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'] ?? []);
|
||||
$refundOrder->save();
|
||||
|
||||
return $refundOrder->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功。
|
||||
*
|
||||
* 成功后会推进退款单状态,并在平台代收场景下做余额冲减或结算逆向处理。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return RefundOrder
|
||||
*/
|
||||
public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($refundNo, $input) {
|
||||
return $this->markRefundSuccessInCurrentTransaction($refundNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款成功。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param array $input 回调或查单入参
|
||||
* @return RefundOrder
|
||||
*/
|
||||
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
$refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo);
|
||||
if (!$refundOrder) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $refundOrder->status;
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_SUCCESS) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isRefundTerminalStatus($currentStatus)) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
$payOrder = $this->payOrderRepository->findForUpdateByPayNo((string) $refundOrder->pay_no);
|
||||
if (!$payOrder || (int) $payOrder->status !== TradeConstant::ORDER_STATUS_SUCCESS) {
|
||||
throw new BusinessStateException('原支付单状态不允许退款', [
|
||||
'refund_no' => $refundNo,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
]);
|
||||
}
|
||||
|
||||
$traceNo = (string) ($refundOrder->trace_no ?: $refundOrder->biz_no);
|
||||
if ((int) $payOrder->channel_type === RouteConstant::CHANNEL_MODE_COLLECT) {
|
||||
$reverseAmount = max(0, (int) $payOrder->pay_amount - (int) $payOrder->fee_actual_amount);
|
||||
if ((int) $payOrder->settlement_status === TradeConstant::SETTLEMENT_STATUS_SETTLED && $reverseAmount > 0) {
|
||||
$this->merchantAccountService->debitAvailableAmountInCurrentTransaction(
|
||||
(int) $refundOrder->merchant_id,
|
||||
$reverseAmount,
|
||||
(string) $refundOrder->refund_no,
|
||||
'REFUND_REVERSE:' . (string) $refundOrder->refund_no,
|
||||
[
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
'remark' => '平台代收退款冲减',
|
||||
],
|
||||
$traceNo
|
||||
);
|
||||
}
|
||||
|
||||
$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 ?? '');
|
||||
$refundOrder->last_error = '';
|
||||
$refundOrder->ext_json = array_merge((array) $refundOrder->ext_json, $input['ext_json'] ?? []);
|
||||
$refundOrder->save();
|
||||
|
||||
$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;
|
||||
}
|
||||
$bizOrder->save();
|
||||
}
|
||||
|
||||
return $refundOrder->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款失败。
|
||||
*/
|
||||
public function markRefundFailed(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($refundNo, $input) {
|
||||
return $this->markRefundFailedInCurrentTransaction($refundNo, $input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款失败。
|
||||
*/
|
||||
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
$refundOrder = $this->refundOrderRepository->findForUpdateByRefundNo($refundNo);
|
||||
if (!$refundOrder) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $refundOrder->status;
|
||||
if ($currentStatus === TradeConstant::REFUND_STATUS_FAILED) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isRefundTerminalStatus($currentStatus)) {
|
||||
return $refundOrder;
|
||||
}
|
||||
|
||||
if ($currentStatus !== TradeConstant::REFUND_STATUS_CREATED && $currentStatus !== TradeConstant::REFUND_STATUS_PROCESSING) {
|
||||
throw new BusinessStateException('退款单状态不允许当前操作', [
|
||||
'refund_no' => $refundNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$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 ?? '');
|
||||
$refundOrder->last_error = (string) ($input['last_error'] ?? $refundOrder->last_error ?? '');
|
||||
$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'] ?? []);
|
||||
$refundOrder->save();
|
||||
|
||||
return $refundOrder->refresh();
|
||||
}
|
||||
}
|
||||
285
app/service/payment/order/RefundQueryService.php
Normal file
285
app/service/payment/order/RefundQueryService.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
|
||||
/**
|
||||
* 退款单查询服务。
|
||||
*
|
||||
* 只负责退款列表、详情和数据查询,不承载退款状态推进逻辑。
|
||||
*/
|
||||
class RefundQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository,
|
||||
protected RefundReportService $refundReportService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询退款订单列表。
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
|
||||
{
|
||||
$query = $this->buildRefundOrderQuery($merchantId);
|
||||
|
||||
$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 . '%')
|
||||
->orWhere('ro.biz_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.trace_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.merchant_refund_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.channel_request_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.channel_refund_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.reason', 'like', '%' . $keyword . '%')
|
||||
->orWhere('ro.last_error', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.merchant_order_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('bo.subject', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_short_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('t.name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if (($merchantFilter = (int) ($filters['merchant_id'] ?? 0)) > 0) {
|
||||
$query->where('ro.merchant_id', $merchantFilter);
|
||||
}
|
||||
|
||||
if (($payTypeId = (int) ($filters['pay_type_id'] ?? 0)) > 0) {
|
||||
$query->where('po.pay_type_id', $payTypeId);
|
||||
}
|
||||
|
||||
if (array_key_exists('status', $filters) && $filters['status'] !== '') {
|
||||
$query->where('ro.status', (int) $filters['status']);
|
||||
}
|
||||
|
||||
if (array_key_exists('channel_mode', $filters) && $filters['channel_mode'] !== '') {
|
||||
$query->where('po.channel_mode', (int) $filters['channel_mode']);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('ro.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$list = [];
|
||||
foreach ($paginator->items() as $item) {
|
||||
$list[] = $this->refundReportService->formatRefundOrderRow((array) $item);
|
||||
}
|
||||
|
||||
return [
|
||||
'list' => $list,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
'pay_types' => $this->payTypeOptions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询退款订单详情。
|
||||
*
|
||||
* @param string $refundNo 退款单号
|
||||
* @param int|null $merchantId 商户侧强制限定的商户 ID
|
||||
* @return array{refund_order:array,timeline:array,account_ledgers:array}
|
||||
*/
|
||||
public function detail(string $refundNo, ?int $merchantId = null): array
|
||||
{
|
||||
$refundNo = trim($refundNo);
|
||||
if ($refundNo === '') {
|
||||
throw new ValidationException('refund_no 不能为空');
|
||||
}
|
||||
|
||||
$query = $this->buildRefundOrderQuery($merchantId);
|
||||
$row = $query->where('ro.refund_no', $refundNo)->first();
|
||||
if (!$row) {
|
||||
throw new ResourceNotFoundException('退款单不存在', ['refund_no' => $refundNo]);
|
||||
}
|
||||
|
||||
$refundOrder = $this->refundReportService->formatRefundOrderRow((array) $row);
|
||||
$timeline = $this->refundReportService->buildRefundTimeline($row);
|
||||
$accountLedgers = $this->loadRefundLedgers($row);
|
||||
|
||||
return [
|
||||
'refund_order' => $refundOrder,
|
||||
'timeline' => $timeline,
|
||||
'account_ledgers' => $accountLedgers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按退款单号查询退款单,可按商户限制。
|
||||
*/
|
||||
public function findByRefundNo(string $refundNo, ?int $merchantId = null): ?\app\model\payment\RefundOrder
|
||||
{
|
||||
$refundNo = trim($refundNo);
|
||||
if ($refundNo === '') {
|
||||
throw new ValidationException('refund_no 不能为空');
|
||||
}
|
||||
|
||||
$query = $this->refundOrderRepository->query()
|
||||
->from('ma_refund_order as ro')
|
||||
->select(['ro.*'])
|
||||
->where('ro.refund_no', $refundNo);
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0) {
|
||||
$query->where('ro.merchant_id', $merchantId);
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建退款订单基础查询,列表与详情共用。
|
||||
*/
|
||||
private function buildRefundOrderQuery(?int $merchantId = null)
|
||||
{
|
||||
$query = $this->refundOrderRepository->query()
|
||||
->from('ma_refund_order as ro')
|
||||
->leftJoin('ma_pay_order as po', 'po.pay_no', '=', 'ro.pay_no')
|
||||
->leftJoin('ma_biz_order as bo', 'bo.biz_no', '=', 'ro.biz_no')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'ro.merchant_id')
|
||||
->leftJoin('ma_merchant_group as g', 'g.id', '=', 'ro.merchant_group_id')
|
||||
->leftJoin('ma_payment_channel as c', 'c.id', '=', 'ro.channel_id')
|
||||
->leftJoin('ma_payment_type as t', 't.id', '=', 'po.pay_type_id')
|
||||
->select([
|
||||
'ro.id',
|
||||
'ro.refund_no',
|
||||
'ro.merchant_id',
|
||||
'ro.merchant_group_id',
|
||||
'ro.biz_no',
|
||||
'ro.trace_no',
|
||||
'ro.pay_no',
|
||||
'ro.merchant_refund_no',
|
||||
'ro.channel_id',
|
||||
'ro.refund_amount',
|
||||
'ro.fee_reverse_amount',
|
||||
'ro.status',
|
||||
'ro.channel_request_no',
|
||||
'ro.channel_refund_no',
|
||||
'ro.reason',
|
||||
'ro.request_at',
|
||||
'ro.processing_at',
|
||||
'ro.succeeded_at',
|
||||
'ro.failed_at',
|
||||
'ro.retry_count',
|
||||
'ro.last_error',
|
||||
'ro.ext_json',
|
||||
'ro.created_at',
|
||||
'ro.updated_at',
|
||||
'po.channel_mode',
|
||||
'po.channel_type',
|
||||
'po.pay_type_id',
|
||||
'po.pay_amount as pay_order_amount',
|
||||
'po.fee_actual_amount as pay_fee_actual_amount',
|
||||
'po.status as pay_status',
|
||||
'bo.merchant_order_no',
|
||||
'bo.subject',
|
||||
'bo.body',
|
||||
'bo.status as biz_status',
|
||||
'bo.order_amount as biz_order_amount',
|
||||
'bo.paid_amount as biz_paid_amount',
|
||||
'bo.refund_amount as biz_refund_amount',
|
||||
'm.merchant_no',
|
||||
'm.merchant_name',
|
||||
'm.merchant_short_name',
|
||||
'g.group_name as merchant_group_name',
|
||||
'c.name as channel_name',
|
||||
'c.plugin_code as channel_plugin_code',
|
||||
't.code as pay_type_code',
|
||||
't.name as pay_type_name',
|
||||
't.icon as pay_type_icon',
|
||||
]);
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0) {
|
||||
$query->where('ro.merchant_id', $merchantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载退款相关资金流水。
|
||||
*/
|
||||
private function loadRefundLedgers(mixed $refundOrder): array
|
||||
{
|
||||
$traceNo = trim((string) ($refundOrder->trace_no ?? ''));
|
||||
$bizNo = trim((string) ($refundOrder->biz_no ?? ''));
|
||||
$refundNo = trim((string) ($refundOrder->refund_no ?? ''));
|
||||
|
||||
$ledgers = [];
|
||||
if ($traceNo !== '') {
|
||||
$ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByTraceNo($traceNo));
|
||||
}
|
||||
|
||||
if (empty($ledgers) && $bizNo !== '') {
|
||||
$ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo));
|
||||
}
|
||||
|
||||
if (empty($ledgers) && $refundNo !== '') {
|
||||
$ledgers = $this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($refundNo));
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($ledgers as $ledger) {
|
||||
$rows[] = $this->refundReportService->formatLedgerRow((array) $ledger);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将查询结果转换成普通数组。
|
||||
*/
|
||||
private function collectionToArray(iterable $items): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($items as $item) {
|
||||
$rows[] = $item;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回启用的支付方式选项,供筛选使用。
|
||||
*/
|
||||
private function payTypeOptions(): array
|
||||
{
|
||||
return $this->paymentTypeRepository->enabledList(['id', 'name'])
|
||||
->map(static function ($payType): array {
|
||||
return [
|
||||
'label' => (string) $payType->name,
|
||||
'value' => (int) $payType->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
100
app/service/payment/order/RefundReportService.php
Normal file
100
app/service/payment/order/RefundReportService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\LedgerConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
|
||||
/**
|
||||
* 退款单结果组装服务。
|
||||
*
|
||||
* 负责退款详情页和列表页的展示字段格式化。
|
||||
*/
|
||||
class RefundReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 格式化退款订单行,统一输出前端展示字段。
|
||||
*/
|
||||
public function formatRefundOrderRow(array $row): array
|
||||
{
|
||||
$row['merchant_group_name'] = trim((string) ($row['merchant_group_name'] ?? '')) ?: '未分组';
|
||||
$row['merchant_name'] = trim((string) ($row['merchant_name'] ?? '')) ?: '未知商户';
|
||||
$row['merchant_short_name'] = trim((string) ($row['merchant_short_name'] ?? ''));
|
||||
$row['pay_type_name'] = trim((string) ($row['pay_type_name'] ?? '')) ?: '未知方式';
|
||||
$row['channel_name'] = trim((string) ($row['channel_name'] ?? '')) ?: '未知通道';
|
||||
|
||||
$row['status_text'] = $this->textFromMap((int) ($row['status'] ?? -1), TradeConstant::refundStatusMap());
|
||||
$row['pay_status_text'] = $this->textFromMap((int) ($row['pay_status'] ?? -1), TradeConstant::orderStatusMap());
|
||||
$row['channel_type_text'] = $this->textFromMap((int) ($row['channel_type'] ?? -1), RouteConstant::channelTypeMap());
|
||||
$row['channel_mode_text'] = $this->textFromMap((int) ($row['channel_mode'] ?? -1), RouteConstant::channelModeMap());
|
||||
|
||||
$row['refund_amount_text'] = $this->formatAmount((int) ($row['refund_amount'] ?? 0));
|
||||
$row['fee_reverse_amount_text'] = $this->formatAmount((int) ($row['fee_reverse_amount'] ?? 0));
|
||||
$row['pay_order_amount_text'] = $this->formatAmount((int) ($row['pay_order_amount'] ?? 0));
|
||||
$row['pay_fee_actual_amount_text'] = $this->formatAmount((int) ($row['pay_fee_actual_amount'] ?? 0));
|
||||
$row['biz_order_amount_text'] = $this->formatAmount((int) ($row['biz_order_amount'] ?? 0));
|
||||
$row['biz_paid_amount_text'] = $this->formatAmount((int) ($row['biz_paid_amount'] ?? 0));
|
||||
$row['biz_refund_amount_text'] = $this->formatAmount((int) ($row['biz_refund_amount'] ?? 0));
|
||||
|
||||
$row['request_at_text'] = $this->formatDateTime($row['request_at'] ?? null, '—');
|
||||
$row['processing_at_text'] = $this->formatDateTime($row['processing_at'] ?? null, '—');
|
||||
$row['succeeded_at_text'] = $this->formatDateTime($row['succeeded_at'] ?? null, '—');
|
||||
$row['failed_at_text'] = $this->formatDateTime($row['failed_at'] ?? null, '—');
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造退款时间线。
|
||||
*/
|
||||
public function buildRefundTimeline(mixed $refundOrder): array
|
||||
{
|
||||
$extJson = (array) ($refundOrder->ext_json ?? []);
|
||||
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'status' => 'created',
|
||||
'label' => '退款单创建',
|
||||
'at' => $this->formatDateTime($refundOrder->request_at ?? $refundOrder->created_at ?? null, '—'),
|
||||
],
|
||||
$refundOrder->processing_at ? [
|
||||
'status' => 'processing',
|
||||
'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 ? [
|
||||
'status' => 'success',
|
||||
'label' => '退款成功',
|
||||
'at' => $this->formatDateTime($refundOrder->succeeded_at, '—'),
|
||||
] : null,
|
||||
$refundOrder->failed_at ? [
|
||||
'status' => 'failed',
|
||||
'label' => '退款失败',
|
||||
'at' => $this->formatDateTime($refundOrder->failed_at, '—'),
|
||||
'reason' => (string) ($refundOrder->last_error ?: ($extJson['fail_reason'] ?? $refundOrder->reason ?? '')),
|
||||
] : null,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化退款相关资金流水。
|
||||
*/
|
||||
public function formatLedgerRow(array $row): array
|
||||
{
|
||||
$row['biz_type_text'] = $this->textFromMap((int) ($row['biz_type'] ?? -1), LedgerConstant::bizTypeMap());
|
||||
$row['event_type_text'] = $this->textFromMap((int) ($row['event_type'] ?? -1), LedgerConstant::eventTypeMap());
|
||||
$row['direction_text'] = $this->textFromMap((int) ($row['direction'] ?? -1), LedgerConstant::directionMap());
|
||||
$row['amount_text'] = $this->formatAmount((int) ($row['amount'] ?? 0));
|
||||
$row['available_before_text'] = $this->formatAmount((int) ($row['available_before'] ?? 0));
|
||||
$row['available_after_text'] = $this->formatAmount((int) ($row['available_after'] ?? 0));
|
||||
$row['frozen_before_text'] = $this->formatAmount((int) ($row['frozen_before'] ?? 0));
|
||||
$row['frozen_after_text'] = $this->formatAmount((int) ($row['frozen_after'] ?? 0));
|
||||
$row['created_at_text'] = $this->formatDateTime($row['created_at'] ?? null, '—');
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
111
app/service/payment/order/RefundService.php
Normal file
111
app/service/payment/order/RefundService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\order;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\RefundOrder;
|
||||
|
||||
/**
|
||||
* 退款单门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给查询、创建和生命周期三个子服务。
|
||||
*/
|
||||
class RefundService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected RefundQueryService $queryService,
|
||||
protected RefundCreationService $creationService,
|
||||
protected RefundLifecycleService $lifecycleService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询退款订单列表。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null): array
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize, $merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询退款订单详情。
|
||||
*/
|
||||
public function detail(string $refundNo, ?int $merchantId = null): array
|
||||
{
|
||||
return $this->queryService->detail($refundNo, $merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建退款单。
|
||||
*/
|
||||
public function createRefund(array $input): RefundOrder
|
||||
{
|
||||
return $this->creationService->createRefund($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记退款处理中。
|
||||
*/
|
||||
public function markRefundProcessing(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundProcessing($refundNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款重试。
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->lifecycleService->retryRefund($refundNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款处理中或重试。
|
||||
*/
|
||||
public function markRefundProcessingInCurrentTransaction(string $refundNo, array $input = [], bool $isRetry = false): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundProcessingInCurrentTransaction($refundNo, $input, $isRetry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款成功。
|
||||
*/
|
||||
public function markRefundSuccess(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundSuccess($refundNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款成功。
|
||||
*/
|
||||
public function markRefundSuccessInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundSuccessInCurrentTransaction($refundNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款失败。
|
||||
*/
|
||||
public function markRefundFailed(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundFailed($refundNo, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务中标记退款失败。
|
||||
*/
|
||||
public function markRefundFailedInCurrentTransaction(string $refundNo, array $input = []): RefundOrder
|
||||
{
|
||||
return $this->lifecycleService->markRefundFailedInCurrentTransaction($refundNo, $input);
|
||||
}
|
||||
}
|
||||
191
app/service/payment/runtime/NotifyService.php
Normal file
191
app/service/payment/runtime/NotifyService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\model\admin\ChannelNotifyLog;
|
||||
use app\model\payment\NotifyTask;
|
||||
use app\model\admin\PayCallbackLog;
|
||||
use app\repository\ops\log\ChannelNotifyLogRepository;
|
||||
use app\repository\payment\notify\NotifyTaskRepository;
|
||||
use app\repository\ops\log\PayCallbackLogRepository;
|
||||
|
||||
/**
|
||||
* 通知服务。
|
||||
*
|
||||
* 负责渠道通知日志、支付回调日志和商户通知任务的统一管理,核心目标是去重、留痕和可重试。
|
||||
*/
|
||||
class NotifyService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected ChannelNotifyLogRepository $channelNotifyLogRepository,
|
||||
protected PayCallbackLogRepository $payCallbackLogRepository,
|
||||
protected NotifyTaskRepository $notifyTaskRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录渠道通知日志。
|
||||
*
|
||||
* 同一通道、通知类型和业务单号只保留一条重复记录。
|
||||
*/
|
||||
public function recordChannelNotify(array $input): ChannelNotifyLog
|
||||
{
|
||||
$channelId = (int) ($input['channel_id'] ?? 0);
|
||||
$notifyType = (int) ($input['notify_type'] ?? NotifyConstant::NOTIFY_TYPE_ASYNC);
|
||||
$bizNo = trim((string) ($input['biz_no'] ?? ''));
|
||||
|
||||
if ($channelId <= 0 || $bizNo === '') {
|
||||
throw new \InvalidArgumentException('渠道通知入参不完整');
|
||||
}
|
||||
|
||||
if ($duplicate = $this->channelNotifyLogRepository->findDuplicate($channelId, $notifyType, $bizNo)) {
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
return $this->channelNotifyLogRepository->create([
|
||||
'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('CNL')),
|
||||
'channel_id' => $channelId,
|
||||
'notify_type' => $notifyType,
|
||||
'biz_no' => $bizNo,
|
||||
'pay_no' => (string) ($input['pay_no'] ?? ''),
|
||||
'channel_request_no' => (string) ($input['channel_request_no'] ?? ''),
|
||||
'channel_trade_no' => (string) ($input['channel_trade_no'] ?? ''),
|
||||
'raw_payload' => $input['raw_payload'] ?? [],
|
||||
'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN),
|
||||
'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING),
|
||||
'retry_count' => (int) ($input['retry_count'] ?? 0),
|
||||
'next_retry_at' => $input['next_retry_at'] ?? null,
|
||||
'last_error' => (string) ($input['last_error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录支付回调日志。
|
||||
*
|
||||
* 以支付单号 + 回调类型作为去重依据。
|
||||
*/
|
||||
public function recordPayCallback(array $input): PayCallbackLog
|
||||
{
|
||||
$payNo = trim((string) ($input['pay_no'] ?? ''));
|
||||
if ($payNo === '') {
|
||||
throw new \InvalidArgumentException('pay_no 不能为空');
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payCallbackLogRepository->create([
|
||||
'pay_no' => $payNo,
|
||||
'channel_id' => (int) ($input['channel_id'] ?? 0),
|
||||
'callback_type' => $callbackType,
|
||||
'request_data' => $input['request_data'] ?? [],
|
||||
'verify_status' => (int) ($input['verify_status'] ?? NotifyConstant::VERIFY_STATUS_UNKNOWN),
|
||||
'process_status' => (int) ($input['process_status'] ?? NotifyConstant::PROCESS_STATUS_PENDING),
|
||||
'process_result' => $input['process_result'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建商户通知任务。
|
||||
*
|
||||
* 通常用于支付成功、退款成功或清算完成后的商户异步通知。
|
||||
*/
|
||||
public function enqueueMerchantNotify(array $input): NotifyTask
|
||||
{
|
||||
return $this->notifyTaskRepository->create([
|
||||
'notify_no' => (string) ($input['notify_no'] ?? $this->generateNo('NTF')),
|
||||
'merchant_id' => (int) ($input['merchant_id'] ?? 0),
|
||||
'merchant_group_id' => (int) ($input['merchant_group_id'] ?? 0),
|
||||
'biz_no' => (string) ($input['biz_no'] ?? ''),
|
||||
'pay_no' => (string) ($input['pay_no'] ?? ''),
|
||||
'notify_url' => (string) ($input['notify_url'] ?? ''),
|
||||
'notify_data' => $input['notify_data'] ?? [],
|
||||
'status' => (int) ($input['status'] ?? NotifyConstant::TASK_STATUS_PENDING),
|
||||
'retry_count' => (int) ($input['retry_count'] ?? 0),
|
||||
'next_retry_at' => $input['next_retry_at'] ?? $this->nextRetryAt(0),
|
||||
'last_notify_at' => $input['last_notify_at'] ?? null,
|
||||
'last_response' => (string) ($input['last_response'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记商户通知成功。
|
||||
*
|
||||
* 成功后会刷新最后通知时间和响应内容。
|
||||
*/
|
||||
public function markTaskSuccess(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
}
|
||||
|
||||
$task->status = NotifyConstant::TASK_STATUS_SUCCESS;
|
||||
$task->last_notify_at = $input['last_notify_at'] ?? $this->now();
|
||||
$task->last_response = (string) ($input['last_response'] ?? '');
|
||||
$task->save();
|
||||
|
||||
return $task->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记商户通知失败并计算下次重试时间。
|
||||
*
|
||||
* 失败后会累计重试次数,并根据退避策略生成下一次重试时间。
|
||||
*/
|
||||
public function markTaskFailed(string $notifyNo, array $input = []): NotifyTask
|
||||
{
|
||||
$task = $this->notifyTaskRepository->findByNotifyNo($notifyNo);
|
||||
if (!$task) {
|
||||
throw new \InvalidArgumentException('通知任务不存在');
|
||||
}
|
||||
|
||||
$retryCount = (int) $task->retry_count + 1;
|
||||
$task->status = NotifyConstant::TASK_STATUS_FAILED;
|
||||
$task->retry_count = $retryCount;
|
||||
$task->last_notify_at = $input['last_notify_at'] ?? $this->now();
|
||||
$task->last_response = (string) ($input['last_response'] ?? '');
|
||||
$task->next_retry_at = $this->nextRetryAt($retryCount);
|
||||
$task->save();
|
||||
|
||||
return $task->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待重试任务。
|
||||
*/
|
||||
public function listRetryableTasks(): iterable
|
||||
{
|
||||
return $this->notifyTaskRepository->listRetryable(NotifyConstant::TASK_STATUS_FAILED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据重试次数计算下次重试时间。
|
||||
*
|
||||
* 使用简单的指数退避思路控制重试频率。
|
||||
*/
|
||||
private function nextRetryAt(int $retryCount): string
|
||||
{
|
||||
$retryCount = max(0, $retryCount);
|
||||
$delay = match (true) {
|
||||
$retryCount <= 0 => 60,
|
||||
$retryCount === 1 => 300,
|
||||
$retryCount === 2 => 900,
|
||||
default => 1800,
|
||||
};
|
||||
|
||||
return FormatHelper::timestamp(time() + $delay);
|
||||
}
|
||||
}
|
||||
|
||||
212
app/service/payment/runtime/PaymentPluginFactoryService.php
Normal file
212
app/service/payment/runtime/PaymentPluginFactoryService.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\exception\PaymentException;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPlugin;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPluginConfRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
|
||||
/**
|
||||
* 支付插件工厂服务。
|
||||
*
|
||||
* 负责解析插件定义、装配配置并实例化插件。
|
||||
*/
|
||||
class PaymentPluginFactoryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentPluginConfRepository $paymentPluginConfRepository,
|
||||
protected PaymentChannelRepository $paymentChannelRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {}
|
||||
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$channelModel = $channel instanceof PaymentChannel
|
||||
? $channel
|
||||
: $this->paymentChannelRepository->find((int) $channel);
|
||||
|
||||
if (!$channelModel) {
|
||||
throw new PaymentException('支付通道不存在', 40402, ['channel_id' => (int) $channel]);
|
||||
}
|
||||
|
||||
$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, [
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'pay_type_code' => $payTypeCode,
|
||||
'channel_id' => (int) $channelModel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$instance = $this->instantiatePlugin((string) $plugin->class_name);
|
||||
$instance->init($this->buildChannelConfig($channelModel, $plugin));
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface
|
||||
{
|
||||
return $this->createByChannel((int) $payOrder->channel_id, (int) $payOrder->pay_type_id, $allowDisabled);
|
||||
}
|
||||
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
$plugin = $this->resolvePlugin((string) $channel->plugin_code, false);
|
||||
$payTypeCode = $this->resolvePayTypeCode($payTypeId);
|
||||
|
||||
if (!$this->pluginSupportsPayType($plugin, $payTypeCode)) {
|
||||
throw new PaymentException('支付插件不支持当前支付方式', 40210, [
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'pay_type_code' => $payTypeCode,
|
||||
'channel_id' => (int) $channel->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
$plugin = $this->resolvePlugin($pluginCode, $allowDisabled);
|
||||
|
||||
return $this->normalizeCodes($plugin->pay_types ?? []);
|
||||
}
|
||||
|
||||
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, [
|
||||
'api_config_id' => $configId,
|
||||
'channel_id' => (int) $channel->id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ((string) $pluginConf->plugin_code !== (string) $plugin->code) {
|
||||
throw new PaymentException('支付插件与配置不匹配', 40211, [
|
||||
'channel_id' => (int) $channel->id,
|
||||
'plugin_code' => (string) $plugin->code,
|
||||
'config_plugin_code' => (string) $pluginConf->plugin_code,
|
||||
]);
|
||||
}
|
||||
|
||||
$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;
|
||||
$config['merchant_id'] = (int) $channel->merchant_id;
|
||||
$config['channel_mode'] = (int) $channel->channel_mode;
|
||||
$config['pay_type_id'] = (int) $channel->pay_type_id;
|
||||
$config['api_config_id'] = $configId;
|
||||
$config['enabled_pay_types'] = $this->normalizeCodes($plugin->pay_types ?? []);
|
||||
$config['enabled_transfer_types'] = $this->normalizeCodes($plugin->transfer_types ?? []);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function instantiatePlugin(string $className): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
$className = $this->resolvePluginClassName($className);
|
||||
if ($className === '') {
|
||||
throw new PaymentException('支付插件未配置实现类', 40212);
|
||||
}
|
||||
|
||||
if (!class_exists($className)) {
|
||||
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]);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function resolvePluginClassName(string $className): string
|
||||
{
|
||||
$className = trim($className);
|
||||
if ($className === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_contains($className, '\\')) {
|
||||
return $className;
|
||||
}
|
||||
|
||||
return 'app\\common\\payment\\' . $className;
|
||||
}
|
||||
|
||||
private function resolvePlugin(string $pluginCode, bool $allowDisabled): PaymentPlugin
|
||||
{
|
||||
/** @var PaymentPlugin|null $plugin */
|
||||
$plugin = $this->paymentPluginRepository->findByCode($pluginCode);
|
||||
if (!$plugin) {
|
||||
throw new PaymentException('支付插件不存在', 40401, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
if (!$allowDisabled && (int) $plugin->status !== 1) {
|
||||
throw new PaymentException('支付插件已禁用', 40214, ['plugin_code' => $pluginCode]);
|
||||
}
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
private function resolvePayTypeCode(int $payTypeId): string
|
||||
{
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
if (!$paymentType) {
|
||||
throw new PaymentException('支付方式不存在', 40405, ['pay_type_id' => $payTypeId]);
|
||||
}
|
||||
|
||||
return trim((string) $paymentType->code);
|
||||
}
|
||||
|
||||
private function pluginSupportsPayType(PaymentPlugin $plugin, string $payTypeCode): bool
|
||||
{
|
||||
$payTypeCode = trim($payTypeCode);
|
||||
if ($payTypeCode === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($payTypeCode, $this->normalizeCodes($plugin->pay_types ?? []), true);
|
||||
}
|
||||
|
||||
private function normalizeCodes(mixed $codes): array
|
||||
{
|
||||
if (is_string($codes)) {
|
||||
$decoded = json_decode($codes, true);
|
||||
$codes = is_array($decoded) ? $decoded : [$codes];
|
||||
}
|
||||
|
||||
if (!is_array($codes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($codes as $code) {
|
||||
$value = trim((string) $code);
|
||||
if ($value !== '') {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
}
|
||||
42
app/service/payment/runtime/PaymentPluginManager.php
Normal file
42
app/service/payment/runtime/PaymentPluginManager.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\interface\PaymentInterface;
|
||||
use app\common\interface\PayPluginInterface;
|
||||
use app\model\payment\PayOrder;
|
||||
use app\model\payment\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付插件门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给插件工厂服务。
|
||||
*/
|
||||
class PaymentPluginManager extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPluginFactoryService $factoryService
|
||||
) {
|
||||
}
|
||||
|
||||
public function createByChannel(PaymentChannel|int $channel, ?int $payTypeId = null, bool $allowDisabled = false): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByChannel($channel, $payTypeId, $allowDisabled);
|
||||
}
|
||||
|
||||
public function createByPayOrder(PayOrder $payOrder, bool $allowDisabled = true): PaymentInterface & PayPluginInterface
|
||||
{
|
||||
return $this->factoryService->createByPayOrder($payOrder, $allowDisabled);
|
||||
}
|
||||
|
||||
public function ensureChannelSupportsPayType(PaymentChannel $channel, int $payTypeId): void
|
||||
{
|
||||
$this->factoryService->ensureChannelSupportsPayType($channel, $payTypeId);
|
||||
}
|
||||
|
||||
public function pluginPayTypes(string $pluginCode, bool $allowDisabled = false): array
|
||||
{
|
||||
return $this->factoryService->pluginPayTypes($pluginCode, $allowDisabled);
|
||||
}
|
||||
}
|
||||
276
app/service/payment/runtime/PaymentRouteResolverService.php
Normal file
276
app/service/payment/runtime/PaymentRouteResolverService.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\constant\RouteConstant;
|
||||
use app\common\util\FormatHelper;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\PaymentChannel;
|
||||
use app\model\payment\PaymentPollGroup;
|
||||
use app\repository\ops\stat\ChannelDailyStatRepository;
|
||||
use app\repository\payment\config\PaymentChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupBindRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupChannelRepository;
|
||||
use app\repository\payment\config\PaymentPollGroupRepository;
|
||||
use app\repository\payment\config\PaymentPluginRepository;
|
||||
use app\repository\payment\config\PaymentTypeRepository;
|
||||
use support\Redis;
|
||||
|
||||
/**
|
||||
* 支付路由解析服务。
|
||||
*
|
||||
* 负责商户分组 -> 轮询组 -> 支付通道的编排与选择。
|
||||
*/
|
||||
class PaymentRouteResolverService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentPollGroupBindRepository $bindRepository,
|
||||
protected PaymentPollGroupRepository $pollGroupRepository,
|
||||
protected PaymentPollGroupChannelRepository $pollGroupChannelRepository,
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected ChannelDailyStatRepository $channelDailyStatRepository,
|
||||
protected PaymentPluginRepository $paymentPluginRepository,
|
||||
protected PaymentTypeRepository $paymentTypeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*
|
||||
* @return array{bind:mixed,poll_group:mixed,candidates:array,selected_channel:array}
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
if ($merchantGroupId <= 0 || $payTypeId <= 0 || $payAmount <= 0) {
|
||||
throw new ValidationException('路由参数不合法');
|
||||
}
|
||||
|
||||
$bind = $this->bindRepository->findActiveByMerchantGroupAndPayType($merchantGroupId, $payTypeId);
|
||||
if (!$bind) {
|
||||
throw new ResourceNotFoundException('路由不存在', [
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var PaymentPollGroup|null $pollGroup */
|
||||
$pollGroup = $this->pollGroupRepository->find((int) $bind->poll_group_id);
|
||||
if (!$pollGroup || (int) $pollGroup->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ResourceNotFoundException('路由不存在', [
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
'poll_group_id' => (int) ($bind->poll_group_id ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$candidateRows = $this->pollGroupChannelRepository->listByPollGroupId((int) $pollGroup->id);
|
||||
if ($candidateRows->isEmpty()) {
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$channelIds = $candidateRows->pluck('channel_id')->all();
|
||||
$channels = $this->channelRepository->query()
|
||||
->whereIn('id', $channelIds)
|
||||
->where('status', CommonConstant::STATUS_ENABLED)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$pluginCodes = $channels->pluck('plugin_code')->filter()->unique()->values()->all();
|
||||
$plugins = [];
|
||||
if (!empty($pluginCodes)) {
|
||||
$plugins = $this->paymentPluginRepository->query()
|
||||
->whereIn('code', $pluginCodes)
|
||||
->get()
|
||||
->keyBy('code')
|
||||
->all();
|
||||
}
|
||||
$paymentType = $this->paymentTypeRepository->find($payTypeId);
|
||||
$payTypeCode = trim((string) ($paymentType->code ?? ''));
|
||||
|
||||
$statDate = $context['stat_date'] ?? FormatHelper::timestamp(time(), 'Y-m-d');
|
||||
$payAmount = (int) $payAmount;
|
||||
$eligible = [];
|
||||
|
||||
foreach ($candidateRows as $row) {
|
||||
$channelId = (int) $row->channel_id;
|
||||
|
||||
/** @var PaymentChannel|null $channel */
|
||||
$channel = $channels->get($channelId);
|
||||
if (!$channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $channel->pay_type_id !== $payTypeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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,
|
||||
'daily_stat' => $stat,
|
||||
'health_score' => (int) ($stat->health_score ?? 0),
|
||||
'success_rate_bp' => (int) ($stat->success_rate_bp ?? 0),
|
||||
'avg_latency_ms' => (int) ($stat->avg_latency_ms ?? 0),
|
||||
'weight' => max(1, (int) $row->weight),
|
||||
'is_default' => (int) $row->is_default,
|
||||
'sort_no' => (int) $row->sort_no,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($eligible)) {
|
||||
throw new BusinessStateException('支付通道不可用', [
|
||||
'poll_group_id' => (int) $pollGroup->id,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'pay_type_id' => $payTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$routeMode = (int) $pollGroup->route_mode;
|
||||
$ordered = $this->sortCandidates($eligible, $routeMode);
|
||||
$selected = $this->selectChannel($ordered, $routeMode, (int) $pollGroup->id);
|
||||
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'poll_group' => $pollGroup,
|
||||
'candidates' => $ordered,
|
||||
'selected_channel' => $selected,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAmountAllowed(PaymentChannel $channel, int $payAmount): bool
|
||||
{
|
||||
if ((int) $channel->min_amount > 0 && $payAmount < (int) $channel->min_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $channel->max_amount > 0 && $payAmount > (int) $channel->max_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$stat ??= $this->channelDailyStatRepository->findByChannelAndDate((int) $channel->id, $statDate);
|
||||
$currentAmount = (int) ($stat->pay_amount ?? 0);
|
||||
$currentCount = (int) ($stat->pay_success_count ?? 0);
|
||||
|
||||
if ((int) $channel->daily_limit_amount > 0 && $currentAmount + $payAmount > (int) $channel->daily_limit_amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $channel->daily_limit_count > 0 && $currentCount + 1 > (int) $channel->daily_limit_count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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']
|
||||
) {
|
||||
return (int) $right['is_default'] <=> (int) $left['is_default'];
|
||||
}
|
||||
|
||||
if ((int) $left['sort_no'] !== (int) $right['sort_no']) {
|
||||
return (int) $left['sort_no'] <=> (int) $right['sort_no'];
|
||||
}
|
||||
|
||||
return (int) $left['channel']->id <=> (int) $right['channel']->id;
|
||||
});
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private function selectChannel(array $candidates, int $routeMode, int $pollGroupId): array
|
||||
{
|
||||
if (count($candidates) === 1) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
return match ($routeMode) {
|
||||
RouteConstant::ROUTE_MODE_WEIGHTED => $this->selectWeightedChannel($candidates),
|
||||
RouteConstant::ROUTE_MODE_ORDER => $this->selectSequentialChannel($candidates, $pollGroupId),
|
||||
RouteConstant::ROUTE_MODE_FIRST_AVAILABLE => $this->selectDefaultChannel($candidates),
|
||||
default => $candidates[0],
|
||||
};
|
||||
}
|
||||
|
||||
private function selectWeightedChannel(array $candidates): array
|
||||
{
|
||||
$totalWeight = array_sum(array_map(static fn (array $item) => max(1, (int) $item['weight']), $candidates));
|
||||
$random = random_int(1, max(1, $totalWeight));
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$random -= max(1, (int) $candidate['weight']);
|
||||
if ($random <= 0) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function selectSequentialChannel(array $candidates, int $pollGroupId): array
|
||||
{
|
||||
if ($pollGroupId <= 0) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
try {
|
||||
$cursorKey = sprintf('payment:route:round_robin:%d', $pollGroupId);
|
||||
$cursor = (int) Redis::incr($cursorKey);
|
||||
Redis::expire($cursorKey, 30 * 86400);
|
||||
$index = max(0, ($cursor - 1) % count($candidates));
|
||||
|
||||
return $candidates[$index] ?? $candidates[0];
|
||||
} catch (\Throwable) {
|
||||
return $candidates[0];
|
||||
}
|
||||
}
|
||||
|
||||
private function selectDefaultChannel(array $candidates): array
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
if ((int) ($candidate['is_default'] ?? 0) === 1) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
}
|
||||
26
app/service/payment/runtime/PaymentRouteService.php
Normal file
26
app/service/payment/runtime/PaymentRouteService.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\runtime;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
/**
|
||||
* 支付路由门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给路由解析服务。
|
||||
*/
|
||||
class PaymentRouteService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentRouteResolverService $resolverService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按商户分组和支付方式解析路由。
|
||||
*/
|
||||
public function resolveByMerchantGroup(int $merchantGroupId, int $payTypeId, int $payAmount, array $context = []): array
|
||||
{
|
||||
return $this->resolverService->resolveByMerchantGroup($merchantGroupId, $payTypeId, $payAmount, $context);
|
||||
}
|
||||
}
|
||||
270
app/service/payment/settlement/SettlementLifecycleService.php
Normal file
270
app/service/payment/settlement/SettlementLifecycleService.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\settlement;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\SettlementOrder;
|
||||
use app\repository\payment\settlement\SettlementItemRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\service\account\funds\MerchantAccountService;
|
||||
|
||||
/**
|
||||
* 清算生命周期服务。
|
||||
*
|
||||
* 负责清算单创建、明细写入、入账完成和失败终态处理。
|
||||
*/
|
||||
class SettlementLifecycleService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected SettlementItemRepository $settlementItemRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected MerchantAccountService $merchantAccountService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建清算单和明细。
|
||||
*
|
||||
* 适用于平台代收链路的清算批次生成,会同时写入汇总与明细。
|
||||
*
|
||||
* @param array $input 清算单参数
|
||||
* @param array $items 清算明细列表
|
||||
* @return SettlementOrder
|
||||
*/
|
||||
public function createSettlementOrder(array $input, array $items = []): SettlementOrder
|
||||
{
|
||||
$settleNo = trim((string) ($input['settle_no'] ?? ''));
|
||||
if ($settleNo === '') {
|
||||
$settleNo = $this->generateNo('STL');
|
||||
}
|
||||
|
||||
if ($existing = $this->settlementOrderRepository->findBySettleNo($settleNo)) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$merchantId = (int) ($input['merchant_id'] ?? 0);
|
||||
$merchantGroupId = (int) ($input['merchant_group_id'] ?? 0);
|
||||
$channelId = (int) ($input['channel_id'] ?? 0);
|
||||
$cycleType = (int) ($input['cycle_type'] ?? TradeConstant::SETTLEMENT_CYCLE_OTHER);
|
||||
$cycleKey = trim((string) ($input['cycle_key'] ?? ''));
|
||||
|
||||
if ($merchantId <= 0 || $merchantGroupId <= 0 || $channelId <= 0 || $cycleKey === '') {
|
||||
throw new ValidationException('清算单入参不完整');
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
$settlementOrder = $this->settlementOrderRepository->create([
|
||||
'settle_no' => $settleNo,
|
||||
'trace_no' => $traceNo,
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'channel_id' => $channelId,
|
||||
'cycle_type' => $cycleType,
|
||||
'cycle_key' => $cycleKey,
|
||||
'status' => (int) ($input['status'] ?? TradeConstant::SETTLEMENT_STATUS_PENDING),
|
||||
'gross_amount' => $summary['gross_amount'],
|
||||
'fee_amount' => $summary['fee_amount'],
|
||||
'refund_amount' => $summary['refund_amount'],
|
||||
'fee_reverse_amount' => $summary['fee_reverse_amount'],
|
||||
'net_amount' => $summary['net_amount'],
|
||||
'accounted_amount' => $summary['accounted_amount'],
|
||||
'generated_at' => $input['generated_at'] ?? $this->now(),
|
||||
'failed_at' => null,
|
||||
'ext_json' => $input['ext_json'] ?? [],
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$this->settlementItemRepository->create([
|
||||
'settle_no' => $settleNo,
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_group_id' => $merchantGroupId,
|
||||
'channel_id' => $channelId,
|
||||
'pay_no' => (string) ($item['pay_no'] ?? ''),
|
||||
'refund_no' => (string) ($item['refund_no'] ?? ''),
|
||||
'pay_amount' => (int) ($item['pay_amount'] ?? 0),
|
||||
'fee_amount' => (int) ($item['fee_amount'] ?? 0),
|
||||
'refund_amount' => (int) ($item['refund_amount'] ?? 0),
|
||||
'fee_reverse_amount' => (int) ($item['fee_reverse_amount'] ?? 0),
|
||||
'net_amount' => (int) ($item['net_amount'] ?? 0),
|
||||
'item_status' => (int) ($item['item_status'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $settlementOrder->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算入账成功。
|
||||
*
|
||||
* 会把清算净额计入商户可提现余额,并同步标记清算单与清算明细为已完成。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @return SettlementOrder
|
||||
*/
|
||||
public function completeSettlement(string $settleNo): SettlementOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($settleNo) {
|
||||
$settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo);
|
||||
if (!$settlementOrder) {
|
||||
throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $settlementOrder->status;
|
||||
if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_SETTLED) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isSettlementTerminalStatus($currentStatus)) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::settlementMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('清结算单状态不允许当前操作', [
|
||||
'settle_no' => $settleNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $settlementOrder->accounted_amount > 0) {
|
||||
$this->merchantAccountService->creditAvailableAmountInCurrentTransaction(
|
||||
(int) $settlementOrder->merchant_id,
|
||||
(int) $settlementOrder->accounted_amount,
|
||||
$settleNo,
|
||||
'SETTLEMENT_CREDIT:' . $settleNo,
|
||||
[
|
||||
'settle_no' => $settleNo,
|
||||
'remark' => '清算入账',
|
||||
],
|
||||
(string) ($settlementOrder->trace_no ?: $settleNo)
|
||||
);
|
||||
}
|
||||
|
||||
$settlementOrder->status = TradeConstant::SETTLEMENT_STATUS_SETTLED;
|
||||
$settlementOrder->accounted_at = $this->now();
|
||||
$settlementOrder->completed_at = $this->now();
|
||||
$settlementOrder->save();
|
||||
|
||||
$items = $this->settlementItemRepository->listBySettleNo($settleNo);
|
||||
foreach ($items as $item) {
|
||||
$item->item_status = TradeConstant::SETTLEMENT_STATUS_SETTLED;
|
||||
$item->save();
|
||||
|
||||
if (!empty($item->pay_no)) {
|
||||
$payOrder = $this->payOrderRepository->findByPayNo((string) $item->pay_no);
|
||||
if ($payOrder) {
|
||||
$payOrder->settlement_status = TradeConstant::SETTLEMENT_STATUS_SETTLED;
|
||||
$payOrder->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $settlementOrder->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算失败。
|
||||
*
|
||||
* 仅用于清算批次未成功入账时的终态标记。
|
||||
*
|
||||
* @param string $settleNo 清算单号
|
||||
* @param string $reason 失败原因
|
||||
* @return SettlementOrder
|
||||
*/
|
||||
public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder
|
||||
{
|
||||
return $this->transactionRetry(function () use ($settleNo, $reason) {
|
||||
$settlementOrder = $this->settlementOrderRepository->findForUpdateBySettleNo($settleNo);
|
||||
if (!$settlementOrder) {
|
||||
throw new ResourceNotFoundException('清结算单不存在', ['settle_no' => $settleNo]);
|
||||
}
|
||||
|
||||
$currentStatus = (int) $settlementOrder->status;
|
||||
if ($currentStatus === TradeConstant::SETTLEMENT_STATUS_REVERSED) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
|
||||
if (TradeConstant::isSettlementTerminalStatus($currentStatus)) {
|
||||
return $settlementOrder;
|
||||
}
|
||||
|
||||
if (!in_array($currentStatus, TradeConstant::settlementMutableStatuses(), true)) {
|
||||
throw new BusinessStateException('清结算单状态不允许当前操作', [
|
||||
'settle_no' => $settleNo,
|
||||
'status' => $currentStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
$settlementOrder->status = TradeConstant::SETTLEMENT_STATUS_REVERSED;
|
||||
$settlementOrder->fail_reason = $reason;
|
||||
$settlementOrder->failed_at = $this->now();
|
||||
$extJson = (array) $settlementOrder->ext_json;
|
||||
if (trim($reason) !== '') {
|
||||
$extJson['fail_reason'] = $reason;
|
||||
}
|
||||
$settlementOrder->ext_json = $extJson;
|
||||
$settlementOrder->save();
|
||||
|
||||
$items = $this->settlementItemRepository->listBySettleNo($settleNo);
|
||||
foreach ($items as $item) {
|
||||
$item->item_status = TradeConstant::SETTLEMENT_STATUS_REVERSED;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
return $settlementOrder->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据清算明细构造汇总数据。
|
||||
*/
|
||||
private function buildSummary(array $items, array $input): array
|
||||
{
|
||||
if (!empty($items)) {
|
||||
$grossAmount = 0;
|
||||
$feeAmount = 0;
|
||||
$refundAmount = 0;
|
||||
$feeReverseAmount = 0;
|
||||
$netAmount = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$grossAmount += (int) ($item['pay_amount'] ?? 0);
|
||||
$feeAmount += (int) ($item['fee_amount'] ?? 0);
|
||||
$refundAmount += (int) ($item['refund_amount'] ?? 0);
|
||||
$feeReverseAmount += (int) ($item['fee_reverse_amount'] ?? 0);
|
||||
$netAmount += (int) ($item['net_amount'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'gross_amount' => $grossAmount,
|
||||
'fee_amount' => $feeAmount,
|
||||
'refund_amount' => $refundAmount,
|
||||
'fee_reverse_amount' => $feeReverseAmount,
|
||||
'net_amount' => $netAmount,
|
||||
'accounted_amount' => $input['accounted_amount'] ?? $netAmount,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'gross_amount' => (int) ($input['gross_amount'] ?? 0),
|
||||
'fee_amount' => (int) ($input['fee_amount'] ?? 0),
|
||||
'refund_amount' => (int) ($input['refund_amount'] ?? 0),
|
||||
'fee_reverse_amount' => (int) ($input['fee_reverse_amount'] ?? 0),
|
||||
'net_amount' => (int) ($input['net_amount'] ?? 0),
|
||||
'accounted_amount' => (int) ($input['accounted_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
226
app/service/payment/settlement/SettlementOrderQueryService.php
Normal file
226
app/service/payment/settlement/SettlementOrderQueryService.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\settlement;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\SettlementOrder;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
use app\repository\payment\settlement\SettlementItemRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
|
||||
/**
|
||||
* 清算订单查询服务。
|
||||
*/
|
||||
class SettlementOrderQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入清算订单仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected SettlementItemRepository $settlementItemRepository,
|
||||
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询清算订单。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10, ?int $merchantId = null)
|
||||
{
|
||||
$query = $this->baseQuery($merchantId);
|
||||
|
||||
$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 . '%')
|
||||
->orWhere('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('g.group_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('c.name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$merchantId = (string) ($filters['merchant_id'] ?? '');
|
||||
if ($merchantId !== '') {
|
||||
$query->where('s.merchant_id', (int) $merchantId);
|
||||
}
|
||||
|
||||
$channelId = (string) ($filters['channel_id'] ?? '');
|
||||
if ($channelId !== '') {
|
||||
$query->where('s.channel_id', (int) $channelId);
|
||||
}
|
||||
|
||||
$status = (string) ($filters['status'] ?? '');
|
||||
if ($status !== '') {
|
||||
$query->where('s.status', (int) $status);
|
||||
}
|
||||
|
||||
$cycleType = (string) ($filters['cycle_type'] ?? '');
|
||||
if ($cycleType !== '') {
|
||||
$query->where('s.cycle_type', (int) $cycleType);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->orderByDesc('s.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
return $this->decorateRow($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按清算单号查询详情。
|
||||
*/
|
||||
public function findBySettleNo(string $settleNo, ?int $merchantId = null): ?SettlementOrder
|
||||
{
|
||||
$row = $this->baseQuery($merchantId)
|
||||
->where('s.settle_no', $settleNo)
|
||||
->first();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询清算订单详情。
|
||||
*/
|
||||
public function detail(string $settleNo, ?int $merchantId = null): array
|
||||
{
|
||||
$settleNo = trim($settleNo);
|
||||
if ($settleNo === '') {
|
||||
throw new ValidationException('settle_no 不能为空');
|
||||
}
|
||||
|
||||
$settlementOrder = $this->findBySettleNo($settleNo, $merchantId);
|
||||
if (!$settlementOrder) {
|
||||
throw new ResourceNotFoundException('清算单不存在', ['settle_no' => $settleNo]);
|
||||
}
|
||||
|
||||
$traceNo = trim((string) ($settlementOrder->trace_no ?: $settlementOrder->settle_no));
|
||||
$accountLedgers = $traceNo !== ''
|
||||
? $this->merchantAccountLedgerRepository->listByTraceNo($traceNo)
|
||||
: collect();
|
||||
|
||||
if ($accountLedgers->isEmpty()) {
|
||||
$accountLedgers = $this->merchantAccountLedgerRepository->listByBizNo((string) $settlementOrder->settle_no);
|
||||
}
|
||||
|
||||
return [
|
||||
'settlement_order' => $settlementOrder,
|
||||
'items' => $this->settlementItemRepository->listBySettleNo($settleNo),
|
||||
'account_ledgers' => $accountLedgers,
|
||||
'timeline' => $this->buildTimeline($settlementOrder),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建时间线。
|
||||
*/
|
||||
public function buildTimeline(?SettlementOrder $settlementOrder): array
|
||||
{
|
||||
if (!$settlementOrder) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter([
|
||||
[
|
||||
'title' => '生成清算单',
|
||||
'status' => 'finish',
|
||||
'at' => $this->formatDateTime($settlementOrder->generated_at ?? null),
|
||||
],
|
||||
$settlementOrder->accounted_at ? [
|
||||
'title' => '入账处理',
|
||||
'status' => 'finish',
|
||||
'at' => $this->formatDateTime($settlementOrder->accounted_at ?? null),
|
||||
] : null,
|
||||
$settlementOrder->completed_at ? [
|
||||
'title' => '清算完成',
|
||||
'status' => 'finish',
|
||||
'at' => $this->formatDateTime($settlementOrder->completed_at ?? null),
|
||||
] : null,
|
||||
$settlementOrder->failed_at ? [
|
||||
'title' => '清算失败',
|
||||
'status' => 'error',
|
||||
'at' => $this->formatDateTime($settlementOrder->failed_at ?? null),
|
||||
'reason' => (string) ($settlementOrder->fail_reason ?? ''),
|
||||
] : null,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单条记录。
|
||||
*/
|
||||
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);
|
||||
$row->fee_amount_text = $this->formatAmount((int) $row->fee_amount);
|
||||
$row->refund_amount_text = $this->formatAmount((int) $row->refund_amount);
|
||||
$row->fee_reverse_amount_text = $this->formatAmount((int) $row->fee_reverse_amount);
|
||||
$row->net_amount_text = $this->formatAmount((int) $row->net_amount);
|
||||
$row->accounted_amount_text = $this->formatAmount((int) $row->accounted_amount);
|
||||
$row->generated_at_text = $this->formatDateTime($row->generated_at ?? null);
|
||||
$row->accounted_at_text = $this->formatDateTime($row->accounted_at ?? null);
|
||||
$row->completed_at_text = $this->formatDateTime($row->completed_at ?? null);
|
||||
$row->failed_at_text = $this->formatDateTime($row->failed_at ?? null);
|
||||
$row->ext_json_text = $this->formatJson($row->ext_json ?? null);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一构建查询。
|
||||
*/
|
||||
private function baseQuery(?int $merchantId = null)
|
||||
{
|
||||
$query = $this->settlementOrderRepository->query()
|
||||
->from('ma_settlement_order as s')
|
||||
->leftJoin('ma_merchant as m', 's.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_merchant_group as g', 'm.group_id', '=', 'g.id')
|
||||
->leftJoin('ma_payment_channel as c', 's.channel_id', '=', 'c.id')
|
||||
->select([
|
||||
's.id',
|
||||
's.settle_no',
|
||||
's.trace_no',
|
||||
's.merchant_id',
|
||||
's.merchant_group_id',
|
||||
's.channel_id',
|
||||
's.cycle_type',
|
||||
's.cycle_key',
|
||||
's.status',
|
||||
's.gross_amount',
|
||||
's.fee_amount',
|
||||
's.refund_amount',
|
||||
's.fee_reverse_amount',
|
||||
's.net_amount',
|
||||
's.accounted_amount',
|
||||
's.generated_at',
|
||||
's.accounted_at',
|
||||
's.completed_at',
|
||||
's.failed_at',
|
||||
's.fail_reason',
|
||||
's.ext_json',
|
||||
's.created_at',
|
||||
's.updated_at',
|
||||
])
|
||||
->selectRaw("COALESCE(m.merchant_no, '') AS merchant_no")
|
||||
->selectRaw("COALESCE(m.merchant_name, '') AS merchant_name")
|
||||
->selectRaw("COALESCE(g.group_name, '') AS merchant_group_name")
|
||||
->selectRaw("COALESCE(c.name, '') AS channel_name");
|
||||
|
||||
if ($merchantId !== null && $merchantId > 0) {
|
||||
$query->where('s.merchant_id', $merchantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
}
|
||||
55
app/service/payment/settlement/SettlementService.php
Normal file
55
app/service/payment/settlement/SettlementService.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\settlement;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\model\payment\SettlementOrder;
|
||||
|
||||
/**
|
||||
* 清算门面服务。
|
||||
*
|
||||
* 对外保留原有调用契约,内部委托给清算生命周期服务。
|
||||
*/
|
||||
class SettlementService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected SettlementOrderQueryService $queryService,
|
||||
protected SettlementLifecycleService $lifecycleService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建清算单和明细。
|
||||
*/
|
||||
public function createSettlementOrder(array $input, array $items = []): SettlementOrder
|
||||
{
|
||||
return $this->lifecycleService->createSettlementOrder($input, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询清算订单详情。
|
||||
*/
|
||||
public function detail(string $settleNo, ?int $merchantId = null): array
|
||||
{
|
||||
return $this->queryService->detail($settleNo, $merchantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算入账成功。
|
||||
*/
|
||||
public function completeSettlement(string $settleNo): SettlementOrder
|
||||
{
|
||||
return $this->lifecycleService->completeSettlement($settleNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清算失败。
|
||||
*/
|
||||
public function failSettlement(string $settleNo, string $reason = ''): SettlementOrder
|
||||
{
|
||||
return $this->lifecycleService->failSettlement($settleNo, $reason);
|
||||
}
|
||||
}
|
||||
248
app/service/payment/trace/TradeTraceReportService.php
Normal file
248
app/service/payment/trace/TradeTraceReportService.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\trace;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\LedgerConstant;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\common\constant\TradeConstant;
|
||||
use app\model\payment\BizOrder;
|
||||
|
||||
/**
|
||||
* 跨域交易追踪结果组装服务。
|
||||
*
|
||||
* 负责把追踪查询到的原始记录组装成摘要和时间线。
|
||||
*/
|
||||
class TradeTraceReportService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 汇总追踪统计数据。
|
||||
*/
|
||||
public function buildSummary(?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders, array $accountLedgers, array $payCallbacks): array
|
||||
{
|
||||
return [
|
||||
'has_biz_order' => $bizOrder !== null,
|
||||
'pay_order_count' => count($payOrders),
|
||||
'refund_order_count' => count($refundOrders),
|
||||
'settlement_order_count' => count($settlementOrders),
|
||||
'ledger_count' => count($accountLedgers),
|
||||
'callback_count' => count($payCallbacks),
|
||||
'pay_amount_total' => $this->sumBy($payOrders, 'pay_amount'),
|
||||
'refund_amount_total' => $this->sumBy($refundOrders, 'refund_amount'),
|
||||
'settlement_accounted_total' => $this->sumBy($settlementOrders, 'accounted_amount'),
|
||||
'ledger_amount_total' => $this->sumBy($accountLedgers, 'amount'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据关联记录组装追踪时间线。
|
||||
*/
|
||||
public function buildTimeline(?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders, array $accountLedgers, array $payCallbacks): array
|
||||
{
|
||||
$events = [];
|
||||
$sortOrder = 0;
|
||||
|
||||
if ($bizOrder) {
|
||||
$this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'created', $bizOrder->created_at, [
|
||||
'label' => '业务单创建',
|
||||
'status_text' => '创建',
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
'merchant_order_no' => (string) $bizOrder->merchant_order_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'paid', $bizOrder->paid_at, [
|
||||
'label' => '业务单已支付',
|
||||
'status_text' => '成功',
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'closed', $bizOrder->closed_at, [
|
||||
'label' => '业务单已关闭',
|
||||
'status_text' => '关闭',
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'failed', $bizOrder->failed_at, [
|
||||
'label' => '业务单失败',
|
||||
'status_text' => '失败',
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'biz_order', (string) $bizOrder->biz_no, 'timeout', $bizOrder->timeout_at, [
|
||||
'label' => '业务单超时',
|
||||
'status_text' => '超时',
|
||||
'biz_no' => (string) $bizOrder->biz_no,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($payOrders as $payOrder) {
|
||||
$payNo = (string) $payOrder->pay_no;
|
||||
$statusTextMap = TradeConstant::orderStatusMap();
|
||||
$this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'created', $payOrder->request_at, [
|
||||
'label' => '支付单创建',
|
||||
'status_text' => '创建',
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'attempt_no' => (int) ($payOrder->attempt_no ?? 0),
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'paid', $payOrder->paid_at, [
|
||||
'label' => '支付成功',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_SUCCESS] ?? '成功'),
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'channel_id' => (int) $payOrder->channel_id,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'closed', $payOrder->closed_at, [
|
||||
'label' => '支付关闭',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_CLOSED] ?? '关闭'),
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'failed', $payOrder->failed_at, [
|
||||
'label' => '支付失败',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_FAILED] ?? '失败'),
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
'channel_error_msg' => (string) ($payOrder->channel_error_msg ?? ''),
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'pay_order', $payNo, 'timeout', $payOrder->timeout_at, [
|
||||
'label' => '支付超时',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::ORDER_STATUS_TIMEOUT] ?? '超时'),
|
||||
'pay_no' => $payNo,
|
||||
'biz_no' => (string) $payOrder->biz_no,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($refundOrders as $refundOrder) {
|
||||
$refundNo = (string) $refundOrder->refund_no;
|
||||
$statusTextMap = TradeConstant::refundStatusMap();
|
||||
$this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'created', $refundOrder->request_at, [
|
||||
'label' => '退款单创建',
|
||||
'status_text' => '创建',
|
||||
'refund_no' => $refundNo,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'processing', $refundOrder->processing_at, [
|
||||
'label' => '退款处理中',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_PROCESSING] ?? '处理中'),
|
||||
'refund_no' => $refundNo,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
'retry_count' => (int) ($refundOrder->retry_count ?? 0),
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'success', $refundOrder->succeeded_at, [
|
||||
'label' => '退款成功',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_SUCCESS] ?? '成功'),
|
||||
'refund_no' => $refundNo,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'refund_order', $refundNo, 'failed', $refundOrder->failed_at, [
|
||||
'label' => '退款失败',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::REFUND_STATUS_FAILED] ?? '失败'),
|
||||
'refund_no' => $refundNo,
|
||||
'pay_no' => (string) $refundOrder->pay_no,
|
||||
'last_error' => (string) ($refundOrder->last_error ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($settlementOrders as $settlementOrder) {
|
||||
$settleNo = (string) $settlementOrder->settle_no;
|
||||
$statusTextMap = TradeConstant::settlementStatusMap();
|
||||
$this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'generated', $settlementOrder->generated_at, [
|
||||
'label' => '清结算单生成',
|
||||
'status_text' => '生成',
|
||||
'settle_no' => $settleNo,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'accounted', $settlementOrder->accounted_at, [
|
||||
'label' => '清结算入账',
|
||||
'status_text' => '入账',
|
||||
'settle_no' => $settleNo,
|
||||
'accounted_amount' => (int) ($settlementOrder->accounted_amount ?? 0),
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'completed', $settlementOrder->completed_at, [
|
||||
'label' => '清结算完成',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::SETTLEMENT_STATUS_SETTLED] ?? '已清算'),
|
||||
'settle_no' => $settleNo,
|
||||
]);
|
||||
$this->pushEvent($events, $sortOrder, 'settlement_order', $settleNo, 'failed', $settlementOrder->failed_at, [
|
||||
'label' => '清结算失败',
|
||||
'status_text' => (string) ($statusTextMap[TradeConstant::SETTLEMENT_STATUS_REVERSED] ?? '已冲正'),
|
||||
'settle_no' => $settleNo,
|
||||
'fail_reason' => (string) ($settlementOrder->fail_reason ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($accountLedgers as $ledger) {
|
||||
$this->pushEvent($events, $sortOrder, 'ledger', (string) $ledger->ledger_no, 'recorded', $ledger->created_at, [
|
||||
'label' => '资金流水',
|
||||
'status_text' => (string) (LedgerConstant::eventTypeMap()[$ledger->event_type] ?? '流水'),
|
||||
'ledger_no' => (string) $ledger->ledger_no,
|
||||
'biz_no' => (string) $ledger->biz_no,
|
||||
'biz_type' => (int) $ledger->biz_type,
|
||||
'biz_type_text' => (string) (LedgerConstant::bizTypeMap()[$ledger->biz_type] ?? ''),
|
||||
'direction' => (int) $ledger->direction,
|
||||
'direction_text' => (string) (LedgerConstant::directionMap()[$ledger->direction] ?? ''),
|
||||
'amount' => (int) $ledger->amount,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($payCallbacks as $callback) {
|
||||
$this->pushEvent($events, $sortOrder, 'pay_callback', (string) ($callback['pay_no'] ?? ''), 'received', $callback['created_at'] ?? null, [
|
||||
'label' => '支付回调',
|
||||
'status_text' => (string) ($callback['callback_type_text'] ?? '回调'),
|
||||
'pay_no' => (string) ($callback['pay_no'] ?? ''),
|
||||
'channel_id' => (int) ($callback['channel_id'] ?? 0),
|
||||
'verify_status' => (int) ($callback['verify_status'] ?? 0),
|
||||
'verify_status_text' => (string) ($callback['verify_status_text'] ?? ''),
|
||||
'process_status' => (int) ($callback['process_status'] ?? 0),
|
||||
'process_status_text' => (string) ($callback['process_status_text'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
usort($events, static function (array $left, array $right): int {
|
||||
$cmp = strcmp((string) ($left['at'] ?? ''), (string) ($right['at'] ?? ''));
|
||||
if ($cmp !== 0) {
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
return ($left['_sort_order'] ?? 0) <=> ($right['_sort_order'] ?? 0);
|
||||
});
|
||||
|
||||
foreach ($events as &$event) {
|
||||
unset($event['_sort_order']);
|
||||
}
|
||||
unset($event);
|
||||
|
||||
return array_values($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条时间线事件。
|
||||
*/
|
||||
private function pushEvent(array &$events, int &$sortOrder, string $type, string $sourceNo, string $status, mixed $at, array $payload = []): void
|
||||
{
|
||||
$atText = $this->formatDateTime($at);
|
||||
if ($atText === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'type' => $type,
|
||||
'source_no' => $sourceNo,
|
||||
'status' => $status,
|
||||
'status_text' => (string) ($payload['status_text'] ?? ''),
|
||||
'label' => (string) ($payload['label'] ?? ''),
|
||||
'at' => $atText,
|
||||
'payload' => $payload,
|
||||
'_sort_order' => $sortOrder++,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总模型列表中的数值字段。
|
||||
*/
|
||||
private function sumBy(array $items, string $field): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($items as $item) {
|
||||
$total += (int) ($item->{$field} ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
269
app/service/payment/trace/TradeTraceService.php
Normal file
269
app/service/payment/trace/TradeTraceService.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\payment\trace;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\NotifyConstant;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\payment\BizOrder;
|
||||
use app\repository\payment\trade\BizOrderRepository;
|
||||
use app\repository\account\ledger\MerchantAccountLedgerRepository;
|
||||
use app\repository\ops\log\PayCallbackLogRepository;
|
||||
use app\repository\payment\trade\PayOrderRepository;
|
||||
use app\repository\payment\trade\RefundOrderRepository;
|
||||
use app\repository\payment\settlement\SettlementOrderRepository;
|
||||
|
||||
/**
|
||||
* 跨域交易追踪查询服务。
|
||||
*/
|
||||
class TradeTraceService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected TradeTraceReportService $tradeTraceReportService,
|
||||
protected BizOrderRepository $bizOrderRepository,
|
||||
protected PayOrderRepository $payOrderRepository,
|
||||
protected RefundOrderRepository $refundOrderRepository,
|
||||
protected SettlementOrderRepository $settlementOrderRepository,
|
||||
protected MerchantAccountLedgerRepository $merchantAccountLedgerRepository,
|
||||
protected PayCallbackLogRepository $payCallbackLogRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据追踪号查询完整交易链路。
|
||||
*/
|
||||
public function queryByTraceNo(string $traceNo): array
|
||||
{
|
||||
$traceNo = trim($traceNo);
|
||||
if ($traceNo === '') {
|
||||
throw new ValidationException('trace_no 不能为空');
|
||||
}
|
||||
|
||||
$matchedBy = 'trace_no';
|
||||
$bizOrder = $this->bizOrderRepository->findByTraceNo($traceNo);
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($traceNo);
|
||||
if ($bizOrder) {
|
||||
$matchedBy = 'biz_no';
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedTraceNo = $traceNo;
|
||||
if ($bizOrder) {
|
||||
$resolvedTraceNo = (string) ($bizOrder->trace_no ?: $bizOrder->biz_no);
|
||||
}
|
||||
|
||||
$payOrders = $this->loadPayOrders($resolvedTraceNo, $bizOrder);
|
||||
$refundOrders = $this->loadRefundOrders($resolvedTraceNo, $bizOrder);
|
||||
$settlementOrders = $this->loadSettlementOrders($resolvedTraceNo);
|
||||
|
||||
if (!$bizOrder) {
|
||||
$bizOrder = $this->deriveBizOrder($payOrders, $refundOrders);
|
||||
if ($bizOrder) {
|
||||
$matchedBy = $matchedBy === 'trace_no' ? 'derived' : $matchedBy;
|
||||
$resolvedTraceNo = (string) ($bizOrder->trace_no ?: $bizOrder->biz_no);
|
||||
$payOrders = $this->loadPayOrders($resolvedTraceNo, $bizOrder);
|
||||
$refundOrders = $this->loadRefundOrders($resolvedTraceNo, $bizOrder);
|
||||
$settlementOrders = $this->loadSettlementOrders($resolvedTraceNo);
|
||||
}
|
||||
}
|
||||
|
||||
$payCallbacks = $this->loadPayCallbacks($payOrders);
|
||||
$accountLedgers = $this->loadLedgers($resolvedTraceNo, $bizOrder, $payOrders, $refundOrders, $settlementOrders);
|
||||
if (empty($accountLedgers) && $resolvedTraceNo !== $traceNo) {
|
||||
$accountLedgers = $this->loadLedgers($traceNo, $bizOrder, $payOrders, $refundOrders, $settlementOrders);
|
||||
}
|
||||
|
||||
if (
|
||||
$bizOrder === null
|
||||
&& empty($payOrders)
|
||||
&& empty($refundOrders)
|
||||
&& empty($settlementOrders)
|
||||
&& empty($accountLedgers)
|
||||
&& empty($payCallbacks)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'trace_no' => $traceNo,
|
||||
'resolved_trace_no' => $resolvedTraceNo,
|
||||
'matched_by' => $matchedBy,
|
||||
'biz_order' => $bizOrder,
|
||||
'pay_orders' => $payOrders,
|
||||
'refund_orders' => $refundOrders,
|
||||
'settlement_orders' => $settlementOrders,
|
||||
'account_ledgers' => $accountLedgers,
|
||||
'pay_callbacks' => $payCallbacks,
|
||||
'summary' => $this->tradeTraceReportService->buildSummary($bizOrder, $payOrders, $refundOrders, $settlementOrders, $accountLedgers, $payCallbacks),
|
||||
'timeline' => $this->tradeTraceReportService->buildTimeline($bizOrder, $payOrders, $refundOrders, $settlementOrders, $accountLedgers, $payCallbacks),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载支付单列表。
|
||||
*/
|
||||
private function loadPayOrders(string $traceNo, ?BizOrder $bizOrder): array
|
||||
{
|
||||
$items = $this->collectionToArray($this->payOrderRepository->listByTraceNo($traceNo));
|
||||
if (!empty($items)) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
if ($bizOrder) {
|
||||
return $this->collectionToArray($this->payOrderRepository->listByBizNo((string) $bizOrder->biz_no));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载退款单列表。
|
||||
*/
|
||||
private function loadRefundOrders(string $traceNo, ?BizOrder $bizOrder): array
|
||||
{
|
||||
$items = $this->collectionToArray($this->refundOrderRepository->listByTraceNo($traceNo));
|
||||
if (!empty($items)) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
if ($bizOrder) {
|
||||
return $this->collectionToArray($this->refundOrderRepository->listByBizNo((string) $bizOrder->biz_no));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载清结算单列表。
|
||||
*/
|
||||
private function loadSettlementOrders(string $traceNo): array
|
||||
{
|
||||
return $this->collectionToArray($this->settlementOrderRepository->listByTraceNo($traceNo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载支付回调日志列表。
|
||||
*/
|
||||
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),
|
||||
'pay_no' => (string) $callback->pay_no,
|
||||
'channel_id' => (int) $callback->channel_id,
|
||||
'callback_type' => (int) $callback->callback_type,
|
||||
'callback_type_text' => (string) (NotifyConstant::callbackTypeMap()[$callback->callback_type] ?? ''),
|
||||
'request_data' => $callback->request_data,
|
||||
'verify_status' => (int) $callback->verify_status,
|
||||
'verify_status_text' => (string) (NotifyConstant::verifyStatusMap()[$callback->verify_status] ?? ''),
|
||||
'process_status' => (int) $callback->process_status,
|
||||
'process_status_text' => (string) (NotifyConstant::processStatusMap()[$callback->process_status] ?? ''),
|
||||
'process_result' => $callback->process_result,
|
||||
'created_at' => $callback->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($callbacks, static function ($left, $right): int {
|
||||
return ($right['id'] ?? 0) <=> ($left['id'] ?? 0);
|
||||
});
|
||||
|
||||
return $callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载资金流水列表。
|
||||
*/
|
||||
private function loadLedgers(string $traceNo, ?BizOrder $bizOrder, array $payOrders, array $refundOrders, array $settlementOrders): array
|
||||
{
|
||||
$ledgers = [];
|
||||
$seen = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
foreach ($payOrders as $payOrder) {
|
||||
$bizNos[] = (string) ($payOrder->pay_no ?? '');
|
||||
}
|
||||
|
||||
foreach ($refundOrders as $refundOrder) {
|
||||
$bizNos[] = (string) ($refundOrder->refund_no ?? '');
|
||||
}
|
||||
|
||||
foreach ($settlementOrders as $settlementOrder) {
|
||||
$bizNos[] = (string) ($settlementOrder->settle_no ?? '');
|
||||
}
|
||||
|
||||
$bizNos = array_values(array_filter(array_unique($bizNos)));
|
||||
foreach ($bizNos as $bizNo) {
|
||||
foreach ($this->collectionToArray($this->merchantAccountLedgerRepository->listByBizNo($bizNo)) as $ledger) {
|
||||
$ledgerNo = (string) ($ledger->ledger_no ?? '');
|
||||
if ($ledgerNo !== '' && isset($seen[$ledgerNo])) {
|
||||
continue;
|
||||
}
|
||||
if ($ledgerNo !== '') {
|
||||
$seen[$ledgerNo] = true;
|
||||
}
|
||||
$ledgers[] = $ledger;
|
||||
}
|
||||
}
|
||||
|
||||
usort($ledgers, static function ($left, $right): int {
|
||||
return ($right->id ?? 0) <=> ($left->id ?? 0);
|
||||
});
|
||||
|
||||
return $ledgers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从支付单或退款单反推出业务单。
|
||||
*/
|
||||
private function deriveBizOrder(array $payOrders, array $refundOrders): ?BizOrder
|
||||
{
|
||||
if (!empty($payOrders)) {
|
||||
$bizNo = (string) ($payOrders[0]->biz_no ?? '');
|
||||
if ($bizNo !== '') {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
if ($bizOrder) {
|
||||
return $bizOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($refundOrders)) {
|
||||
$bizNo = (string) ($refundOrders[0]->biz_no ?? '');
|
||||
if ($bizNo !== '') {
|
||||
$bizOrder = $this->bizOrderRepository->findByBizNo($bizNo);
|
||||
if ($bizOrder) {
|
||||
return $bizOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可迭代对象转换为普通数组。
|
||||
*/
|
||||
private function collectionToArray(iterable $items): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($items as $item) {
|
||||
$rows[] = $item;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
}
|
||||
113
app/service/system/access/AdminAuthService.php
Normal file
113
app/service/system/access/AdminAuthService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\system\access;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\AuthConstant;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\common\util\JwtTokenManager;
|
||||
use app\exception\ValidationException;
|
||||
use app\model\admin\AdminUser;
|
||||
use app\repository\system\user\AdminUserRepository;
|
||||
|
||||
/**
|
||||
* 管理员认证服务。
|
||||
*
|
||||
* 负责管理员账号校验、JWT 签发、登录态校验和主动注销。
|
||||
*/
|
||||
class AdminAuthService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入对应依赖。
|
||||
*/
|
||||
public function __construct(
|
||||
protected AdminUserRepository $adminUserRepository,
|
||||
protected JwtTokenManager $jwtTokenManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验中间件传入的管理员登录 token。
|
||||
*/
|
||||
public function authenticateToken(string $token, string $ip = '', string $userAgent = ''): ?AdminUser
|
||||
{
|
||||
$result = $this->jwtTokenManager->verify('admin', $token, $ip, $userAgent);
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$adminId = (int) ($result['session']['admin_id'] ?? $result['claims']['sub'] ?? 0);
|
||||
if ($adminId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var AdminUser|null $admin */
|
||||
$admin = $this->adminUserRepository->find($adminId);
|
||||
if (!$admin || (int) $admin->status !== CommonConstant::STATUS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验管理员账号密码并签发 JWT。
|
||||
*/
|
||||
public function authenticateCredentials(string $username, string $password, string $ip = '', string $userAgent = ''): array
|
||||
{
|
||||
$admin = $this->adminUserRepository->findByUsername($username);
|
||||
if (!$admin || (int) $admin->status !== CommonConstant::STATUS_ENABLED) {
|
||||
throw new ValidationException('管理员账号或密码错误');
|
||||
}
|
||||
|
||||
if (!password_verify($password, (string) $admin->password_hash)) {
|
||||
throw new ValidationException('管理员账号或密码错误');
|
||||
}
|
||||
|
||||
$admin->last_login_at = $this->now();
|
||||
$admin->last_login_ip = $ip;
|
||||
$admin->save();
|
||||
|
||||
return $this->issueToken((int) $admin->id, 86400, $ip, $userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销当前管理员登录 token。
|
||||
*/
|
||||
public function revokeToken(string $token): bool
|
||||
{
|
||||
return $this->jwtTokenManager->revoke('admin', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发新的管理员登录 token。
|
||||
*/
|
||||
public function issueToken(int $adminId, int $ttlSeconds = 86400, string $ip = '', string $userAgent = ''): array
|
||||
{
|
||||
/** @var AdminUser|null $admin */
|
||||
$admin = $this->adminUserRepository->find($adminId);
|
||||
if (!$admin) {
|
||||
throw new ValidationException('管理员不存在');
|
||||
}
|
||||
|
||||
$issued = $this->jwtTokenManager->issue('admin', [
|
||||
'sub' => (string) $adminId,
|
||||
'admin_id' => $adminId,
|
||||
'username' => (string) $admin->username,
|
||||
'is_super' => (int) $admin->is_super,
|
||||
], [
|
||||
'admin_id' => $adminId,
|
||||
'admin_username' => (string) $admin->username,
|
||||
'real_name' => (string) $admin->real_name,
|
||||
'is_super' => (int) $admin->is_super,
|
||||
'last_login_ip' => $ip,
|
||||
'user_agent' => $userAgent,
|
||||
], $ttlSeconds);
|
||||
|
||||
return [
|
||||
'token' => $issued['token'],
|
||||
'expires_in' => $issued['expires_in'],
|
||||
'admin' => $admin,
|
||||
];
|
||||
}
|
||||
}
|
||||
242
app/service/system/config/SystemConfigDefinitionService.php
Normal file
242
app/service/system/config/SystemConfigDefinitionService.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\system\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use RuntimeException;
|
||||
|
||||
class SystemConfigDefinitionService extends BaseService
|
||||
{
|
||||
protected const VIRTUAL_FIELD_PREFIX = '__';
|
||||
|
||||
/**
|
||||
* 已解析的标签页缓存。
|
||||
*/
|
||||
protected ?array $tabCache = null;
|
||||
|
||||
/**
|
||||
* 标签页键到定义的缓存。
|
||||
*/
|
||||
protected ?array $tabMapCache = null;
|
||||
|
||||
public function tabs(): array
|
||||
{
|
||||
if ($this->tabCache !== null) {
|
||||
return $this->tabCache;
|
||||
}
|
||||
|
||||
$definitions = (array) config('system_config', []);
|
||||
$tabs = [];
|
||||
$seenKeys = [];
|
||||
$seenFields = [];
|
||||
|
||||
foreach ($definitions as $groupCode => $definition) {
|
||||
if (!is_array($definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tab = $this->normalizeTab((string) $groupCode, $definition);
|
||||
if ($tab === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $tab['key'];
|
||||
if (isset($seenKeys[$key])) {
|
||||
throw new RuntimeException(sprintf('系统配置标签 key 重复:%s', $key));
|
||||
}
|
||||
|
||||
foreach ($tab['rules'] as $rule) {
|
||||
$field = (string) ($rule['field'] ?? '');
|
||||
if ($field === '' || $this->isVirtualField($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seenFields[$field])) {
|
||||
throw new RuntimeException(sprintf('系统配置项 key 重复:%s', $field));
|
||||
}
|
||||
|
||||
$seenFields[$field] = true;
|
||||
}
|
||||
|
||||
$seenKeys[$key] = true;
|
||||
$tabs[] = $tab;
|
||||
}
|
||||
|
||||
usort($tabs, static function (array $left, array $right): int {
|
||||
$leftSort = (int) ($left['sort'] ?? 0);
|
||||
$rightSort = (int) ($right['sort'] ?? 0);
|
||||
|
||||
return $leftSort <=> $rightSort;
|
||||
});
|
||||
|
||||
$this->tabCache = $tabs;
|
||||
$this->tabMapCache = [];
|
||||
|
||||
foreach ($tabs as $tab) {
|
||||
$key = (string) ($tab['key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$this->tabMapCache[$key] = $tab;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tabCache;
|
||||
}
|
||||
|
||||
public function tab(string $groupCode): ?array
|
||||
{
|
||||
$groupCode = strtolower(trim($groupCode));
|
||||
if ($groupCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->tabs();
|
||||
|
||||
return $this->tabMapCache[$groupCode] ?? null;
|
||||
}
|
||||
|
||||
public function hydrateRules(array $tab, array $values): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = (string) ($rule['field'] ?? '');
|
||||
if ($field === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isVirtualField($field)) {
|
||||
$rule['value'] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? '');
|
||||
}
|
||||
$rules[] = $rule;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function extractFormData(array $tab, array $values): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = (string) ($rule['field'] ?? '');
|
||||
if ($field === '' || $this->isVirtualField($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data[$field] = array_key_exists($field, $values) ? $values[$field] : ($rule['value'] ?? '');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function requiredFieldMessages(array $tab): array
|
||||
{
|
||||
$messages = [];
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field === '' || $this->isVirtualField($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) ($rule['validate'] ?? []) as $validateRule) {
|
||||
if (!is_array($validateRule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($validateRule['required'])) {
|
||||
$messages[$field] = (string) ($validateRule['message'] ?? sprintf('%s 不能为空', (string) ($rule['title'] ?? $field)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
private function normalizeTab(string $groupCode, array $definition): ?array
|
||||
{
|
||||
$key = strtolower(trim((string) ($definition['key'] ?? $groupCode)));
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rules = [];
|
||||
foreach ((array) ($definition['rules'] ?? []) as $rule) {
|
||||
$normalizedRule = $this->normalizeRule($rule);
|
||||
if ($normalizedRule !== null) {
|
||||
$rules[] = $normalizedRule;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'title' => (string) ($definition['title'] ?? $key),
|
||||
'icon' => (string) ($definition['icon'] ?? ''),
|
||||
'description' => (string) ($definition['description'] ?? ''),
|
||||
'sort' => (int) ($definition['sort'] ?? 0),
|
||||
'disabled' => (bool) ($definition['disabled'] ?? false),
|
||||
'submitText' => (string) ($definition['submitText'] ?? '保存配置'),
|
||||
'refreshAfterSubmit' => (bool) ($definition['refreshAfterSubmit'] ?? true),
|
||||
'rules' => $rules,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRule(mixed $rule): ?array
|
||||
{
|
||||
if (!is_array($rule)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
foreach ((array) ($rule['options'] ?? []) as $option) {
|
||||
if (!is_array($option)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'label' => (string) ($option['label'] ?? ''),
|
||||
'value' => (string) ($option['value'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$validate = [];
|
||||
foreach ((array) ($rule['validate'] ?? []) as $validateRule) {
|
||||
if (!is_array($validateRule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validate[] = $validateRule;
|
||||
}
|
||||
|
||||
$normalized = $rule;
|
||||
$normalized['type'] = (string) ($rule['type'] ?? 'input');
|
||||
$normalized['field'] = $field;
|
||||
$normalized['title'] = (string) ($rule['title'] ?? $field);
|
||||
$normalized['value'] = (string) ($rule['value'] ?? '');
|
||||
$normalized['props'] = is_array($rule['props'] ?? null) ? $rule['props'] : [];
|
||||
$normalized['options'] = $options;
|
||||
$normalized['validate'] = $validate;
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function isVirtualField(string $field): bool
|
||||
{
|
||||
return str_starts_with($field, self::VIRTUAL_FIELD_PREFIX);
|
||||
}
|
||||
}
|
||||
160
app/service/system/config/SystemConfigPageService.php
Normal file
160
app/service/system/config/SystemConfigPageService.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\system\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\system\config\SystemConfigRepository;
|
||||
use Webman\Event\Event;
|
||||
|
||||
class SystemConfigPageService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemConfigRepository $systemConfigRepository,
|
||||
protected SystemConfigDefinitionService $systemConfigDefinitionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function tabs(): array
|
||||
{
|
||||
$tabs = [];
|
||||
foreach ($this->systemConfigDefinitionService->tabs() as $tab) {
|
||||
unset($tab['rules']);
|
||||
$tabs[] = $tab;
|
||||
}
|
||||
|
||||
$defaultKey = '';
|
||||
foreach ($tabs as $tab) {
|
||||
if (!empty($tab['disabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$defaultKey = (string) ($tab['key'] ?? '');
|
||||
if ($defaultKey !== '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'defaultKey' => $defaultKey !== '' ? $defaultKey : (string) ($tabs[0]['key'] ?? ''),
|
||||
'tabs' => $tabs,
|
||||
];
|
||||
}
|
||||
|
||||
public function detail(string $groupCode): array
|
||||
{
|
||||
$tab = $this->systemConfigDefinitionService->tab($groupCode);
|
||||
if (!$tab) {
|
||||
throw new ValidationException('系统配置标签不存在');
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field !== '' && !str_starts_with($field, '__')) {
|
||||
$keys[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_values(array_unique($keys));
|
||||
if ($keys === []) {
|
||||
$rowMap = [];
|
||||
} else {
|
||||
$rows = $this->systemConfigRepository->query()
|
||||
->whereIn('config_key', $keys)
|
||||
->get(['config_key', 'config_value']);
|
||||
|
||||
$rowMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$tab['rules'] = $this->systemConfigDefinitionService->hydrateRules($tab, $rowMap);
|
||||
$tab['formData'] = $this->systemConfigDefinitionService->extractFormData($tab, $rowMap);
|
||||
|
||||
return $tab;
|
||||
}
|
||||
|
||||
public function save(string $groupCode, array $values): array
|
||||
{
|
||||
$tab = $this->systemConfigDefinitionService->tab($groupCode);
|
||||
if (!$tab) {
|
||||
throw new ValidationException('系统配置标签不存在');
|
||||
}
|
||||
|
||||
$formData = $this->systemConfigDefinitionService->extractFormData($tab, $values);
|
||||
$this->validateRequiredValues($tab, $formData);
|
||||
|
||||
$this->transaction(function () use ($tab, $formData): void {
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field === '' || str_starts_with($field, '__')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->stringifyValue($formData[$field] ?? '');
|
||||
$this->systemConfigRepository->updateOrCreate(
|
||||
['config_key' => $field],
|
||||
[
|
||||
'group_code' => (string) $tab['key'],
|
||||
'config_value' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Event::emit('system.config.changed', [
|
||||
'group_code' => (string) $tab['key'],
|
||||
]);
|
||||
|
||||
return $this->detail((string) $tab['key']);
|
||||
}
|
||||
|
||||
protected function validateRequiredValues(array $tab, array $values): void
|
||||
{
|
||||
$messages = $this->systemConfigDefinitionService->requiredFieldMessages($tab);
|
||||
|
||||
foreach ($messages as $field => $message) {
|
||||
$value = $values[$field] ?? '';
|
||||
if ($this->isEmptyValue($value)) {
|
||||
throw new ValidationException($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function isEmptyValue(mixed $value): bool
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value === [];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return trim($value) === '';
|
||||
}
|
||||
|
||||
return $value === null || $value === '';
|
||||
}
|
||||
|
||||
protected function stringifyValue(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_array($value) || is_object($value)) {
|
||||
throw new ValidationException('系统配置值暂不支持复杂类型');
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
}
|
||||
124
app/service/system/config/SystemConfigRuntimeService.php
Normal file
124
app/service/system/config/SystemConfigRuntimeService.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\system\config;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\repository\system\config\SystemConfigRepository;
|
||||
use support\Cache;
|
||||
use Throwable;
|
||||
|
||||
class SystemConfigRuntimeService extends BaseService
|
||||
{
|
||||
protected const CACHE_KEY = 'system_config:all';
|
||||
|
||||
public function __construct(
|
||||
protected SystemConfigRepository $systemConfigRepository,
|
||||
protected SystemConfigDefinitionService $systemConfigDefinitionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function all(bool $refresh = false): array
|
||||
{
|
||||
if (!$refresh) {
|
||||
$cached = $this->readCache();
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->refresh();
|
||||
}
|
||||
|
||||
public function get(string $configKey, mixed $default = '', bool $refresh = false): string
|
||||
{
|
||||
$configKey = strtolower(trim($configKey));
|
||||
if ($configKey === '') {
|
||||
return (string) $default;
|
||||
}
|
||||
|
||||
$values = $this->all($refresh);
|
||||
|
||||
return (string) ($values[$configKey] ?? $default);
|
||||
}
|
||||
|
||||
public function refresh(): array
|
||||
{
|
||||
$values = $this->buildValueMap();
|
||||
$this->writeCache($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
protected function buildValueMap(): array
|
||||
{
|
||||
$values = [];
|
||||
$tabs = $this->systemConfigDefinitionService->tabs();
|
||||
$keys = [];
|
||||
|
||||
foreach ($tabs as $tab) {
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field !== '' && !str_starts_with($field, '__')) {
|
||||
$keys[] = $field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_values(array_unique($keys));
|
||||
if ($keys === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->systemConfigRepository->query()
|
||||
->whereIn('config_key', $keys)
|
||||
->get(['config_key', 'config_value']);
|
||||
|
||||
$rowMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowMap[strtolower((string) $row->config_key)] = (string) ($row->config_value ?? '');
|
||||
}
|
||||
|
||||
foreach ($tabs as $tab) {
|
||||
foreach ((array) ($tab['rules'] ?? []) as $rule) {
|
||||
if (!is_array($rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
if ($field === '' || str_starts_with($field, '__')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$field] = array_key_exists($field, $rowMap)
|
||||
? (string) $rowMap[$field]
|
||||
: (string) ($rule['value'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
protected function readCache(): ?array
|
||||
{
|
||||
try {
|
||||
$raw = Cache::get(self::CACHE_KEY);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
protected function writeCache(array $values): void
|
||||
{
|
||||
try {
|
||||
Cache::set(self::CACHE_KEY, $values);
|
||||
} catch (Throwable) {
|
||||
// Redis 不可用时不阻塞主流程。
|
||||
}
|
||||
}
|
||||
}
|
||||
196
app/service/system/user/AdminUserService.php
Normal file
196
app/service/system/user/AdminUserService.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\system\user;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\CommonConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\model\admin\AdminUser;
|
||||
use app\repository\system\user\AdminUserRepository;
|
||||
|
||||
/**
|
||||
* 管理员用户管理服务。
|
||||
*
|
||||
* 负责管理员账号的列表查询、新增、修改和删除,以及密码字段的统一处理。
|
||||
*/
|
||||
class AdminUserService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造函数,注入管理员用户仓库。
|
||||
*/
|
||||
public function __construct(
|
||||
protected AdminUserRepository $adminUserRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询管理员用户。
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->adminUserRepository->query()->from('ma_admin_user as u');
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('u.username', 'like', '%' . $keyword . '%')
|
||||
->orWhere('u.real_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('u.mobile', 'like', '%' . $keyword . '%')
|
||||
->orWhere('u.email', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$status = (string) ($filters['status'] ?? '');
|
||||
if ($status !== '') {
|
||||
$query->where('u.status', (int) $status);
|
||||
}
|
||||
|
||||
$isSuper = (string) ($filters['is_super'] ?? '');
|
||||
if ($isSuper !== '') {
|
||||
$query->where('u.is_super', (int) $isSuper);
|
||||
}
|
||||
|
||||
$paginator = $query
|
||||
->select([
|
||||
'u.id',
|
||||
'u.username',
|
||||
'u.real_name',
|
||||
'u.mobile',
|
||||
'u.email',
|
||||
'u.is_super',
|
||||
'u.status',
|
||||
'u.last_login_at',
|
||||
'u.last_login_ip',
|
||||
'u.remark',
|
||||
'u.created_at',
|
||||
'u.updated_at',
|
||||
])
|
||||
->orderByDesc('u.id')
|
||||
->paginate(max(1, $pageSize), ['*'], 'page', max(1, $page));
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$row->status_text = (string) ((int) $row->status === CommonConstant::STATUS_ENABLED ? '启用' : '禁用');
|
||||
$row->is_super_text = (string) ((int) $row->is_super === 1 ? '超级管理员' : '普通管理员');
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查询管理员用户。
|
||||
*/
|
||||
public function findById(int $id): ?AdminUser
|
||||
{
|
||||
return $this->adminUserRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增管理员用户。
|
||||
*/
|
||||
public function create(array $data): AdminUser
|
||||
{
|
||||
return $this->adminUserRepository->create($this->normalizePayload($data, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改管理员用户。
|
||||
*/
|
||||
public function update(int $id, array $data): ?AdminUser
|
||||
{
|
||||
$current = $this->adminUserRepository->find($id);
|
||||
if (!$current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->adminUserRepository->updateById($id, $this->normalizePayload($data, true))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->adminUserRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除管理员用户。
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->adminUserRepository->deleteById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前管理员资料。
|
||||
*/
|
||||
public function profile(int $adminId, string $adminUsername = ''): array
|
||||
{
|
||||
$admin = $this->adminUserRepository->find($adminId);
|
||||
if (!$admin) {
|
||||
throw new ResourceNotFoundException('管理员不存在', ['admin_id' => $adminId]);
|
||||
}
|
||||
|
||||
$isSuper = (int) $admin->is_super === 1;
|
||||
$role = [
|
||||
'code' => 'admin',
|
||||
'name' => $isSuper ? '超级管理员' : '普通管理员',
|
||||
'admin' => $isSuper,
|
||||
'disabled' => false,
|
||||
];
|
||||
|
||||
$user = [
|
||||
'id' => (int) $admin->id,
|
||||
'deptId' => '0',
|
||||
'deptName' => '管理中心',
|
||||
'userName' => (string) ($admin->username !== '' ? $admin->username : trim($adminUsername)),
|
||||
'nickName' => (string) ($admin->real_name !== '' ? $admin->real_name : $admin->username),
|
||||
'email' => (string) ($admin->email ?? ''),
|
||||
'phone' => (string) ($admin->mobile ?? ''),
|
||||
'sex' => 2,
|
||||
'avatar' => '',
|
||||
'status' => (int) $admin->status,
|
||||
'description' => trim((string) ($admin->remark ?? '')) !== '' ? (string) $admin->remark : '平台后台管理员账号',
|
||||
'roles' => [$role],
|
||||
'loginIp' => (string) ($admin->last_login_ip ?? ''),
|
||||
'loginDate' => $this->formatDateTime($admin->last_login_at ?? null),
|
||||
'createBy' => '系统',
|
||||
'createTime' => $this->formatDateTime($admin->created_at ?? null),
|
||||
'updateBy' => null,
|
||||
'updateTime' => $this->formatDateTime($admin->updated_at ?? null),
|
||||
'admin' => $isSuper,
|
||||
];
|
||||
|
||||
return [
|
||||
'admin_id' => (int) $admin->id,
|
||||
'admin_username' => (string) ($admin->username !== '' ? $admin->username : trim($adminUsername)),
|
||||
'user' => $user,
|
||||
'roles' => ['admin'],
|
||||
'permissions' => $isSuper ? ['*:*:*'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一整理写入字段,并处理密码哈希。
|
||||
*/
|
||||
private function normalizePayload(array $data, bool $isUpdate): array
|
||||
{
|
||||
$payload = [
|
||||
'username' => trim((string) ($data['username'] ?? '')),
|
||||
'real_name' => trim((string) ($data['real_name'] ?? '')),
|
||||
'mobile' => trim((string) ($data['mobile'] ?? '')),
|
||||
'email' => trim((string) ($data['email'] ?? '')),
|
||||
'is_super' => (int) ($data['is_super'] ?? 0),
|
||||
'status' => (int) ($data['status'] ?? CommonConstant::STATUS_ENABLED),
|
||||
'remark' => trim((string) ($data['remark'] ?? '')),
|
||||
];
|
||||
|
||||
$password = trim((string) ($data['password'] ?? ''));
|
||||
if ($password !== '') {
|
||||
$payload['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
} elseif (!$isUpdate) {
|
||||
$payload['password_hash'] = '';
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user