重构初始化

This commit is contained in:
技术老胡
2026-04-15 11:45:46 +08:00
parent 72d72d735b
commit 7612026773
381 changed files with 28287 additions and 14717 deletions

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

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

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

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

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

View 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();
}
}

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

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

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

View File

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

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

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

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

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

View 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();
}
}
}

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

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

View 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();
}
}

View 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();
}
}

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

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

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

View 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();
}
}

View 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();
}
}

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

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

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

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

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

View 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];
}
}

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

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

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

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

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

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