mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-03-25 20:24:27 +08:00
codex基础代码更新
This commit is contained in:
@@ -6,6 +6,7 @@ namespace app\common\contracts;
|
||||
|
||||
use app\exceptions\PaymentException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付插件接口
|
||||
@@ -81,4 +82,8 @@ interface PaymentInterface
|
||||
* @throws PaymentException 验签失败、数据异常等
|
||||
*/
|
||||
public function notify(Request $request): array;
|
||||
|
||||
public function notifySuccess(): string|Response;
|
||||
|
||||
public function notifyFail(): string|Response;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\common\contracts\PaymentInterface;
|
||||
use app\exceptions\PaymentException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Yansongda\Pay\Pay;
|
||||
use Yansongda\Supports\Collection;
|
||||
|
||||
@@ -267,4 +268,13 @@ class AlipayPayment extends BasePayment implements PaymentInterface
|
||||
throw new PaymentException('支付宝回调验签失败:' . $e->getMessage(), 402);
|
||||
}
|
||||
}
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use app\common\base\BasePayment;
|
||||
use app\common\contracts\PaymentInterface;
|
||||
use app\exceptions\PaymentException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 拉卡拉支付插件(最小可用示例)
|
||||
@@ -74,5 +75,13 @@ class LakalaPayment extends BasePayment implements PaymentInterface
|
||||
{
|
||||
throw new PaymentException('LakalaPayment::notify 暂未实现', 402);
|
||||
}
|
||||
}
|
||||
public function notifySuccess(): string|Response
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function notifyFail(): string|Response
|
||||
{
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,162 +3,649 @@
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\repositories\{PaymentChannelRepository, PaymentMethodRepository};
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
use app\repositories\PaymentMethodRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
use app\services\ChannelRoutePolicyService;
|
||||
use app\services\ChannelRouterService;
|
||||
use app\services\PluginService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 通道管理控制器
|
||||
*/
|
||||
class ChannelController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PluginService $pluginService,
|
||||
protected ChannelRoutePolicyService $routePolicyService,
|
||||
protected ChannelRouterService $channelRouterService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通道列表
|
||||
* GET /adminapi/channel/list
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$merchantId = (int)$request->get('merchant_id', 0);
|
||||
$appId = (int)$request->get('app_id', 0);
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
|
||||
$where = [];
|
||||
if ($merchantId > 0) {
|
||||
$where['merchant_id'] = $merchantId;
|
||||
}
|
||||
if ($appId > 0) {
|
||||
$where['merchant_app_id'] = $appId;
|
||||
}
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findByCode($methodCode);
|
||||
if ($method) {
|
||||
$where['method_id'] = $method->id;
|
||||
}
|
||||
}
|
||||
|
||||
$page = (int)($request->get('page', 1));
|
||||
$pageSize = (int)($request->get('page_size', 10));
|
||||
|
||||
$result = $this->channelRepository->paginate($where, $page, $pageSize);
|
||||
|
||||
return $this->success($result);
|
||||
$page = max(1, (int)$request->get('page', 1));
|
||||
$pageSize = max(1, (int)$request->get('page_size', 10));
|
||||
$filters = $this->resolveChannelFilters($request, true);
|
||||
return $this->page($this->channelRepository->searchPaginate($filters, $page, $pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通道详情
|
||||
* GET /adminapi/channel/detail
|
||||
*/
|
||||
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if (!$id) {
|
||||
if ($id <= 0) {
|
||||
return $this->fail('通道ID不能为空', 400);
|
||||
}
|
||||
|
||||
|
||||
$channel = $this->channelRepository->find($id);
|
||||
if (!$channel) {
|
||||
return $this->fail('通道不存在', 404);
|
||||
}
|
||||
|
||||
|
||||
$methodCode = '';
|
||||
if ($channel->method_id) {
|
||||
$method = $this->methodRepository->find($channel->method_id);
|
||||
$methodCode = $method ? $method->method_code : '';
|
||||
if ((int)$channel->method_id > 0) {
|
||||
$method = $this->methodRepository->find((int)$channel->method_id);
|
||||
$methodCode = $method ? (string)$method->method_code : '';
|
||||
}
|
||||
|
||||
try {
|
||||
$configSchema = $this->pluginService->getConfigSchema($channel->plugin_code, $methodCode);
|
||||
|
||||
// 合并当前配置值
|
||||
$configSchema = $this->pluginService->getConfigSchema((string)$channel->plugin_code, $methodCode);
|
||||
$currentConfig = $channel->getConfigArray();
|
||||
if (isset($configSchema['fields']) && is_array($configSchema['fields'])) {
|
||||
foreach ($configSchema['fields'] as &$field) {
|
||||
if (isset($field['field']) && isset($currentConfig[$field['field']])) {
|
||||
$field['value'] = $currentConfig[$field['field']];
|
||||
$fieldName = $field['field'] ?? '';
|
||||
if ($fieldName !== '' && array_key_exists($fieldName, $currentConfig)) {
|
||||
$field['value'] = $currentConfig[$fieldName];
|
||||
}
|
||||
}
|
||||
unset($field);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'channel' => $channel,
|
||||
'config_schema' => $configSchema,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->success([
|
||||
'channel' => $channel,
|
||||
'config_schema' => ['fields' => []],
|
||||
]);
|
||||
$configSchema = ['fields' => []];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'channel' => $channel,
|
||||
'method_code' => $methodCode,
|
||||
'config_schema' => $configSchema,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存通道
|
||||
* POST /adminapi/channel/save
|
||||
*/
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$pluginCode = $data['plugin_code'] ?? '';
|
||||
$methodCode = $data['method_code'] ?? '';
|
||||
$merchantId = (int)($data['merchant_id'] ?? 0);
|
||||
$merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0));
|
||||
$channelCode = trim((string)($data['channel_code'] ?? ($data['chan_code'] ?? '')));
|
||||
$channelName = trim((string)($data['channel_name'] ?? ($data['chan_name'] ?? '')));
|
||||
$pluginCode = trim((string)($data['plugin_code'] ?? ''));
|
||||
$methodCode = trim((string)($data['method_code'] ?? ''));
|
||||
$enabledProducts = $data['enabled_products'] ?? [];
|
||||
|
||||
if (empty($pluginCode) || empty($methodCode)) {
|
||||
return $this->fail('插件编码和支付方式不能为空', 400);
|
||||
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('请选择所属商户', 400);
|
||||
}
|
||||
|
||||
// 提取配置参数(从表单字段中提取)
|
||||
if ($merchantAppId <= 0) {
|
||||
return $this->fail('请选择所属应用', 400);
|
||||
}
|
||||
if ($channelName === '') {
|
||||
return $this->fail('请输入通道名称', 400);
|
||||
}
|
||||
if ($pluginCode === '' || $methodCode === '') {
|
||||
return $this->fail('支付插件和支付方式不能为空', 400);
|
||||
}
|
||||
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
if (!$method) {
|
||||
return $this->fail('支付方式不存在', 400);
|
||||
}
|
||||
|
||||
if ($channelCode !== '') {
|
||||
$exists = $this->channelRepository->findByChanCode($channelCode);
|
||||
if ($exists && (int)$exists->id !== $id) {
|
||||
return $this->fail('通道编码已存在', 400);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$configJson = $this->pluginService->buildConfigFromForm($pluginCode, $methodCode, $data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('插件不存在或配置错误:' . $e->getMessage(), 400);
|
||||
}
|
||||
|
||||
$method = $this->methodRepository->findByCode($methodCode);
|
||||
if (!$method) {
|
||||
return $this->fail('支付方式不存在', 400);
|
||||
}
|
||||
|
||||
$configWithProducts = array_merge($configJson, ['enabled_products' => is_array($enabledProducts) ? $enabledProducts : []]);
|
||||
|
||||
$channelData = [
|
||||
'merchant_id' => (int)($data['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($data['app_id'] ?? 0),
|
||||
'chan_code' => $data['channel_code'] ?? $data['chan_code'] ?? '',
|
||||
'chan_name' => $data['channel_name'] ?? $data['chan_name'] ?? '',
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'chan_code' => $channelCode !== '' ? $channelCode : 'CH' . date('YmdHis') . mt_rand(1000, 9999),
|
||||
'chan_name' => $channelName,
|
||||
'plugin_code' => $pluginCode,
|
||||
'method_id' => $method->id,
|
||||
'config_json' => $configWithProducts,
|
||||
'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100.00,
|
||||
'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0.00,
|
||||
'chan_mode' => $data['channel_mode'] ?? 'wallet',
|
||||
'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0.00,
|
||||
'method_id' => (int)$method->id,
|
||||
'config_json' => array_merge($configJson, [
|
||||
'enabled_products' => is_array($enabledProducts) ? array_values($enabledProducts) : [],
|
||||
]),
|
||||
'split_ratio' => isset($data['split_ratio']) ? (float)$data['split_ratio'] : 100,
|
||||
'chan_cost' => isset($data['channel_cost']) ? (float)$data['channel_cost'] : 0,
|
||||
'chan_mode' => trim((string)($data['channel_mode'] ?? 'wallet')) ?: 'wallet',
|
||||
'daily_limit' => isset($data['daily_limit']) ? (float)$data['daily_limit'] : 0,
|
||||
'daily_cnt' => isset($data['daily_count']) ? (int)$data['daily_count'] : 0,
|
||||
'min_amount' => isset($data['min_amount']) && $data['min_amount'] !== '' ? (float)$data['min_amount'] : null,
|
||||
'max_amount' => isset($data['max_amount']) && $data['max_amount'] !== '' ? (float)$data['max_amount'] : null,
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
];
|
||||
|
||||
|
||||
if ($id > 0) {
|
||||
// 更新
|
||||
$channel = $this->channelRepository->find($id);
|
||||
if (!$channel) {
|
||||
return $this->fail('通道不存在', 404);
|
||||
}
|
||||
$this->channelRepository->updateById($id, $channelData);
|
||||
} else {
|
||||
if (empty($channelData['chan_code'])) {
|
||||
$channelData['chan_code'] = 'CH' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
}
|
||||
$this->channelRepository->create($channelData);
|
||||
$channel = $this->channelRepository->create($channelData);
|
||||
$id = (int)$channel->id;
|
||||
}
|
||||
|
||||
return $this->success(null, '保存成功');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success(['id' => $id], '保存成功');
|
||||
}
|
||||
|
||||
public function toggle(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
$status = $request->post('status', null);
|
||||
if ($id <= 0 || $status === null) {
|
||||
return $this->fail('参数错误', 400);
|
||||
}
|
||||
$ok = $this->channelRepository->updateById($id, ['status' => (int)$status]);
|
||||
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
|
||||
}
|
||||
|
||||
public function monitor(Request $request)
|
||||
{
|
||||
$filters = $this->resolveChannelFilters($request);
|
||||
$days = $this->resolveDays($request->get('days', 7));
|
||||
$channels = $this->channelRepository->searchList($filters);
|
||||
if ($channels->isEmpty()) {
|
||||
return $this->success(['list' => [], 'summary' => $this->buildMonitorSummary([])]);
|
||||
}
|
||||
|
||||
$orderFilters = [
|
||||
'merchant_id' => $filters['merchant_id'] ?? null,
|
||||
'merchant_app_id' => $filters['merchant_app_id'] ?? null,
|
||||
'method_id' => $filters['method_id'] ?? null,
|
||||
'created_from' => $days['created_from'],
|
||||
'created_to' => $days['created_to'],
|
||||
];
|
||||
$channelIds = [];
|
||||
foreach ($channels as $channel) {
|
||||
$channelIds[] = (int)$channel->id;
|
||||
}
|
||||
$statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters);
|
||||
$rows = [];
|
||||
foreach ($channels as $channel) {
|
||||
$rows[] = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []);
|
||||
}
|
||||
|
||||
usort($rows, function (array $left, array $right) {
|
||||
if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) {
|
||||
return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0);
|
||||
}
|
||||
return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0);
|
||||
});
|
||||
|
||||
return $this->success(['list' => $rows, 'summary' => $this->buildMonitorSummary($rows)]);
|
||||
}
|
||||
|
||||
public function polling(Request $request)
|
||||
{
|
||||
$filters = $this->resolveChannelFilters($request);
|
||||
$days = $this->resolveDays($request->get('days', 7));
|
||||
$channels = $this->channelRepository->searchList($filters);
|
||||
$testAmount = $request->get('test_amount', null);
|
||||
$testAmount = ($testAmount === null || $testAmount === '') ? null : (float)$testAmount;
|
||||
if ($channels->isEmpty()) {
|
||||
return $this->success(['list' => [], 'summary' => $this->buildPollingSummary([])]);
|
||||
}
|
||||
|
||||
$orderFilters = [
|
||||
'merchant_id' => $filters['merchant_id'] ?? null,
|
||||
'merchant_app_id' => $filters['merchant_app_id'] ?? null,
|
||||
'method_id' => $filters['method_id'] ?? null,
|
||||
'created_from' => $days['created_from'],
|
||||
'created_to' => $days['created_to'],
|
||||
];
|
||||
$channelIds = [];
|
||||
foreach ($channels as $channel) {
|
||||
$channelIds[] = (int)$channel->id;
|
||||
}
|
||||
$statsMap = $this->orderRepository->aggregateByChannel($channelIds, $orderFilters);
|
||||
$rows = [];
|
||||
foreach ($channels as $channel) {
|
||||
$monitorRow = $this->buildMonitorRow($channel->toArray(), $statsMap[(int)$channel->id] ?? []);
|
||||
$rows[] = $this->buildPollingRow($monitorRow, $testAmount);
|
||||
}
|
||||
|
||||
$stateWeight = ['ready' => 0, 'degraded' => 1, 'blocked' => 2];
|
||||
usort($rows, function (array $left, array $right) use ($stateWeight) {
|
||||
$leftWeight = $stateWeight[$left['route_state'] ?? 'blocked'] ?? 9;
|
||||
$rightWeight = $stateWeight[$right['route_state'] ?? 'blocked'] ?? 9;
|
||||
if ($leftWeight === $rightWeight) {
|
||||
if (($right['route_score'] ?? 0) === ($left['route_score'] ?? 0)) {
|
||||
return ($left['sort'] ?? 0) <=> ($right['sort'] ?? 0);
|
||||
}
|
||||
return ($right['route_score'] ?? 0) <=> ($left['route_score'] ?? 0);
|
||||
}
|
||||
return $leftWeight <=> $rightWeight;
|
||||
});
|
||||
|
||||
foreach ($rows as $index => &$row) {
|
||||
$row['route_rank'] = $index + 1;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $this->success(['list' => $rows, 'summary' => $this->buildPollingSummary($rows)]);
|
||||
}
|
||||
|
||||
public function policyList(Request $request)
|
||||
{
|
||||
$merchantId = (int)$request->get('merchant_id', 0);
|
||||
$merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0));
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
$pluginCode = trim((string)$request->get('plugin_code', ''));
|
||||
$status = $request->get('status', null);
|
||||
|
||||
$policies = $this->routePolicyService->list();
|
||||
$channelMap = [];
|
||||
foreach ($this->channelRepository->searchList([]) as $channel) {
|
||||
$channelMap[(int)$channel->id] = $channel->toArray();
|
||||
}
|
||||
|
||||
$filtered = array_values(array_filter($policies, function (array $policy) use ($merchantId, $merchantAppId, $methodCode, $pluginCode, $status) {
|
||||
if ($merchantId > 0 && (int)($policy['merchant_id'] ?? 0) !== $merchantId) return false;
|
||||
if ($merchantAppId > 0 && (int)($policy['merchant_app_id'] ?? 0) !== $merchantAppId) return false;
|
||||
if ($methodCode !== '' && (string)($policy['method_code'] ?? '') !== $methodCode) return false;
|
||||
if ($pluginCode !== '' && (string)($policy['plugin_code'] ?? '') !== $pluginCode) return false;
|
||||
if ($status !== null && $status !== '' && (int)($policy['status'] ?? 0) !== (int)$status) return false;
|
||||
return true;
|
||||
}));
|
||||
|
||||
$list = [];
|
||||
foreach ($filtered as $policy) {
|
||||
$items = [];
|
||||
foreach (($policy['items'] ?? []) as $index => $item) {
|
||||
$channelId = (int)($item['channel_id'] ?? 0);
|
||||
$channel = $channelMap[$channelId] ?? [];
|
||||
$items[] = [
|
||||
'channel_id' => $channelId,
|
||||
'role' => trim((string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup'))),
|
||||
'weight' => max(0, (int)($item['weight'] ?? 100)),
|
||||
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
|
||||
'chan_code' => (string)($channel['chan_code'] ?? ''),
|
||||
'chan_name' => (string)($channel['chan_name'] ?? ''),
|
||||
'channel_status' => isset($channel['status']) ? (int)$channel['status'] : null,
|
||||
'sort' => (int)($channel['sort'] ?? 0),
|
||||
'plugin_code' => (string)($channel['plugin_code'] ?? ''),
|
||||
'method_id' => (int)($channel['method_id'] ?? 0),
|
||||
'merchant_id' => (int)($channel['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
usort($items, fn(array $left, array $right) => ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0));
|
||||
$policy['items'] = $items;
|
||||
$policy['channel_count'] = count($items);
|
||||
$list[] = $policy;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $list,
|
||||
'summary' => [
|
||||
'total' => count($list),
|
||||
'enabled' => count(array_filter($list, fn(array $policy) => (int)($policy['status'] ?? 0) === 1)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function policySave(Request $request)
|
||||
{
|
||||
try {
|
||||
$payload = $this->preparePolicyPayload($request->post(), true);
|
||||
return $this->success($this->routePolicyService->save($payload), '保存成功');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->fail($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function policyPreview(Request $request)
|
||||
{
|
||||
try {
|
||||
$payload = $this->preparePolicyPayload($request->post(), false);
|
||||
$testAmount = $request->post('test_amount', $request->post('preview_amount', 0));
|
||||
$amount = ($testAmount === null || $testAmount === '') ? 0 : (float)$testAmount;
|
||||
$preview = $this->channelRouterService->previewPolicyDraft(
|
||||
(int)$payload['merchant_id'],
|
||||
(int)$payload['merchant_app_id'],
|
||||
(int)$payload['method_id'],
|
||||
$payload,
|
||||
$amount
|
||||
);
|
||||
return $this->success($preview);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function policyDelete(Request $request)
|
||||
{
|
||||
$id = trim((string)$request->post('id', ''));
|
||||
if ($id === '') {
|
||||
return $this->fail('策略ID不能为空', 400);
|
||||
}
|
||||
$ok = $this->routePolicyService->delete($id);
|
||||
return $ok ? $this->success(null, '删除成功') : $this->fail('策略不存在或已删除', 404);
|
||||
}
|
||||
|
||||
private function preparePolicyPayload(array $data, bool $requirePolicyName = true): array
|
||||
{
|
||||
$policyName = trim((string)($data['policy_name'] ?? ''));
|
||||
$merchantId = (int)($data['merchant_id'] ?? 0);
|
||||
$merchantAppId = (int)($data['merchant_app_id'] ?? ($data['app_id'] ?? 0));
|
||||
$methodCode = trim((string)($data['method_code'] ?? ''));
|
||||
$pluginCode = trim((string)($data['plugin_code'] ?? ''));
|
||||
$routeMode = trim((string)($data['route_mode'] ?? 'priority'));
|
||||
$status = (int)($data['status'] ?? 1);
|
||||
$itemsInput = $data['items'] ?? [];
|
||||
|
||||
if ($requirePolicyName && $policyName === '') throw new \InvalidArgumentException('请输入策略名称');
|
||||
if ($methodCode === '') throw new \InvalidArgumentException('请选择支付方式');
|
||||
if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) throw new \InvalidArgumentException('路由模式不合法');
|
||||
if (!is_array($itemsInput) || $itemsInput === []) throw new \InvalidArgumentException('请至少选择一个通道');
|
||||
if ($merchantId <= 0 || $merchantAppId <= 0) throw new \InvalidArgumentException('请先选择商户和应用');
|
||||
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
if (!$method) throw new \InvalidArgumentException('支付方式不存在');
|
||||
|
||||
$channelMap = [];
|
||||
foreach ($this->channelRepository->searchList([]) as $channel) {
|
||||
$channelMap[(int)$channel->id] = $channel->toArray();
|
||||
}
|
||||
|
||||
$normalizedItems = [];
|
||||
$usedChannelIds = [];
|
||||
foreach ($itemsInput as $index => $item) {
|
||||
$channelId = (int)($item['channel_id'] ?? 0);
|
||||
if ($channelId <= 0) throw new \InvalidArgumentException('策略项中的通道ID不合法');
|
||||
if (in_array($channelId, $usedChannelIds, true)) throw new \InvalidArgumentException('策略中存在重复通道,请去重后再提交');
|
||||
|
||||
$channel = $channelMap[$channelId] ?? null;
|
||||
if (!$channel) throw new \InvalidArgumentException('存在未找到的通道,请刷新后重试');
|
||||
if ($merchantId > 0 && (int)$channel['merchant_id'] !== $merchantId) throw new \InvalidArgumentException('策略中的通道与商户不匹配');
|
||||
if ($merchantAppId > 0 && (int)$channel['merchant_app_id'] !== $merchantAppId) throw new \InvalidArgumentException('策略中的通道与应用不匹配');
|
||||
if ((int)$channel['method_id'] !== (int)$method->id) throw new \InvalidArgumentException('策略中的通道与支付方式不匹配');
|
||||
if ($pluginCode !== '' && (string)$channel['plugin_code'] !== $pluginCode) throw new \InvalidArgumentException('策略中的通道与插件不匹配');
|
||||
|
||||
$defaultRole = $routeMode === 'weight' ? 'normal' : ($index === 0 ? 'primary' : 'backup');
|
||||
$role = trim((string)($item['role'] ?? $defaultRole));
|
||||
if (!in_array($role, ['primary', 'backup', 'normal'], true)) {
|
||||
$role = $defaultRole;
|
||||
}
|
||||
$normalizedItems[] = [
|
||||
'channel_id' => $channelId,
|
||||
'role' => $role,
|
||||
'weight' => max(0, (int)($item['weight'] ?? 100)),
|
||||
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
|
||||
];
|
||||
$usedChannelIds[] = $channelId;
|
||||
}
|
||||
|
||||
usort($normalizedItems, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
|
||||
}
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
foreach ($normalizedItems as $index => &$item) {
|
||||
$item['priority'] = $index + 1;
|
||||
if ($routeMode === 'weight' && $item['role'] === 'backup') {
|
||||
$item['role'] = 'normal';
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return [
|
||||
'id' => trim((string)($data['id'] ?? '')),
|
||||
'policy_name' => $policyName !== '' ? $policyName : '策略草稿预览',
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_code' => $methodCode,
|
||||
'method_id' => (int)$method->id,
|
||||
'plugin_code' => $pluginCode,
|
||||
'route_mode' => $routeMode,
|
||||
'status' => $status,
|
||||
'circuit_breaker_threshold' => max(0, min(100, (int)($data['circuit_breaker_threshold'] ?? 50))),
|
||||
'failover_cooldown' => max(0, (int)($data['failover_cooldown'] ?? 10)),
|
||||
'remark' => trim((string)($data['remark'] ?? '')),
|
||||
'items' => $normalizedItems,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveChannelFilters(Request $request, bool $withKeywords = false): array
|
||||
{
|
||||
$filters = [];
|
||||
$merchantId = (int)$request->get('merchant_id', 0);
|
||||
if ($merchantId > 0) $filters['merchant_id'] = $merchantId;
|
||||
$merchantAppId = (int)$request->get('merchant_app_id', $request->get('app_id', 0));
|
||||
if ($merchantAppId > 0) $filters['merchant_app_id'] = $merchantAppId;
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
$filters['method_id'] = $method ? (int)$method->id : -1;
|
||||
}
|
||||
$pluginCode = trim((string)$request->get('plugin_code', ''));
|
||||
if ($pluginCode !== '') $filters['plugin_code'] = $pluginCode;
|
||||
$status = $request->get('status', null);
|
||||
if ($status !== null && $status !== '') $filters['status'] = (int)$status;
|
||||
if ($withKeywords) {
|
||||
$chanCode = trim((string)$request->get('chan_code', ''));
|
||||
if ($chanCode !== '') $filters['chan_code'] = $chanCode;
|
||||
$chanName = trim((string)$request->get('chan_name', ''));
|
||||
if ($chanName !== '') $filters['chan_name'] = $chanName;
|
||||
}
|
||||
return $filters;
|
||||
}
|
||||
|
||||
private function resolveDays(mixed $daysInput): array
|
||||
{
|
||||
$days = max(1, min(30, (int)$daysInput));
|
||||
return [
|
||||
'days' => $days,
|
||||
'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')),
|
||||
'created_to' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildMonitorRow(array $channel, array $stats): array
|
||||
{
|
||||
$totalOrders = (int)($stats['total_orders'] ?? 0);
|
||||
$successOrders = (int)($stats['success_orders'] ?? 0);
|
||||
$pendingOrders = (int)($stats['pending_orders'] ?? 0);
|
||||
$failOrders = (int)($stats['fail_orders'] ?? 0);
|
||||
$closedOrders = (int)($stats['closed_orders'] ?? 0);
|
||||
$todayOrders = (int)($stats['today_orders'] ?? 0);
|
||||
$todaySuccessOrders = (int)($stats['today_success_orders'] ?? 0);
|
||||
$todaySuccessAmount = round((float)($stats['today_success_amount'] ?? 0), 2);
|
||||
$successRate = $totalOrders > 0 ? round($successOrders / $totalOrders * 100, 2) : 0;
|
||||
$dailyLimit = isset($channel['daily_limit']) ? (float)$channel['daily_limit'] : 0;
|
||||
$dailyCnt = isset($channel['daily_cnt']) ? (int)$channel['daily_cnt'] : 0;
|
||||
$todayLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null;
|
||||
$healthScore = 0;
|
||||
$healthLevel = 'disabled';
|
||||
$status = (int)($channel['status'] ?? 0);
|
||||
|
||||
if ($status === 1) {
|
||||
if ($totalOrders === 0) {
|
||||
$healthScore = 60;
|
||||
$healthLevel = 'idle';
|
||||
} else {
|
||||
$healthScore = 90;
|
||||
if ($successRate < 95) $healthScore -= 10;
|
||||
if ($successRate < 80) $healthScore -= 15;
|
||||
if ($successRate < 60) $healthScore -= 20;
|
||||
if ($failOrders > 0) $healthScore -= min(15, $failOrders * 3);
|
||||
if ($pendingOrders > max(3, (int)floor($successOrders / 2))) $healthScore -= 10;
|
||||
if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) $healthScore -= 20;
|
||||
elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) $healthScore -= 10;
|
||||
$healthScore = max(0, min(100, $healthScore));
|
||||
if ($healthScore >= 80) $healthLevel = 'healthy';
|
||||
elseif ($healthScore >= 60) $healthLevel = 'warning';
|
||||
else $healthLevel = 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)($channel['id'] ?? 0),
|
||||
'merchant_id' => (int)($channel['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($channel['merchant_app_id'] ?? 0),
|
||||
'chan_code' => (string)($channel['chan_code'] ?? ''),
|
||||
'chan_name' => (string)($channel['chan_name'] ?? ''),
|
||||
'plugin_code' => (string)($channel['plugin_code'] ?? ''),
|
||||
'method_id' => (int)($channel['method_id'] ?? 0),
|
||||
'status' => $status,
|
||||
'sort' => (int)($channel['sort'] ?? 0),
|
||||
'daily_limit' => $dailyLimit > 0 ? round($dailyLimit, 2) : 0,
|
||||
'daily_cnt' => $dailyCnt > 0 ? $dailyCnt : 0,
|
||||
'min_amount' => $channel['min_amount'] === null ? null : round((float)$channel['min_amount'], 2),
|
||||
'max_amount' => $channel['max_amount'] === null ? null : round((float)$channel['max_amount'], 2),
|
||||
'total_orders' => $totalOrders,
|
||||
'success_orders' => $successOrders,
|
||||
'pending_orders' => $pendingOrders,
|
||||
'fail_orders' => $failOrders,
|
||||
'closed_orders' => $closedOrders,
|
||||
'today_orders' => $todayOrders,
|
||||
'today_success_orders' => $todaySuccessOrders,
|
||||
'total_amount' => round((float)($stats['total_amount'] ?? 0), 2),
|
||||
'success_amount' => round((float)($stats['success_amount'] ?? 0), 2),
|
||||
'today_amount' => round((float)($stats['today_amount'] ?? 0), 2),
|
||||
'today_success_amount' => $todaySuccessAmount,
|
||||
'last_order_at' => $stats['last_order_at'] ?? null,
|
||||
'last_success_at' => $stats['last_success_at'] ?? null,
|
||||
'success_rate' => $successRate,
|
||||
'today_limit_usage_rate' => $todayLimitUsageRate,
|
||||
'health_score' => $healthScore,
|
||||
'health_level' => $healthLevel,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildMonitorSummary(array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'total_channels' => count($rows),
|
||||
'enabled_channels' => 0,
|
||||
'healthy_channels' => 0,
|
||||
'warning_channels' => 0,
|
||||
'danger_channels' => 0,
|
||||
'total_orders' => 0,
|
||||
'success_rate' => 0,
|
||||
'today_success_amount' => 0,
|
||||
];
|
||||
$successOrders = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ((int)($row['status'] ?? 0) === 1) $summary['enabled_channels']++;
|
||||
$level = $row['health_level'] ?? '';
|
||||
if ($level === 'healthy') $summary['healthy_channels']++;
|
||||
elseif ($level === 'warning') $summary['warning_channels']++;
|
||||
elseif ($level === 'danger') $summary['danger_channels']++;
|
||||
$summary['total_orders'] += (int)($row['total_orders'] ?? 0);
|
||||
$summary['today_success_amount'] = round($summary['today_success_amount'] + (float)($row['today_success_amount'] ?? 0), 2);
|
||||
$successOrders += (int)($row['success_orders'] ?? 0);
|
||||
}
|
||||
if ($summary['total_orders'] > 0) {
|
||||
$summary['success_rate'] = round($successOrders / $summary['total_orders'] * 100, 2);
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function buildPollingRow(array $monitorRow, ?float $testAmount): array
|
||||
{
|
||||
$reasons = [];
|
||||
$status = (int)($monitorRow['status'] ?? 0);
|
||||
$dailyLimit = (float)($monitorRow['daily_limit'] ?? 0);
|
||||
$dailyCnt = (int)($monitorRow['daily_cnt'] ?? 0);
|
||||
$todaySuccessAmount = (float)($monitorRow['today_success_amount'] ?? 0);
|
||||
$todayOrders = (int)($monitorRow['today_orders'] ?? 0);
|
||||
$minAmount = $monitorRow['min_amount'];
|
||||
$maxAmount = $monitorRow['max_amount'];
|
||||
$remainingDailyLimit = $dailyLimit > 0 ? round($dailyLimit - $todaySuccessAmount, 2) : null;
|
||||
$remainingDailyCount = $dailyCnt > 0 ? $dailyCnt - $todayOrders : null;
|
||||
$routeState = 'ready';
|
||||
|
||||
if ($status !== 1) { $routeState = 'blocked'; $reasons[] = '通道已禁用'; }
|
||||
if ($testAmount !== null) {
|
||||
if ($minAmount !== null && $testAmount < (float)$minAmount) { $routeState = 'blocked'; $reasons[] = '低于最小支付金额'; }
|
||||
if ($maxAmount !== null && (float)$maxAmount > 0 && $testAmount > (float)$maxAmount) { $routeState = 'blocked'; $reasons[] = '超过最大支付金额'; }
|
||||
}
|
||||
if ($remainingDailyLimit !== null && $remainingDailyLimit <= 0) { $routeState = 'blocked'; $reasons[] = '单日限额已用尽'; }
|
||||
if ($remainingDailyCount !== null && $remainingDailyCount <= 0) { $routeState = 'blocked'; $reasons[] = '单日笔数已用尽'; }
|
||||
if ($routeState !== 'blocked') {
|
||||
if (($monitorRow['health_level'] ?? '') === 'warning' || ($monitorRow['health_level'] ?? '') === 'danger') { $routeState = 'degraded'; $reasons[] = '监控健康度偏低'; }
|
||||
if ((int)($monitorRow['total_orders'] ?? 0) === 0) { $routeState = 'degraded'; $reasons[] = '暂无订单样本,建议灰度'; }
|
||||
if ((float)($monitorRow['success_rate'] ?? 0) < 80 && (int)($monitorRow['total_orders'] ?? 0) > 0) { $routeState = 'degraded'; $reasons[] = '成功率偏低'; }
|
||||
if ((int)($monitorRow['pending_orders'] ?? 0) > max(3, (int)($monitorRow['success_orders'] ?? 0))) { $routeState = 'degraded'; $reasons[] = '待支付订单偏多'; }
|
||||
}
|
||||
|
||||
$priorityBonus = max(0, 20 - min(20, (int)($monitorRow['sort'] ?? 0) * 2));
|
||||
$sampleBonus = (int)($monitorRow['total_orders'] ?? 0) > 0 ? min(10, (int)floor(((float)($monitorRow['success_rate'] ?? 0)) / 10)) : 5;
|
||||
$routeScore = round(max(0, min(100, ((float)($monitorRow['health_score'] ?? 0) * 0.7) + $priorityBonus + $sampleBonus)), 2);
|
||||
if ($routeState === 'degraded') $routeScore = max(0, round($routeScore - 15, 2));
|
||||
if ($routeState === 'blocked') $routeScore = 0;
|
||||
|
||||
return array_merge($monitorRow, [
|
||||
'route_state' => $routeState,
|
||||
'route_rank' => 0,
|
||||
'route_score' => $routeScore,
|
||||
'remaining_daily_limit' => $remainingDailyLimit === null ? null : round(max(0, $remainingDailyLimit), 2),
|
||||
'remaining_daily_count' => $remainingDailyCount === null ? null : max(0, $remainingDailyCount),
|
||||
'reasons' => array_values(array_unique($reasons)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildPollingSummary(array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'total_channels' => count($rows),
|
||||
'ready_channels' => 0,
|
||||
'degraded_channels' => 0,
|
||||
'blocked_channels' => 0,
|
||||
'recommended_channel' => null,
|
||||
'fallback_chain' => [],
|
||||
];
|
||||
foreach ($rows as $row) {
|
||||
$state = $row['route_state'] ?? 'blocked';
|
||||
if ($state === 'ready') $summary['ready_channels']++;
|
||||
elseif ($state === 'degraded') $summary['degraded_channels']++;
|
||||
else $summary['blocked_channels']++;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
if ($summary['recommended_channel'] === null && ($row['route_state'] ?? '') !== 'blocked') {
|
||||
$summary['recommended_channel'] = $row;
|
||||
continue;
|
||||
}
|
||||
if (($row['route_state'] ?? '') !== 'blocked' && count($summary['fallback_chain']) < 5) {
|
||||
$summary['fallback_chain'][] = sprintf('%s(%s)', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? ''));
|
||||
}
|
||||
}
|
||||
if ($summary['recommended_channel'] !== null) {
|
||||
$recommendedId = (int)($summary['recommended_channel']['id'] ?? 0);
|
||||
if ($recommendedId > 0) {
|
||||
$summary['fallback_chain'] = [];
|
||||
foreach ($rows as $row) {
|
||||
if ((int)($row['id'] ?? 0) === $recommendedId || ($row['route_state'] ?? '') === 'blocked') continue;
|
||||
$summary['fallback_chain'][] = sprintf('%s(%s)', (string)($row['chan_name'] ?? ''), (string)($row['chan_code'] ?? ''));
|
||||
if (count($summary['fallback_chain']) >= 5) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
522
app/http/admin/controller/FinanceController.php
Normal file
522
app/http/admin/controller/FinanceController.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\repositories\PaymentMethodRepository;
|
||||
use support\Db;
|
||||
use support\Request;
|
||||
|
||||
class FinanceController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function reconciliation(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'COUNT(*) AS total_orders,
|
||||
SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders,
|
||||
SUM(CASE WHEN o.status = 0 THEN 1 ELSE 0 END) AS pending_orders,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
|
||||
COALESCE(SUM(o.amount), 0) AS total_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS total_fee,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS total_net_amount'
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
"o.*, m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name, pm.method_code, pm.method_name,
|
||||
pc.chan_code, pc.chan_name,
|
||||
COALESCE(o.real_amount - o.fee, 0) AS net_amount,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(o.extra, '$.routing.policy.policy_name')) AS route_policy_name"
|
||||
)
|
||||
->orderByDesc('o.id')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['reconcile_status'] = $this->reconcileStatus((int)($item['status'] ?? 0), (int)($item['notify_stat'] ?? 0));
|
||||
$item['reconcile_status_text'] = $this->reconcileStatusText($item['reconcile_status']);
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'total_orders' => (int)($summaryRow->total_orders ?? 0),
|
||||
'success_orders' => (int)($summaryRow->success_orders ?? 0),
|
||||
'pending_orders' => (int)($summaryRow->pending_orders ?? 0),
|
||||
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
|
||||
'total_amount' => (string)($summaryRow->total_amount ?? '0.00'),
|
||||
'total_fee' => (string)($summaryRow->total_fee ?? '0.00'),
|
||||
'total_net_amount' => (string)($summaryRow->total_net_amount ?? '0.00'),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settlement(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(DISTINCT o.merchant_app_id) AS app_count,
|
||||
COUNT(*) AS success_orders,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS fee_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount'
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'o.merchant_id, o.merchant_app_id,
|
||||
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
|
||||
COUNT(*) AS success_orders,
|
||||
COUNT(DISTINCT o.channel_id) AS channel_count,
|
||||
COUNT(DISTINCT o.method_id) AS method_count,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS fee_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS notify_pending_amount,
|
||||
MAX(o.pay_at) AS last_pay_at'
|
||||
)
|
||||
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
|
||||
->orderByRaw('SUM(o.real_amount - o.fee) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'app_count' => (int)($summaryRow->app_count ?? 0),
|
||||
'success_orders' => (int)($summaryRow->success_orders ?? 0),
|
||||
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
|
||||
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
|
||||
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
|
||||
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
|
||||
'notify_pending_amount' => (string)($summaryRow->notify_pending_amount ?? '0.00'),
|
||||
],
|
||||
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function fee(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters);
|
||||
|
||||
if (($filters['status'] ?? '') === '') {
|
||||
$baseQuery->where('o.status', 1);
|
||||
}
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(DISTINCT o.channel_id) AS channel_count,
|
||||
COUNT(DISTINCT o.method_id) AS method_count,
|
||||
COUNT(*) AS order_count,
|
||||
COALESCE(SUM(o.real_amount), 0) AS total_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS total_fee'
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'o.merchant_id, o.channel_id, o.method_id,
|
||||
m.merchant_no, m.merchant_name,
|
||||
pm.method_code, pm.method_name,
|
||||
pc.chan_code, pc.chan_name,
|
||||
COUNT(*) AS order_count,
|
||||
SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS success_orders,
|
||||
COALESCE(SUM(o.real_amount), 0) AS total_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS total_fee,
|
||||
COALESCE(AVG(CASE WHEN o.real_amount > 0 THEN o.fee / o.real_amount ELSE NULL END), 0) AS avg_fee_rate,
|
||||
MAX(o.created_at) AS last_order_at'
|
||||
)
|
||||
->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name')
|
||||
->orderByRaw('SUM(o.fee) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['avg_fee_rate_percent'] = round(((float)($item['avg_fee_rate'] ?? 0)) * 100, 4);
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'channel_count' => (int)($summaryRow->channel_count ?? 0),
|
||||
'method_count' => (int)($summaryRow->method_count ?? 0),
|
||||
'order_count' => (int)($summaryRow->order_count ?? 0),
|
||||
'total_amount' => (string)($summaryRow->total_amount ?? '0.00'),
|
||||
'total_fee' => (string)($summaryRow->total_fee ?? '0.00'),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settlementRecord(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
"COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS record_count,
|
||||
COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(DISTINCT o.merchant_app_id) AS app_count,
|
||||
COUNT(*) AS success_orders,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS fee_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders"
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
"DATE(COALESCE(o.pay_at, o.created_at)) AS settlement_date,
|
||||
o.merchant_id, o.merchant_app_id,
|
||||
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
|
||||
COUNT(*) AS success_orders,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS fee_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
|
||||
MAX(o.pay_at) AS last_pay_at"
|
||||
)
|
||||
->groupByRaw("DATE(COALESCE(o.pay_at, o.created_at)), o.merchant_id, o.merchant_app_id, m.merchant_no, m.merchant_name, ma.app_id, ma.app_name")
|
||||
->orderByDesc('settlement_date')
|
||||
->orderByRaw('SUM(o.real_amount - o.fee) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['settlement_status'] = (int)($item['notify_pending_orders'] ?? 0) > 0 ? 'pending' : 'ready';
|
||||
$item['settlement_status_text'] = $item['settlement_status'] === 'ready' ? 'ready' : 'pending_notify';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'record_count' => (int)($summaryRow->record_count ?? 0),
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'app_count' => (int)($summaryRow->app_count ?? 0),
|
||||
'success_orders' => (int)($summaryRow->success_orders ?? 0),
|
||||
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
|
||||
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
|
||||
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
|
||||
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function batchSettlement(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
"COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(DISTINCT o.merchant_app_id) AS app_count,
|
||||
COUNT(*) AS success_orders,
|
||||
COUNT(DISTINCT CONCAT(o.merchant_app_id, '#', DATE(COALESCE(o.pay_at, o.created_at)))) AS batch_days,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount"
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
"o.merchant_id, o.merchant_app_id,
|
||||
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
|
||||
COUNT(*) AS success_orders,
|
||||
COUNT(DISTINCT DATE(COALESCE(o.pay_at, o.created_at))) AS batch_days,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS ready_amount,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders,
|
||||
MAX(o.pay_at) AS last_pay_at"
|
||||
)
|
||||
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
|
||||
->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['batch_status'] = (float)($item['pending_amount'] ?? 0) > 0 ? 'pending' : 'ready';
|
||||
$item['batch_status_text'] = $item['batch_status'] === 'ready' ? 'ready_to_batch' : 'pending_notify';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'app_count' => (int)($summaryRow->app_count ?? 0),
|
||||
'success_orders' => (int)($summaryRow->success_orders ?? 0),
|
||||
'batch_days' => (int)($summaryRow->batch_days ?? 0),
|
||||
'ready_amount' => (string)($summaryRow->ready_amount ?? '0.00'),
|
||||
'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function split(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT o.channel_id) AS channel_count,
|
||||
COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(*) AS order_count,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount,
|
||||
COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount,
|
||||
COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount'
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'o.merchant_id, o.channel_id, o.method_id,
|
||||
m.merchant_no, m.merchant_name,
|
||||
pm.method_code, pm.method_name,
|
||||
pc.chan_code, pc.chan_name,
|
||||
COALESCE(pc.split_ratio, 100) AS split_ratio,
|
||||
COALESCE(pc.chan_cost, 0) AS chan_cost,
|
||||
COUNT(*) AS order_count,
|
||||
COALESCE(SUM(o.real_amount), 0) AS gross_amount,
|
||||
COALESCE(SUM(o.fee), 0) AS fee_amount,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
COALESCE(SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100), 0) AS merchant_share_amount,
|
||||
COALESCE(SUM((o.real_amount - o.fee) * (100 - COALESCE(pc.split_ratio, 100)) / 100), 0) AS platform_share_amount,
|
||||
COALESCE(SUM(o.real_amount * COALESCE(pc.chan_cost, 0) / 100), 0) AS channel_cost_amount,
|
||||
MAX(o.pay_at) AS last_pay_at'
|
||||
)
|
||||
->groupBy('o.merchant_id', 'o.channel_id', 'o.method_id', 'm.merchant_no', 'm.merchant_name', 'pm.method_code', 'pm.method_name', 'pc.chan_code', 'pc.chan_name', 'pc.split_ratio', 'pc.chan_cost')
|
||||
->orderByRaw('SUM((o.real_amount - o.fee) * COALESCE(pc.split_ratio, 100) / 100) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'channel_count' => (int)($summaryRow->channel_count ?? 0),
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'order_count' => (int)($summaryRow->order_count ?? 0),
|
||||
'gross_amount' => (string)($summaryRow->gross_amount ?? '0.00'),
|
||||
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
|
||||
'merchant_share_amount' => (string)($summaryRow->merchant_share_amount ?? '0.00'),
|
||||
'platform_share_amount' => (string)($summaryRow->platform_share_amount ?? '0.00'),
|
||||
'channel_cost_amount' => (string)($summaryRow->channel_cost_amount ?? '0.00'),
|
||||
],
|
||||
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function invoice(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildFilters($request);
|
||||
$baseQuery = $this->buildOrderQuery($filters)->where('o.status', 1);
|
||||
|
||||
$summaryRow = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT o.merchant_id) AS merchant_count,
|
||||
COUNT(DISTINCT o.merchant_app_id) AS app_count,
|
||||
COUNT(*) AS success_orders,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders'
|
||||
)
|
||||
->first();
|
||||
|
||||
$paginator = (clone $baseQuery)
|
||||
->selectRaw(
|
||||
'o.merchant_id, o.merchant_app_id,
|
||||
m.merchant_no, m.merchant_name, ma.app_id AS merchant_app_code, ma.app_name,
|
||||
COUNT(*) AS success_orders,
|
||||
COALESCE(SUM(o.real_amount - o.fee), 0) AS net_amount,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS invoiceable_amount,
|
||||
COALESCE(SUM(CASE WHEN o.notify_stat = 0 THEN o.real_amount - o.fee ELSE 0 END), 0) AS pending_invoice_amount,
|
||||
SUM(CASE WHEN o.notify_stat = 0 THEN 1 ELSE 0 END) AS pending_notify_orders,
|
||||
MAX(o.pay_at) AS last_pay_at'
|
||||
)
|
||||
->groupBy('o.merchant_id', 'o.merchant_app_id', 'm.merchant_no', 'm.merchant_name', 'ma.app_id', 'ma.app_name')
|
||||
->orderByRaw('SUM(CASE WHEN o.notify_stat = 1 THEN o.real_amount - o.fee ELSE 0 END) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['invoice_status'] = (float)($item['pending_invoice_amount'] ?? 0) > 0 ? 'pending' : 'ready';
|
||||
$item['invoice_status_text'] = $item['invoice_status'] === 'ready' ? 'ready' : 'pending_review';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'app_count' => (int)($summaryRow->app_count ?? 0),
|
||||
'success_orders' => (int)($summaryRow->success_orders ?? 0),
|
||||
'invoiceable_amount' => (string)($summaryRow->invoiceable_amount ?? '0.00'),
|
||||
'pending_invoice_amount' => (string)($summaryRow->pending_invoice_amount ?? '0.00'),
|
||||
'pending_notify_orders' => (int)($summaryRow->pending_notify_orders ?? 0),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildFilters(Request $request): array
|
||||
{
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
$methodId = 0;
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
$methodId = $method ? (int)$method->id : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant_id' => (int)$request->get('merchant_id', 0),
|
||||
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
|
||||
'method_id' => $methodId,
|
||||
'channel_id' => (int)$request->get('channel_id', 0),
|
||||
'status' => (string)$request->get('status', ''),
|
||||
'notify_stat' => (string)$request->get('notify_stat', ''),
|
||||
'order_id' => trim((string)$request->get('order_id', '')),
|
||||
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
|
||||
'created_from' => trim((string)$request->get('created_from', '')),
|
||||
'created_to' => trim((string)$request->get('created_to', '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildOrderQuery(array $filters)
|
||||
{
|
||||
$query = Db::table('ma_pay_order as o')
|
||||
->leftJoin('ma_merchant as m', 'm.id', '=', 'o.merchant_id')
|
||||
->leftJoin('ma_merchant_app as ma', 'ma.id', '=', 'o.merchant_app_id')
|
||||
->leftJoin('ma_pay_method as pm', 'pm.id', '=', 'o.method_id')
|
||||
->leftJoin('ma_pay_channel as pc', 'pc.id', '=', 'o.channel_id');
|
||||
|
||||
if (!empty($filters['merchant_id'])) {
|
||||
$query->where('o.merchant_id', (int)$filters['merchant_id']);
|
||||
}
|
||||
if (!empty($filters['merchant_app_id'])) {
|
||||
$query->where('o.merchant_app_id', (int)$filters['merchant_app_id']);
|
||||
}
|
||||
if (!empty($filters['method_id'])) {
|
||||
$query->where('o.method_id', (int)$filters['method_id']);
|
||||
}
|
||||
if (!empty($filters['channel_id'])) {
|
||||
$query->where('o.channel_id', (int)$filters['channel_id']);
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('o.status', (int)$filters['status']);
|
||||
}
|
||||
if (($filters['notify_stat'] ?? '') !== '') {
|
||||
$query->where('o.notify_stat', (int)$filters['notify_stat']);
|
||||
}
|
||||
if (!empty($filters['order_id'])) {
|
||||
$query->where('o.order_id', 'like', '%' . $filters['order_id'] . '%');
|
||||
}
|
||||
if (!empty($filters['mch_order_no'])) {
|
||||
$query->where('o.mch_order_no', 'like', '%' . $filters['mch_order_no'] . '%');
|
||||
}
|
||||
if (!empty($filters['created_from'])) {
|
||||
$query->where('o.created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$query->where('o.created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function reconcileStatus(int $status, int $notifyStat): string
|
||||
{
|
||||
if ($status === 1 && $notifyStat === 1) {
|
||||
return 'matched';
|
||||
}
|
||||
if ($status === 1 && $notifyStat === 0) {
|
||||
return 'notify_pending';
|
||||
}
|
||||
if ($status === 0) {
|
||||
return 'pending';
|
||||
}
|
||||
if ($status === 2) {
|
||||
return 'failed';
|
||||
}
|
||||
if ($status === 3) {
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private function reconcileStatusText(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'matched' => 'matched',
|
||||
'notify_pending' => 'notify_pending',
|
||||
'pending' => 'pending',
|
||||
'failed' => 'failed',
|
||||
'closed' => 'closed',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,18 @@ namespace app\http\admin\controller;
|
||||
use app\common\base\BaseController;
|
||||
use app\repositories\MerchantAppRepository;
|
||||
use app\repositories\MerchantRepository;
|
||||
use app\services\SystemConfigService;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 商户应用管理
|
||||
*/
|
||||
class MerchantAppController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantAppRepository $merchantAppRepository,
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected SystemConfigService $systemConfigService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /adminapi/merchant-app/list
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
@@ -35,30 +31,59 @@ class MerchantAppController extends BaseController
|
||||
];
|
||||
|
||||
$paginator = $this->merchantAppRepository->searchPaginate($filters, $page, $pageSize);
|
||||
return $this->page($paginator);
|
||||
$packageMap = $this->buildPackageMap();
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$config = $this->getConfigObject($this->appConfigKey((int)($item['id'] ?? 0)));
|
||||
$packageCode = trim((string)($config['package_code'] ?? ''));
|
||||
$item['package_code'] = $packageCode;
|
||||
$item['package_name'] = $packageCode !== '' ? ($packageMap[$packageCode] ?? $packageCode) : '';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /adminapi/merchant-app/detail?id=1
|
||||
*/
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('应用ID不能为空', 400);
|
||||
return $this->fail('app id is required', 400);
|
||||
}
|
||||
|
||||
$row = $this->merchantAppRepository->find($id);
|
||||
if (!$row) {
|
||||
return $this->fail('应用不存在', 404);
|
||||
return $this->fail('app not found', 404);
|
||||
}
|
||||
|
||||
return $this->success($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /adminapi/merchant-app/save
|
||||
*/
|
||||
public function configDetail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('app id is required', 400);
|
||||
}
|
||||
|
||||
$app = $this->merchantAppRepository->find($id);
|
||||
if (!$app) {
|
||||
return $this->fail('app not found', 404);
|
||||
}
|
||||
|
||||
$config = array_merge($this->defaultAppConfig(), $this->getConfigObject($this->appConfigKey($id)));
|
||||
return $this->success([
|
||||
'app' => $app,
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
@@ -71,29 +96,28 @@ class MerchantAppController extends BaseController
|
||||
$status = (int)($data['status'] ?? 1);
|
||||
|
||||
if ($merchantId <= 0 || $appId === '' || $appName === '') {
|
||||
return $this->fail('商户、应用ID、应用名称不能为空', 400);
|
||||
return $this->fail('merchant_id, app_id and app_name are required', 400);
|
||||
}
|
||||
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
return $this->fail('商户不存在', 404);
|
||||
return $this->fail('merchant not found', 404);
|
||||
}
|
||||
|
||||
if (!in_array($apiType, ['openapi', 'epay', 'custom', 'default'], true)) {
|
||||
return $this->fail('api_type 不合法', 400);
|
||||
return $this->fail('invalid api_type', 400);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$row = $this->merchantAppRepository->find($id);
|
||||
if (!$row) {
|
||||
return $this->fail('应用不存在', 404);
|
||||
return $this->fail('app not found', 404);
|
||||
}
|
||||
|
||||
// app_id 变更需校验唯一
|
||||
if ($row->app_id !== $appId) {
|
||||
$exists = $this->merchantAppRepository->findAnyByAppId($appId);
|
||||
if ($exists) {
|
||||
return $this->fail('应用ID已存在', 400);
|
||||
return $this->fail('app_id already exists', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +129,6 @@ class MerchantAppController extends BaseController
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
// 可选:前端传入 app_secret 才更新
|
||||
if (!empty($data['app_secret'])) {
|
||||
$update['app_secret'] = (string)$data['app_secret'];
|
||||
}
|
||||
@@ -114,7 +137,7 @@ class MerchantAppController extends BaseController
|
||||
} else {
|
||||
$exists = $this->merchantAppRepository->findAnyByAppId($appId);
|
||||
if ($exists) {
|
||||
return $this->fail('应用ID已存在', 400);
|
||||
return $this->fail('app_id already exists', 400);
|
||||
}
|
||||
|
||||
$secret = !empty($data['app_secret']) ? (string)$data['app_secret'] : $this->generateSecret();
|
||||
@@ -128,44 +151,95 @@ class MerchantAppController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(null, '保存成功');
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /adminapi/merchant-app/reset-secret
|
||||
*/
|
||||
public function resetSecret(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('应用ID不能为空', 400);
|
||||
return $this->fail('app id is required', 400);
|
||||
}
|
||||
|
||||
$row = $this->merchantAppRepository->find($id);
|
||||
if (!$row) {
|
||||
return $this->fail('应用不存在', 404);
|
||||
return $this->fail('app not found', 404);
|
||||
}
|
||||
|
||||
$secret = $this->generateSecret();
|
||||
$this->merchantAppRepository->updateById($id, ['app_secret' => $secret]);
|
||||
|
||||
return $this->success(['app_secret' => $secret], '重置成功');
|
||||
return $this->success(['app_secret' => $secret], 'reset success');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /adminapi/merchant-app/toggle
|
||||
*/
|
||||
public function toggle(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
$status = $request->post('status', null);
|
||||
|
||||
if ($id <= 0 || $status === null) {
|
||||
return $this->fail('参数错误', 400);
|
||||
return $this->fail('invalid params', 400);
|
||||
}
|
||||
|
||||
$ok = $this->merchantAppRepository->updateById($id, ['status' => (int)$status]);
|
||||
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
|
||||
return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500);
|
||||
}
|
||||
|
||||
public function configSave(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('app id is required', 400);
|
||||
}
|
||||
|
||||
$app = $this->merchantAppRepository->find($id);
|
||||
if (!$app) {
|
||||
return $this->fail('app not found', 404);
|
||||
}
|
||||
|
||||
$signType = trim((string)$request->post('sign_type', 'md5'));
|
||||
$callbackMode = trim((string)$request->post('callback_mode', 'server'));
|
||||
if (!in_array($signType, ['md5', 'sha256', 'hmac-sha256'], true)) {
|
||||
return $this->fail('invalid sign_type', 400);
|
||||
}
|
||||
if (!in_array($callbackMode, ['server', 'server+page', 'manual'], true)) {
|
||||
return $this->fail('invalid callback_mode', 400);
|
||||
}
|
||||
|
||||
$config = [
|
||||
'package_code' => trim((string)$request->post('package_code', '')),
|
||||
'notify_url' => trim((string)$request->post('notify_url', '')),
|
||||
'return_url' => trim((string)$request->post('return_url', '')),
|
||||
'callback_mode' => $callbackMode,
|
||||
'sign_type' => $signType,
|
||||
'order_expire_minutes' => max(0, (int)$request->post('order_expire_minutes', 30)),
|
||||
'callback_retry_limit' => max(0, (int)$request->post('callback_retry_limit', 6)),
|
||||
'ip_whitelist' => trim((string)$request->post('ip_whitelist', '')),
|
||||
'amount_min' => max(0, (float)$request->post('amount_min', 0)),
|
||||
'amount_max' => max(0, (float)$request->post('amount_max', 0)),
|
||||
'daily_limit' => max(0, (float)$request->post('daily_limit', 0)),
|
||||
'notify_enabled' => (int)$request->post('notify_enabled', 1) === 1 ? 1 : 0,
|
||||
'remark' => trim((string)$request->post('remark', '')),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($config['package_code'] !== '') {
|
||||
$packageExists = false;
|
||||
foreach ($this->getConfigEntries('merchant_packages') as $package) {
|
||||
if (($package['package_code'] ?? '') === $config['package_code']) {
|
||||
$packageExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$packageExists) {
|
||||
return $this->fail('package_code not found', 400);
|
||||
}
|
||||
}
|
||||
|
||||
$stored = array_merge($this->defaultAppConfig(), $this->getConfigObject($this->appConfigKey($id)), $config);
|
||||
$this->systemConfigService->setValue($this->appConfigKey($id), $stored);
|
||||
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
private function generateSecret(): string
|
||||
@@ -173,5 +247,69 @@ class MerchantAppController extends BaseController
|
||||
$raw = random_bytes(24);
|
||||
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
private function appConfigKey(int $appId): string
|
||||
{
|
||||
return 'merchant_app_config_' . $appId;
|
||||
}
|
||||
|
||||
private function defaultAppConfig(): array
|
||||
{
|
||||
return [
|
||||
'package_code' => '',
|
||||
'notify_url' => '',
|
||||
'return_url' => '',
|
||||
'callback_mode' => 'server',
|
||||
'sign_type' => 'md5',
|
||||
'order_expire_minutes' => 30,
|
||||
'callback_retry_limit' => 6,
|
||||
'ip_whitelist' => '',
|
||||
'amount_min' => 0,
|
||||
'amount_max' => 0,
|
||||
'daily_limit' => 0,
|
||||
'notify_enabled' => 1,
|
||||
'remark' => '',
|
||||
'updated_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function getConfigObject(string $configKey): array
|
||||
{
|
||||
$raw = $this->systemConfigService->getValue($configKey, '{}');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function getConfigEntries(string $configKey): array
|
||||
{
|
||||
$raw = $this->systemConfigService->getValue($configKey, '[]');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($decoded, 'is_array'));
|
||||
}
|
||||
|
||||
private function buildPackageMap(): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($this->getConfigEntries('merchant_packages') as $package) {
|
||||
$packageCode = trim((string)($package['package_code'] ?? ''));
|
||||
if ($packageCode === '') {
|
||||
continue;
|
||||
}
|
||||
$map[$packageCode] = trim((string)($package['package_name'] ?? $packageCode));
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,18 @@ namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\repositories\MerchantRepository;
|
||||
use app\services\SystemConfigService;
|
||||
use support\Db;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 商户管理
|
||||
*/
|
||||
class MerchantController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected MerchantRepository $merchantRepository,
|
||||
protected SystemConfigService $systemConfigService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /adminapi/merchant/list
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
@@ -31,30 +28,59 @@ class MerchantController extends BaseController
|
||||
];
|
||||
|
||||
$paginator = $this->merchantRepository->searchPaginate($filters, $page, $pageSize);
|
||||
return $this->page($paginator);
|
||||
$groupMap = $this->buildGroupMap();
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$profile = $this->getConfigObject($this->merchantProfileKey((int)($item['id'] ?? 0)));
|
||||
$groupCode = trim((string)($profile['group_code'] ?? ''));
|
||||
$item['group_code'] = $groupCode;
|
||||
$item['group_name'] = $groupCode !== '' ? ($groupMap[$groupCode] ?? $groupCode) : '';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /adminapi/merchant/detail?id=1
|
||||
*/
|
||||
public function detail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('商户ID不能为空', 400);
|
||||
return $this->fail('merchant id is required', 400);
|
||||
}
|
||||
|
||||
$row = $this->merchantRepository->find($id);
|
||||
if (!$row) {
|
||||
return $this->fail('商户不存在', 404);
|
||||
return $this->fail('merchant not found', 404);
|
||||
}
|
||||
|
||||
return $this->success($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /adminapi/merchant/save
|
||||
*/
|
||||
public function profileDetail(Request $request)
|
||||
{
|
||||
$id = (int)$request->get('id', 0);
|
||||
if ($id <= 0) {
|
||||
return $this->fail('merchant id is required', 400);
|
||||
}
|
||||
|
||||
$merchant = $this->merchantRepository->find($id);
|
||||
if (!$merchant) {
|
||||
return $this->fail('merchant not found', 404);
|
||||
}
|
||||
|
||||
$profile = array_merge($this->defaultMerchantProfile(), $this->getConfigObject($this->merchantProfileKey($id)));
|
||||
return $this->success([
|
||||
'merchant' => $merchant,
|
||||
'profile' => $profile,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
@@ -66,11 +92,11 @@ class MerchantController extends BaseController
|
||||
$status = (int)($data['status'] ?? 1);
|
||||
|
||||
if ($merchantNo === '' || $merchantName === '') {
|
||||
return $this->fail('商户号与商户名称不能为空', 400);
|
||||
return $this->fail('merchant_no and merchant_name are required', 400);
|
||||
}
|
||||
|
||||
if (!in_array($fundsMode, ['direct', 'wallet', 'hybrid'], true)) {
|
||||
return $this->fail('资金模式不合法', 400);
|
||||
return $this->fail('invalid funds_mode', 400);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
@@ -83,7 +109,7 @@ class MerchantController extends BaseController
|
||||
} else {
|
||||
$exists = $this->merchantRepository->findByMerchantNo($merchantNo);
|
||||
if ($exists) {
|
||||
return $this->fail('商户号已存在', 400);
|
||||
return $this->fail('merchant_no already exists', 400);
|
||||
}
|
||||
|
||||
$this->merchantRepository->create([
|
||||
@@ -94,23 +120,757 @@ class MerchantController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success(null, '保存成功');
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /adminapi/merchant/toggle
|
||||
*/
|
||||
public function toggle(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
$status = $request->post('status', null);
|
||||
|
||||
if ($id <= 0 || $status === null) {
|
||||
return $this->fail('参数错误', 400);
|
||||
return $this->fail('invalid params', 400);
|
||||
}
|
||||
|
||||
$ok = $this->merchantRepository->updateById($id, ['status' => (int)$status]);
|
||||
return $ok ? $this->success(null, '操作成功') : $this->fail('操作失败', 500);
|
||||
return $ok ? $this->success(null, 'updated') : $this->fail('update failed', 500);
|
||||
}
|
||||
|
||||
public function profileSave(Request $request)
|
||||
{
|
||||
$merchantId = (int)$request->post('merchant_id', 0);
|
||||
if ($merchantId <= 0) {
|
||||
return $this->fail('merchant_id is required', 400);
|
||||
}
|
||||
|
||||
$merchant = $this->merchantRepository->find($merchantId);
|
||||
if (!$merchant) {
|
||||
return $this->fail('merchant not found', 404);
|
||||
}
|
||||
|
||||
$riskLevel = trim((string)$request->post('risk_level', 'standard'));
|
||||
$settlementCycle = trim((string)$request->post('settlement_cycle', 't1'));
|
||||
if (!in_array($riskLevel, ['low', 'standard', 'high'], true)) {
|
||||
return $this->fail('invalid risk_level', 400);
|
||||
}
|
||||
if (!in_array($settlementCycle, ['d0', 't1', 'manual'], true)) {
|
||||
return $this->fail('invalid settlement_cycle', 400);
|
||||
}
|
||||
|
||||
$profile = [
|
||||
'group_code' => trim((string)$request->post('group_code', '')),
|
||||
'contact_name' => trim((string)$request->post('contact_name', '')),
|
||||
'contact_phone' => trim((string)$request->post('contact_phone', '')),
|
||||
'notify_email' => trim((string)$request->post('notify_email', '')),
|
||||
'callback_domain' => trim((string)$request->post('callback_domain', '')),
|
||||
'callback_ip_whitelist' => trim((string)$request->post('callback_ip_whitelist', '')),
|
||||
'risk_level' => $riskLevel,
|
||||
'single_limit' => max(0, (float)$request->post('single_limit', 0)),
|
||||
'daily_limit' => max(0, (float)$request->post('daily_limit', 0)),
|
||||
'settlement_cycle' => $settlementCycle,
|
||||
'tech_support' => trim((string)$request->post('tech_support', '')),
|
||||
'remark' => trim((string)$request->post('remark', '')),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
if ($profile['group_code'] !== '') {
|
||||
$groupExists = false;
|
||||
foreach ($this->getConfigEntries('merchant_groups') as $group) {
|
||||
if (($group['group_code'] ?? '') === $profile['group_code']) {
|
||||
$groupExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$groupExists) {
|
||||
return $this->fail('group_code not found', 400);
|
||||
}
|
||||
}
|
||||
|
||||
$stored = array_merge($this->defaultMerchantProfile(), $this->getConfigObject($this->merchantProfileKey($merchantId)), $profile);
|
||||
$this->systemConfigService->setValue($this->merchantProfileKey($merchantId), $stored);
|
||||
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
public function statistics(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildOpFilters($request);
|
||||
|
||||
$summaryQuery = Db::table('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_pay_channel as pc', 'pc.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
|
||||
$join->on('o.merchant_id', '=', 'm.id');
|
||||
if (!empty($filters['created_from'])) {
|
||||
$join->where('o.created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$join->where('o.created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
});
|
||||
$this->applyMerchantFilters($summaryQuery, $filters);
|
||||
|
||||
$summaryRow = $summaryQuery
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT m.id) AS merchant_count,
|
||||
COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS active_merchant_count,
|
||||
COUNT(DISTINCT ma.id) AS app_count,
|
||||
COUNT(DISTINCT pc.id) AS channel_count,
|
||||
COUNT(DISTINCT o.id) AS order_count,
|
||||
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount'
|
||||
)
|
||||
->first();
|
||||
|
||||
$listQuery = Db::table('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_pay_channel as pc', 'pc.merchant_id', '=', 'm.id')
|
||||
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
|
||||
$join->on('o.merchant_id', '=', 'm.id');
|
||||
if (!empty($filters['created_from'])) {
|
||||
$join->where('o.created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$join->where('o.created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
});
|
||||
$this->applyMerchantFilters($listQuery, $filters);
|
||||
|
||||
$paginator = $listQuery
|
||||
->selectRaw(
|
||||
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at,
|
||||
COUNT(DISTINCT ma.id) AS app_count,
|
||||
COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count,
|
||||
COUNT(DISTINCT pc.id) AS channel_count,
|
||||
COUNT(DISTINCT o.id) AS order_count,
|
||||
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
|
||||
COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS success_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
|
||||
MAX(o.created_at) AS last_order_at'
|
||||
)
|
||||
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at')
|
||||
->orderByDesc('m.id')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'active_merchant_count' => (int)($summaryRow->active_merchant_count ?? 0),
|
||||
'app_count' => (int)($summaryRow->app_count ?? 0),
|
||||
'channel_count' => (int)($summaryRow->channel_count ?? 0),
|
||||
'order_count' => (int)($summaryRow->order_count ?? 0),
|
||||
'success_order_count' => (int)($summaryRow->success_order_count ?? 0),
|
||||
'success_amount' => (string)($summaryRow->success_amount ?? '0.00'),
|
||||
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
|
||||
],
|
||||
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function funds(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$filters = $this->buildOpFilters($request);
|
||||
|
||||
$summaryQuery = Db::table('ma_merchant as m')
|
||||
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
|
||||
$join->on('o.merchant_id', '=', 'm.id');
|
||||
if (!empty($filters['created_from'])) {
|
||||
$join->where('o.created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$join->where('o.created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
});
|
||||
$this->applyMerchantFilters($summaryQuery, $filters);
|
||||
|
||||
$summaryRow = $summaryQuery
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT m.id) AS merchant_count,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount,
|
||||
COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders'
|
||||
)
|
||||
->first();
|
||||
|
||||
$listQuery = Db::table('ma_merchant as m')
|
||||
->leftJoin('ma_pay_order as o', function ($join) use ($filters) {
|
||||
$join->on('o.merchant_id', '=', 'm.id');
|
||||
if (!empty($filters['created_from'])) {
|
||||
$join->where('o.created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$join->where('o.created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
});
|
||||
$this->applyMerchantFilters($listQuery, $filters);
|
||||
|
||||
$paginator = $listQuery
|
||||
->selectRaw(
|
||||
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at,
|
||||
COUNT(DISTINCT CASE WHEN o.status = 1 THEN o.id END) AS success_order_count,
|
||||
COUNT(DISTINCT CASE WHEN o.status = 0 THEN o.id END) AS pending_order_count,
|
||||
COUNT(DISTINCT CASE WHEN o.notify_stat = 0 THEN o.id END) AS notify_pending_orders,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount ELSE 0 END), 0) AS settled_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 0 THEN o.amount ELSE 0 END), 0) AS pending_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.fee ELSE 0 END), 0) AS fee_amount,
|
||||
COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) AS net_amount,
|
||||
MAX(o.pay_at) AS last_pay_at'
|
||||
)
|
||||
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at')
|
||||
->orderByRaw('COALESCE(SUM(CASE WHEN o.status = 1 THEN o.real_amount - o.fee ELSE 0 END), 0) DESC')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'settled_amount' => (string)($summaryRow->settled_amount ?? '0.00'),
|
||||
'pending_amount' => (string)($summaryRow->pending_amount ?? '0.00'),
|
||||
'fee_amount' => (string)($summaryRow->fee_amount ?? '0.00'),
|
||||
'net_amount' => (string)($summaryRow->net_amount ?? '0.00'),
|
||||
'notify_pending_orders' => (int)($summaryRow->notify_pending_orders ?? 0),
|
||||
],
|
||||
'list' => array_map(fn ($row) => (array)$row, $paginator->items()),
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function audit(Request $request)
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
$auditStatus = trim((string)$request->get('audit_status', ''));
|
||||
$keyword = trim((string)$request->get('keyword', ''));
|
||||
|
||||
$summaryQuery = Db::table('ma_merchant as m');
|
||||
if ($keyword !== '') {
|
||||
$summaryQuery->where(function ($query) use ($keyword) {
|
||||
$query->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
if ($auditStatus === 'pending') {
|
||||
$summaryQuery->where('m.status', 0);
|
||||
} elseif ($auditStatus === 'approved') {
|
||||
$summaryQuery->where('m.status', 1);
|
||||
}
|
||||
|
||||
$summaryRow = $summaryQuery
|
||||
->selectRaw(
|
||||
'COUNT(DISTINCT m.id) AS merchant_count,
|
||||
COUNT(DISTINCT CASE WHEN m.status = 0 THEN m.id END) AS pending_count,
|
||||
COUNT(DISTINCT CASE WHEN m.status = 1 THEN m.id END) AS approved_count'
|
||||
)
|
||||
->first();
|
||||
|
||||
$listQuery = Db::table('ma_merchant as m')
|
||||
->leftJoin('ma_merchant_app as ma', 'ma.merchant_id', '=', 'm.id');
|
||||
if ($keyword !== '') {
|
||||
$listQuery->where(function ($query) use ($keyword) {
|
||||
$query->where('m.merchant_no', 'like', '%' . $keyword . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
if ($auditStatus === 'pending') {
|
||||
$listQuery->where('m.status', 0);
|
||||
} elseif ($auditStatus === 'approved') {
|
||||
$listQuery->where('m.status', 1);
|
||||
}
|
||||
|
||||
$paginator = $listQuery
|
||||
->selectRaw(
|
||||
'm.id, m.merchant_no, m.merchant_name, m.funds_mode, m.status, m.created_at, m.updated_at,
|
||||
COUNT(DISTINCT ma.id) AS app_count,
|
||||
COUNT(DISTINCT CASE WHEN ma.status = 1 THEN ma.id END) AS active_app_count,
|
||||
COUNT(DISTINCT CASE WHEN ma.status = 0 THEN ma.id END) AS disabled_app_count'
|
||||
)
|
||||
->groupBy('m.id', 'm.merchant_no', 'm.merchant_name', 'm.funds_mode', 'm.status', 'm.created_at', 'm.updated_at')
|
||||
->orderBy('m.status', 'asc')
|
||||
->orderByDesc('m.id')
|
||||
->paginate($pageSize, ['*'], 'page', $page);
|
||||
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$item = (array)$row;
|
||||
$item['audit_status'] = (int)($item['status'] ?? 0) === 1 ? 'approved' : 'pending';
|
||||
$item['audit_status_text'] = $item['audit_status'] === 'approved' ? 'approved' : 'pending';
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'summary' => [
|
||||
'merchant_count' => (int)($summaryRow->merchant_count ?? 0),
|
||||
'pending_count' => (int)($summaryRow->pending_count ?? 0),
|
||||
'approved_count' => (int)($summaryRow->approved_count ?? 0),
|
||||
],
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function auditAction(Request $request)
|
||||
{
|
||||
$id = (int)$request->post('id', 0);
|
||||
$action = trim((string)$request->post('action', ''));
|
||||
|
||||
if ($id <= 0 || !in_array($action, ['approve', 'suspend'], true)) {
|
||||
return $this->fail('invalid params', 400);
|
||||
}
|
||||
|
||||
$status = $action === 'approve' ? 1 : 0;
|
||||
Db::connection()->transaction(function () use ($id, $status) {
|
||||
Db::table('ma_merchant')->where('id', $id)->update([
|
||||
'status' => $status,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
Db::table('ma_merchant_app')->where('merchant_id', $id)->update([
|
||||
'status' => $status,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->success(null, $action === 'approve' ? 'approved' : 'suspended');
|
||||
}
|
||||
|
||||
public function groupList(Request $request)
|
||||
{
|
||||
$page = max(1, (int)$request->get('page', 1));
|
||||
$pageSize = max(1, (int)$request->get('page_size', 10));
|
||||
$keyword = trim((string)$request->get('keyword', ''));
|
||||
$status = $request->get('status', '');
|
||||
|
||||
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
|
||||
$items = array_values(array_filter($items, function (array $item) use ($keyword, $status) {
|
||||
if ($keyword !== '') {
|
||||
$haystacks = [
|
||||
strtolower((string)($item['group_code'] ?? '')),
|
||||
strtolower((string)($item['group_name'] ?? '')),
|
||||
strtolower((string)($item['remark'] ?? '')),
|
||||
];
|
||||
$needle = strtolower($keyword);
|
||||
$matched = false;
|
||||
foreach ($haystacks as $haystack) {
|
||||
if ($haystack !== '' && str_contains($haystack, $needle)) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== '' && (int)$item['status'] !== (int)$status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
usort($items, function (array $a, array $b) {
|
||||
$sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0);
|
||||
if ($sortCompare !== 0) {
|
||||
return $sortCompare;
|
||||
}
|
||||
return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
return $this->success($this->buildConfigPagePayload(
|
||||
$items,
|
||||
$page,
|
||||
$pageSize,
|
||||
[
|
||||
'group_count' => count($items),
|
||||
'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)),
|
||||
'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public function groupSave(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
$id = trim((string)($data['id'] ?? ''));
|
||||
$groupCode = trim((string)($data['group_code'] ?? ''));
|
||||
$groupName = trim((string)($data['group_name'] ?? ''));
|
||||
|
||||
if ($groupCode === '' || $groupName === '') {
|
||||
return $this->fail('group_code and group_name are required', 400);
|
||||
}
|
||||
|
||||
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
|
||||
foreach ($items as $item) {
|
||||
if (($item['group_code'] ?? '') === $groupCode && ($item['id'] ?? '') !== $id) {
|
||||
return $this->fail('group_code already exists', 400);
|
||||
}
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$saved = false;
|
||||
foreach ($items as &$item) {
|
||||
if (($item['id'] ?? '') !== $id || $id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = array_merge($item, [
|
||||
'group_code' => $groupCode,
|
||||
'group_name' => $groupName,
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'remark' => trim((string)($data['remark'] ?? '')),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$saved = true;
|
||||
break;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
if (!$saved) {
|
||||
$items[] = [
|
||||
'id' => $id !== '' ? $id : uniqid('grp_', true),
|
||||
'group_code' => $groupCode,
|
||||
'group_name' => $groupName,
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'remark' => trim((string)($data['remark'] ?? '')),
|
||||
'merchant_count' => 0,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$this->setConfigEntries('merchant_groups', $items);
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
public function groupDelete(Request $request)
|
||||
{
|
||||
$id = trim((string)$request->post('id', ''));
|
||||
if ($id === '') {
|
||||
return $this->fail('id is required', 400);
|
||||
}
|
||||
|
||||
$items = array_map([$this, 'normalizeGroupItem'], $this->getConfigEntries('merchant_groups'));
|
||||
$filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id));
|
||||
if (count($filtered) === count($items)) {
|
||||
return $this->fail('group not found', 404);
|
||||
}
|
||||
|
||||
$this->setConfigEntries('merchant_groups', $filtered);
|
||||
return $this->success(null, 'deleted');
|
||||
}
|
||||
|
||||
public function packageList(Request $request)
|
||||
{
|
||||
$page = max(1, (int)$request->get('page', 1));
|
||||
$pageSize = max(1, (int)$request->get('page_size', 10));
|
||||
$keyword = trim((string)$request->get('keyword', ''));
|
||||
$status = $request->get('status', '');
|
||||
$apiType = trim((string)$request->get('api_type', ''));
|
||||
|
||||
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
|
||||
$items = array_values(array_filter($items, function (array $item) use ($keyword, $status, $apiType) {
|
||||
if ($keyword !== '') {
|
||||
$haystacks = [
|
||||
strtolower((string)($item['package_code'] ?? '')),
|
||||
strtolower((string)($item['package_name'] ?? '')),
|
||||
strtolower((string)($item['fee_desc'] ?? '')),
|
||||
strtolower((string)($item['remark'] ?? '')),
|
||||
];
|
||||
$needle = strtolower($keyword);
|
||||
$matched = false;
|
||||
foreach ($haystacks as $haystack) {
|
||||
if ($haystack !== '' && str_contains($haystack, $needle)) {
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($status !== '' && (int)$item['status'] !== (int)$status) {
|
||||
return false;
|
||||
}
|
||||
if ($apiType !== '' && (string)$item['api_type'] !== $apiType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
usort($items, function (array $a, array $b) {
|
||||
$sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0);
|
||||
if ($sortCompare !== 0) {
|
||||
return $sortCompare;
|
||||
}
|
||||
return strcmp((string)($b['updated_at'] ?? ''), (string)($a['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
$apiTypeCount = [];
|
||||
foreach ($items as $item) {
|
||||
$type = (string)($item['api_type'] ?? 'custom');
|
||||
$apiTypeCount[$type] = ($apiTypeCount[$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $this->success($this->buildConfigPagePayload(
|
||||
$items,
|
||||
$page,
|
||||
$pageSize,
|
||||
[
|
||||
'package_count' => count($items),
|
||||
'active_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] === 1)),
|
||||
'disabled_count' => count(array_filter($items, fn (array $item) => (int)$item['status'] !== 1)),
|
||||
'api_type_count' => $apiTypeCount,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public function packageSave(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
$id = trim((string)($data['id'] ?? ''));
|
||||
$packageCode = trim((string)($data['package_code'] ?? ''));
|
||||
$packageName = trim((string)($data['package_name'] ?? ''));
|
||||
$apiType = trim((string)($data['api_type'] ?? 'epay'));
|
||||
|
||||
if ($packageCode === '' || $packageName === '') {
|
||||
return $this->fail('package_code and package_name are required', 400);
|
||||
}
|
||||
if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) {
|
||||
return $this->fail('invalid api_type', 400);
|
||||
}
|
||||
|
||||
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
|
||||
foreach ($items as $item) {
|
||||
if (($item['package_code'] ?? '') === $packageCode && ($item['id'] ?? '') !== $id) {
|
||||
return $this->fail('package_code already exists', 400);
|
||||
}
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$saved = false;
|
||||
foreach ($items as &$item) {
|
||||
if (($item['id'] ?? '') !== $id || $id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$item = array_merge($item, [
|
||||
'package_code' => $packageCode,
|
||||
'package_name' => $packageName,
|
||||
'api_type' => $apiType,
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)),
|
||||
'daily_limit' => trim((string)($data['daily_limit'] ?? '')),
|
||||
'fee_desc' => trim((string)($data['fee_desc'] ?? '')),
|
||||
'callback_policy' => trim((string)($data['callback_policy'] ?? '')),
|
||||
'remark' => trim((string)($data['remark'] ?? '')),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$saved = true;
|
||||
break;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
if (!$saved) {
|
||||
$items[] = [
|
||||
'id' => $id !== '' ? $id : uniqid('pkg_', true),
|
||||
'package_code' => $packageCode,
|
||||
'package_name' => $packageName,
|
||||
'api_type' => $apiType,
|
||||
'sort' => (int)($data['sort'] ?? 0),
|
||||
'status' => (int)($data['status'] ?? 1),
|
||||
'channel_limit' => max(0, (int)($data['channel_limit'] ?? 0)),
|
||||
'daily_limit' => trim((string)($data['daily_limit'] ?? '')),
|
||||
'fee_desc' => trim((string)($data['fee_desc'] ?? '')),
|
||||
'callback_policy' => trim((string)($data['callback_policy'] ?? '')),
|
||||
'remark' => trim((string)($data['remark'] ?? '')),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$this->setConfigEntries('merchant_packages', $items);
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
public function packageDelete(Request $request)
|
||||
{
|
||||
$id = trim((string)$request->post('id', ''));
|
||||
if ($id === '') {
|
||||
return $this->fail('id is required', 400);
|
||||
}
|
||||
|
||||
$items = array_map([$this, 'normalizePackageItem'], $this->getConfigEntries('merchant_packages'));
|
||||
$filtered = array_values(array_filter($items, fn (array $item) => ($item['id'] ?? '') !== $id));
|
||||
if (count($filtered) === count($items)) {
|
||||
return $this->fail('package not found', 404);
|
||||
}
|
||||
|
||||
$this->setConfigEntries('merchant_packages', $filtered);
|
||||
return $this->success(null, 'deleted');
|
||||
}
|
||||
|
||||
private function buildOpFilters(Request $request): array
|
||||
{
|
||||
return [
|
||||
'merchant_id' => (int)$request->get('merchant_id', 0),
|
||||
'status' => (string)$request->get('status', ''),
|
||||
'keyword' => trim((string)$request->get('keyword', '')),
|
||||
'created_from' => trim((string)$request->get('created_from', '')),
|
||||
'created_to' => trim((string)$request->get('created_to', '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function applyMerchantFilters($query, array $filters): void
|
||||
{
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('m.status', (int)$filters['status']);
|
||||
}
|
||||
if (!empty($filters['merchant_id'])) {
|
||||
$query->where('m.id', (int)$filters['merchant_id']);
|
||||
}
|
||||
if (!empty($filters['keyword'])) {
|
||||
$query->where(function ($builder) use ($filters) {
|
||||
$builder->where('m.merchant_no', 'like', '%' . $filters['keyword'] . '%')
|
||||
->orWhere('m.merchant_name', 'like', '%' . $filters['keyword'] . '%');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function getConfigEntries(string $configKey): array
|
||||
{
|
||||
$raw = $this->systemConfigService->getValue($configKey, '[]');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($decoded, 'is_array'));
|
||||
}
|
||||
|
||||
private function setConfigEntries(string $configKey, array $items): void
|
||||
{
|
||||
$this->systemConfigService->setValue($configKey, array_values($items));
|
||||
}
|
||||
|
||||
private function buildConfigPagePayload(array $items, int $page, int $pageSize, array $summary): array
|
||||
{
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'list' => array_values(array_slice($items, $offset, $pageSize)),
|
||||
'total' => count($items),
|
||||
'page' => $page,
|
||||
'size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeGroupItem(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (string)($item['id'] ?? ''),
|
||||
'group_code' => trim((string)($item['group_code'] ?? '')),
|
||||
'group_name' => trim((string)($item['group_name'] ?? '')),
|
||||
'sort' => (int)($item['sort'] ?? 0),
|
||||
'status' => (int)($item['status'] ?? 1),
|
||||
'remark' => trim((string)($item['remark'] ?? '')),
|
||||
'merchant_count' => max(0, (int)($item['merchant_count'] ?? 0)),
|
||||
'created_at' => (string)($item['created_at'] ?? ''),
|
||||
'updated_at' => (string)($item['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePackageItem(array $item): array
|
||||
{
|
||||
$apiType = trim((string)($item['api_type'] ?? 'epay'));
|
||||
if (!in_array($apiType, ['epay', 'openapi', 'custom'], true)) {
|
||||
$apiType = 'custom';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string)($item['id'] ?? ''),
|
||||
'package_code' => trim((string)($item['package_code'] ?? '')),
|
||||
'package_name' => trim((string)($item['package_name'] ?? '')),
|
||||
'api_type' => $apiType,
|
||||
'sort' => (int)($item['sort'] ?? 0),
|
||||
'status' => (int)($item['status'] ?? 1),
|
||||
'channel_limit' => max(0, (int)($item['channel_limit'] ?? 0)),
|
||||
'daily_limit' => trim((string)($item['daily_limit'] ?? '')),
|
||||
'fee_desc' => trim((string)($item['fee_desc'] ?? '')),
|
||||
'callback_policy' => trim((string)($item['callback_policy'] ?? '')),
|
||||
'remark' => trim((string)($item['remark'] ?? '')),
|
||||
'created_at' => (string)($item['created_at'] ?? ''),
|
||||
'updated_at' => (string)($item['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function merchantProfileKey(int $merchantId): string
|
||||
{
|
||||
return 'merchant_profile_' . $merchantId;
|
||||
}
|
||||
|
||||
private function defaultMerchantProfile(): array
|
||||
{
|
||||
return [
|
||||
'group_code' => '',
|
||||
'contact_name' => '',
|
||||
'contact_phone' => '',
|
||||
'notify_email' => '',
|
||||
'callback_domain' => '',
|
||||
'callback_ip_whitelist' => '',
|
||||
'risk_level' => 'standard',
|
||||
'single_limit' => 0,
|
||||
'daily_limit' => 0,
|
||||
'settlement_cycle' => 't1',
|
||||
'tech_support' => '',
|
||||
'remark' => '',
|
||||
'updated_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
private function buildGroupMap(): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($this->getConfigEntries('merchant_groups') as $group) {
|
||||
$groupCode = trim((string)($group['group_code'] ?? ''));
|
||||
if ($groupCode === '') {
|
||||
continue;
|
||||
}
|
||||
$map[$groupCode] = trim((string)($group['group_name'] ?? $groupCode));
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function getConfigObject(string $configKey): array
|
||||
{
|
||||
$raw = $this->systemConfigService->getValue($configKey, '{}');
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\models\Merchant;
|
||||
use app\models\MerchantApp;
|
||||
use app\models\PaymentChannel;
|
||||
use app\models\PaymentMethod;
|
||||
use app\repositories\PaymentMethodRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
use app\services\PayOrderService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 订单管理
|
||||
@@ -27,28 +32,20 @@ class OrderController extends BaseController
|
||||
{
|
||||
$page = (int)$request->get('page', 1);
|
||||
$pageSize = (int)$request->get('page_size', 10);
|
||||
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
$methodId = 0;
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
$methodId = $method ? (int)$method->id : 0;
|
||||
}
|
||||
|
||||
$filters = [
|
||||
'merchant_id' => (int)$request->get('merchant_id', 0),
|
||||
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
|
||||
'method_id' => $methodId,
|
||||
'channel_id' => (int)$request->get('channel_id', 0),
|
||||
'status' => $request->get('status', ''),
|
||||
'order_id' => trim((string)$request->get('order_id', '')),
|
||||
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
|
||||
'created_from' => trim((string)$request->get('created_from', '')),
|
||||
'created_to' => trim((string)$request->get('created_to', '')),
|
||||
];
|
||||
$filters = $this->buildListFilters($request);
|
||||
|
||||
$paginator = $this->orderRepository->searchPaginate($filters, $page, $pageSize);
|
||||
return $this->page($paginator);
|
||||
$items = [];
|
||||
foreach ($paginator->items() as $row) {
|
||||
$items[] = $this->formatOrderRow($row);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'list' => $items,
|
||||
'total' => $paginator->total(),
|
||||
'page' => $paginator->currentPage(),
|
||||
'size' => $paginator->perPage(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +68,139 @@ class OrderController extends BaseController
|
||||
return $this->fail('订单不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success($row);
|
||||
return $this->success($this->formatOrderRow($row));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /adminapi/order/export
|
||||
*/
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$limit = 5000;
|
||||
$filters = $this->buildListFilters($request);
|
||||
$rows = $this->orderRepository->searchList($filters, $limit);
|
||||
|
||||
$merchantIds = [];
|
||||
$merchantAppIds = [];
|
||||
$methodIds = [];
|
||||
$channelIds = [];
|
||||
$items = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->formatOrderRow($row);
|
||||
$items[] = $item;
|
||||
if (!empty($item['merchant_id'])) {
|
||||
$merchantIds[] = (int)$item['merchant_id'];
|
||||
}
|
||||
if (!empty($item['merchant_app_id'])) {
|
||||
$merchantAppIds[] = (int)$item['merchant_app_id'];
|
||||
}
|
||||
if (!empty($item['method_id'])) {
|
||||
$methodIds[] = (int)$item['method_id'];
|
||||
}
|
||||
if (!empty($item['channel_id'])) {
|
||||
$channelIds[] = (int)$item['channel_id'];
|
||||
}
|
||||
}
|
||||
|
||||
$merchantMap = Merchant::query()
|
||||
->whereIn('id', array_values(array_unique($merchantIds)))
|
||||
->get(['id', 'merchant_no', 'merchant_name'])
|
||||
->keyBy('id');
|
||||
$merchantAppMap = MerchantApp::query()
|
||||
->whereIn('id', array_values(array_unique($merchantAppIds)))
|
||||
->get(['id', 'app_id', 'app_name'])
|
||||
->keyBy('id');
|
||||
$methodMap = PaymentMethod::query()
|
||||
->whereIn('id', array_values(array_unique($methodIds)))
|
||||
->get(['id', 'method_code', 'method_name'])
|
||||
->keyBy('id');
|
||||
$channelMap = PaymentChannel::query()
|
||||
->whereIn('id', array_values(array_unique($channelIds)))
|
||||
->get(['id', 'chan_code', 'chan_name'])
|
||||
->keyBy('id');
|
||||
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
fputcsv($stream, [
|
||||
'系统单号',
|
||||
'商户单号',
|
||||
'商户编号',
|
||||
'商户名称',
|
||||
'应用APPID',
|
||||
'应用名称',
|
||||
'支付方式编码',
|
||||
'支付方式名称',
|
||||
'通道编码',
|
||||
'通道名称',
|
||||
'订单金额',
|
||||
'实收金额',
|
||||
'手续费',
|
||||
'币种',
|
||||
'订单状态',
|
||||
'路由结果',
|
||||
'路由模式',
|
||||
'策略名称',
|
||||
'通道单号',
|
||||
'通道交易号',
|
||||
'通知状态',
|
||||
'通知次数',
|
||||
'客户端IP',
|
||||
'商品标题',
|
||||
'创建时间',
|
||||
'支付时间',
|
||||
'路由错误',
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$merchant = $merchantMap->get((int)($item['merchant_id'] ?? 0));
|
||||
$merchantApp = $merchantAppMap->get((int)($item['merchant_app_id'] ?? 0));
|
||||
$method = $methodMap->get((int)($item['method_id'] ?? 0));
|
||||
$channel = $channelMap->get((int)($item['channel_id'] ?? 0));
|
||||
|
||||
fputcsv($stream, [
|
||||
(string)($item['order_id'] ?? ''),
|
||||
(string)($item['mch_order_no'] ?? ''),
|
||||
(string)($merchant->merchant_no ?? ''),
|
||||
(string)($merchant->merchant_name ?? ''),
|
||||
(string)($merchantApp->app_id ?? ''),
|
||||
(string)($merchantApp->app_name ?? ''),
|
||||
(string)($method->method_code ?? ''),
|
||||
(string)($method->method_name ?? ''),
|
||||
(string)($channel->chan_code ?? $item['route_channel_code'] ?? ''),
|
||||
(string)($channel->chan_name ?? $item['route_channel_name'] ?? ''),
|
||||
(string)($item['amount'] ?? '0.00'),
|
||||
(string)($item['real_amount'] ?? '0.00'),
|
||||
(string)($item['fee'] ?? '0.00'),
|
||||
(string)($item['currency'] ?? ''),
|
||||
$this->statusText((int)($item['status'] ?? 0)),
|
||||
(string)($item['route_source_text'] ?? ''),
|
||||
(string)($item['route_mode_text'] ?? ''),
|
||||
(string)($item['route_policy_name'] ?? ''),
|
||||
(string)($item['chan_order_no'] ?? ''),
|
||||
(string)($item['chan_trade_no'] ?? ''),
|
||||
$this->notifyStatusText((int)($item['notify_stat'] ?? 0)),
|
||||
(string)($item['notify_cnt'] ?? '0'),
|
||||
(string)($item['client_ip'] ?? ''),
|
||||
(string)($item['subject'] ?? ''),
|
||||
(string)($item['created_at'] ?? ''),
|
||||
(string)($item['pay_at'] ?? ''),
|
||||
(string)($item['route_error']['message'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
rewind($stream);
|
||||
$content = stream_get_contents($stream) ?: '';
|
||||
fclose($stream);
|
||||
|
||||
$filename = 'orders-' . date('Ymd-His') . '.csv';
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => "attachment; filename*=UTF-8''" . rawurlencode($filename),
|
||||
'X-Export-Count' => (string)count($items),
|
||||
'X-Export-Limit' => (string)$limit,
|
||||
'X-Export-Limited' => count($items) >= $limit ? '1' : '0',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,5 +225,92 @@ class OrderController extends BaseController
|
||||
return $this->fail($e->getMessage(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function formatOrderRow(object $row): array
|
||||
{
|
||||
$data = method_exists($row, 'toArray') ? $row->toArray() : (array)$row;
|
||||
$extra = is_array($data['extra'] ?? null) ? $data['extra'] : [];
|
||||
$routing = is_array($extra['routing'] ?? null) ? $extra['routing'] : null;
|
||||
$routeError = is_array($extra['route_error'] ?? null) ? $extra['route_error'] : null;
|
||||
|
||||
$data['routing'] = $routing;
|
||||
$data['route_error'] = $routeError;
|
||||
$data['route_candidates'] = is_array($routing['candidates'] ?? null) ? $routing['candidates'] : [];
|
||||
$data['route_policy_name'] = (string)($routing['policy']['policy_name'] ?? '');
|
||||
$data['route_source'] = (string)($routing['source'] ?? '');
|
||||
$data['route_source_text'] = $this->routeSourceText($routing, $routeError);
|
||||
$data['route_mode_text'] = $this->routeModeText((string)($routing['route_mode'] ?? ''));
|
||||
$data['route_channel_name'] = (string)($routing['selected_channel_name'] ?? '');
|
||||
$data['route_channel_code'] = (string)($routing['selected_channel_code'] ?? '');
|
||||
$data['route_state'] = $routeError
|
||||
? 'error'
|
||||
: ($routing ? (string)($routing['source'] ?? 'unknown') : 'none');
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function buildListFilters(Request $request): array
|
||||
{
|
||||
$methodCode = trim((string)$request->get('method_code', ''));
|
||||
$methodId = 0;
|
||||
if ($methodCode !== '') {
|
||||
$method = $this->methodRepository->findAnyByCode($methodCode);
|
||||
$methodId = $method ? (int)$method->id : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'merchant_id' => (int)$request->get('merchant_id', 0),
|
||||
'merchant_app_id' => (int)$request->get('merchant_app_id', 0),
|
||||
'method_id' => $methodId,
|
||||
'channel_id' => (int)$request->get('channel_id', 0),
|
||||
'route_state' => trim((string)$request->get('route_state', '')),
|
||||
'route_policy_name' => trim((string)$request->get('route_policy_name', '')),
|
||||
'status' => $request->get('status', ''),
|
||||
'order_id' => trim((string)$request->get('order_id', '')),
|
||||
'mch_order_no' => trim((string)$request->get('mch_order_no', '')),
|
||||
'created_from' => trim((string)$request->get('created_from', '')),
|
||||
'created_to' => trim((string)$request->get('created_to', '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function statusText(int $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
0 => '待支付',
|
||||
1 => '成功',
|
||||
2 => '失败',
|
||||
3 => '关闭',
|
||||
default => (string)$status,
|
||||
};
|
||||
}
|
||||
|
||||
private function notifyStatusText(int $notifyStatus): string
|
||||
{
|
||||
return $notifyStatus === 1 ? '已通知' : '待通知';
|
||||
}
|
||||
|
||||
private function routeSourceText(?array $routing, ?array $routeError): string
|
||||
{
|
||||
if ($routeError) {
|
||||
return '路由失败';
|
||||
}
|
||||
|
||||
return match ((string)($routing['source'] ?? '')) {
|
||||
'policy' => '策略命中',
|
||||
'fallback' => '回退选择',
|
||||
default => '未记录',
|
||||
};
|
||||
}
|
||||
|
||||
private function routeModeText(string $routeMode): string
|
||||
{
|
||||
return match ($routeMode) {
|
||||
'priority' => '优先级',
|
||||
'weight' => '权重分流',
|
||||
'failover' => '主备切换',
|
||||
'sort' => '排序兜底',
|
||||
default => $routeMode ?: '-',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,76 +3,310 @@
|
||||
namespace app\http\admin\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\SystemConfigService;
|
||||
use app\services\SystemSettingService;
|
||||
use support\Db;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 系统控制器
|
||||
*/
|
||||
class SystemController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected SystemSettingService $settingService
|
||||
protected SystemSettingService $settingService,
|
||||
protected SystemConfigService $configService
|
||||
) {
|
||||
}
|
||||
/**
|
||||
* GET /system/getDict
|
||||
* GET /system/getDict/{code}
|
||||
*
|
||||
* 获取字典数据
|
||||
* 支持通过路由参数 code 查询指定字典,不传则返回所有字典
|
||||
*
|
||||
* 示例:
|
||||
* GET /adminapi/system/getDict - 返回所有字典
|
||||
* GET /adminapi/system/getDict/gender - 返回性别字典
|
||||
* GET /adminapi/system/getDict/status - 返回状态字典
|
||||
*/
|
||||
|
||||
public function getDict(Request $request, string $code = '')
|
||||
{
|
||||
$data = $this->settingService->getDict($code);
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /system/base-config/tabs
|
||||
*
|
||||
* 获取所有Tab配置
|
||||
* 由 SystemSettingService 负责读取配置和缓存
|
||||
*/
|
||||
public function getTabsConfig()
|
||||
{
|
||||
$tabs = $this->settingService->getTabs();
|
||||
return $this->success($tabs);
|
||||
return $this->success($this->settingService->getTabs());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /system/base-config/form/{tabKey}
|
||||
*
|
||||
* 获取指定Tab的表单配置
|
||||
* 从 SystemSettingService 获取合并后的配置
|
||||
*/
|
||||
public function getFormConfig(Request $request, string $tabKey)
|
||||
{
|
||||
$formConfig = $this->settingService->getFormConfig($tabKey);
|
||||
return $this->success($formConfig);
|
||||
return $this->success($this->settingService->getFormConfig($tabKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /system/base-config/submit/{tabKey}
|
||||
*
|
||||
* 提交表单数据
|
||||
* 接收表单数据,直接使用字段名(fieldName)作为 config_key 保存到数据库
|
||||
*/
|
||||
public function submitConfig(Request $request, string $tabKey)
|
||||
{
|
||||
$formData = $request->post();
|
||||
|
||||
if (empty($formData)) {
|
||||
return $this->fail('提交数据不能为空', 400);
|
||||
return $this->fail('submitted data is empty', 400);
|
||||
}
|
||||
|
||||
$this->settingService->saveFormConfig($tabKey, $formData);
|
||||
return $this->success(null, '保存成功');
|
||||
return $this->success(null, 'saved');
|
||||
}
|
||||
|
||||
public function logFiles()
|
||||
{
|
||||
$logDir = runtime_path('logs');
|
||||
if (!is_dir($logDir)) {
|
||||
return $this->success([]);
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach (glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: [] as $file) {
|
||||
if (!is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'name' => basename($file),
|
||||
'size' => filesize($file) ?: 0,
|
||||
'updated_at' => date('Y-m-d H:i:s', filemtime($file) ?: time()),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, fn ($a, $b) => strcmp((string)$b['updated_at'], (string)$a['updated_at']));
|
||||
|
||||
return $this->success($items);
|
||||
}
|
||||
|
||||
public function logSummary()
|
||||
{
|
||||
$logDir = runtime_path('logs');
|
||||
if (!is_dir($logDir)) {
|
||||
return $this->success([
|
||||
'total_files' => 0,
|
||||
'total_size' => 0,
|
||||
'latest_file' => '',
|
||||
'categories' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
$files = glob($logDir . DIRECTORY_SEPARATOR . '*.log') ?: [];
|
||||
$categoryStats = [];
|
||||
$totalSize = 0;
|
||||
$latestFile = '';
|
||||
$latestTime = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = filesize($file) ?: 0;
|
||||
$updatedAt = filemtime($file) ?: 0;
|
||||
$name = basename($file);
|
||||
$category = $this->resolveLogCategory($name);
|
||||
|
||||
$totalSize += $size;
|
||||
if (!isset($categoryStats[$category])) {
|
||||
$categoryStats[$category] = [
|
||||
'category' => $category,
|
||||
'file_count' => 0,
|
||||
'total_size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$categoryStats[$category]['file_count']++;
|
||||
$categoryStats[$category]['total_size'] += $size;
|
||||
|
||||
if ($updatedAt >= $latestTime) {
|
||||
$latestTime = $updatedAt;
|
||||
$latestFile = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'total_files' => count($files),
|
||||
'total_size' => $totalSize,
|
||||
'latest_file' => $latestFile,
|
||||
'categories' => array_values($categoryStats),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logContent(Request $request)
|
||||
{
|
||||
$file = basename(trim((string)$request->get('file', '')));
|
||||
$lines = max(20, min(1000, (int)$request->get('lines', 200)));
|
||||
$keyword = trim((string)$request->get('keyword', ''));
|
||||
$level = strtoupper(trim((string)$request->get('level', '')));
|
||||
if ($file === '') {
|
||||
return $this->fail('file is required', 400);
|
||||
}
|
||||
|
||||
$logDir = runtime_path('logs');
|
||||
$fullPath = realpath($logDir . DIRECTORY_SEPARATOR . $file);
|
||||
$realLogDir = realpath($logDir);
|
||||
|
||||
if (!$fullPath || !$realLogDir || !str_starts_with($fullPath, $realLogDir) || !is_file($fullPath)) {
|
||||
return $this->fail('log file not found', 404);
|
||||
}
|
||||
|
||||
$contentLines = file($fullPath, FILE_IGNORE_NEW_LINES);
|
||||
if (!is_array($contentLines)) {
|
||||
return $this->fail('failed to read log file', 500);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$contentLines = array_values(array_filter($contentLines, static function ($line) use ($keyword) {
|
||||
return stripos($line, $keyword) !== false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($level !== '') {
|
||||
$contentLines = array_values(array_filter($contentLines, static function ($line) use ($level) {
|
||||
return stripos(strtoupper($line), $level) !== false;
|
||||
}));
|
||||
}
|
||||
|
||||
$matchedLineCount = count($contentLines);
|
||||
$tail = array_slice($contentLines, -$lines);
|
||||
return $this->success([
|
||||
'file' => $file,
|
||||
'size' => filesize($fullPath) ?: 0,
|
||||
'updated_at' => date('Y-m-d H:i:s', filemtime($fullPath) ?: time()),
|
||||
'line_count' => $matchedLineCount,
|
||||
'keyword' => $keyword,
|
||||
'level' => $level,
|
||||
'lines' => $tail,
|
||||
'content' => implode(PHP_EOL, $tail),
|
||||
]);
|
||||
}
|
||||
|
||||
public function noticeOverview()
|
||||
{
|
||||
$config = $this->configService->getValues([
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_ssl',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'from_email',
|
||||
'from_name',
|
||||
]);
|
||||
|
||||
$taskSummary = Db::table('ma_notify_task')
|
||||
->selectRaw(
|
||||
'COUNT(*) AS total_tasks,
|
||||
SUM(CASE WHEN status = \'PENDING\' THEN 1 ELSE 0 END) AS pending_tasks,
|
||||
SUM(CASE WHEN status = \'SUCCESS\' THEN 1 ELSE 0 END) AS success_tasks,
|
||||
SUM(CASE WHEN status = \'FAIL\' THEN 1 ELSE 0 END) AS fail_tasks,
|
||||
MAX(last_notify_at) AS last_notify_at'
|
||||
)
|
||||
->first();
|
||||
|
||||
$orderSummary = Db::table('ma_pay_order')
|
||||
->selectRaw(
|
||||
'SUM(CASE WHEN status = 1 AND notify_stat = 0 THEN 1 ELSE 0 END) AS notify_pending_orders,
|
||||
SUM(CASE WHEN status = 1 AND notify_stat = 1 THEN 1 ELSE 0 END) AS notified_orders'
|
||||
)
|
||||
->first();
|
||||
|
||||
$requiredKeys = ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email'];
|
||||
$configuredCount = 0;
|
||||
foreach ($requiredKeys as $key) {
|
||||
if (!empty($config[$key])) {
|
||||
$configuredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'config' => [
|
||||
'smtp_host' => (string)($config['smtp_host'] ?? ''),
|
||||
'smtp_port' => (string)($config['smtp_port'] ?? ''),
|
||||
'smtp_ssl' => in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true),
|
||||
'smtp_username' => $this->maskString((string)($config['smtp_username'] ?? '')),
|
||||
'from_email' => (string)($config['from_email'] ?? ''),
|
||||
'from_name' => (string)($config['from_name'] ?? ''),
|
||||
'configured_fields' => $configuredCount,
|
||||
'required_fields' => count($requiredKeys),
|
||||
'is_ready' => $configuredCount === count($requiredKeys),
|
||||
],
|
||||
'tasks' => [
|
||||
'total_tasks' => (int)($taskSummary->total_tasks ?? 0),
|
||||
'pending_tasks' => (int)($taskSummary->pending_tasks ?? 0),
|
||||
'success_tasks' => (int)($taskSummary->success_tasks ?? 0),
|
||||
'fail_tasks' => (int)($taskSummary->fail_tasks ?? 0),
|
||||
'last_notify_at' => (string)($taskSummary->last_notify_at ?? ''),
|
||||
],
|
||||
'orders' => [
|
||||
'notify_pending_orders' => (int)($orderSummary->notify_pending_orders ?? 0),
|
||||
'notified_orders' => (int)($orderSummary->notified_orders ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function noticeTest(Request $request)
|
||||
{
|
||||
$config = $this->configService->getValues([
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_ssl',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'from_email',
|
||||
]);
|
||||
|
||||
$missingFields = [];
|
||||
foreach (['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'from_email'] as $field) {
|
||||
if (empty($config[$field])) {
|
||||
$missingFields[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingFields !== []) {
|
||||
return $this->fail('missing config fields: ' . implode(', ', $missingFields), 400);
|
||||
}
|
||||
|
||||
$host = (string)$config['smtp_host'];
|
||||
$port = (int)$config['smtp_port'];
|
||||
$useSsl = in_array(strtolower((string)($config['smtp_ssl'] ?? '')), ['1', 'true', 'yes', 'on'], true);
|
||||
$transport = ($useSsl ? 'ssl://' : 'tcp://') . $host . ':' . $port;
|
||||
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$connection = @stream_socket_client($transport, $errno, $errstr, 5, STREAM_CLIENT_CONNECT);
|
||||
|
||||
if (!is_resource($connection)) {
|
||||
return $this->fail('smtp connection failed: ' . ($errstr !== '' ? $errstr : 'unknown error'), 500);
|
||||
}
|
||||
|
||||
stream_set_timeout($connection, 3);
|
||||
$banner = fgets($connection, 512) ?: '';
|
||||
fclose($connection);
|
||||
|
||||
return $this->success([
|
||||
'transport' => $transport,
|
||||
'banner' => trim($banner),
|
||||
'checked_at' => date('Y-m-d H:i:s'),
|
||||
'note' => 'only smtp connectivity and basic config were verified; no test email was sent',
|
||||
], 'smtp connection ok');
|
||||
}
|
||||
|
||||
private function resolveLogCategory(string $fileName): string
|
||||
{
|
||||
$name = strtolower($fileName);
|
||||
if (str_contains($name, 'pay') || str_contains($name, 'notify')) {
|
||||
return 'payment';
|
||||
}
|
||||
if (str_contains($name, 'queue') || str_contains($name, 'job')) {
|
||||
return 'queue';
|
||||
}
|
||||
if (str_contains($name, 'error') || str_contains($name, 'exception')) {
|
||||
return 'error';
|
||||
}
|
||||
if (str_contains($name, 'admin') || str_contains($name, 'system')) {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
private function maskString(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 4) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 2) . str_repeat('*', max(2, $length - 4)) . substr($value, -2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
namespace app\http\api\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\api\EpayService;
|
||||
use app\validation\EpayValidator;
|
||||
use app\services\api\EpayProtocolService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
@@ -14,43 +13,32 @@ use support\Response;
|
||||
class EpayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected EpayService $epayService
|
||||
) {}
|
||||
protected EpayProtocolService $epayProtocolService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面跳转支付
|
||||
*/
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$data = match ($request->method()) {
|
||||
'GET' => $request->get(),
|
||||
'POST' => $request->post(),
|
||||
default => $request->all(),
|
||||
};
|
||||
|
||||
try {
|
||||
// 参数校验(使用自定义 Validator + 场景)
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('submit')
|
||||
->validate();
|
||||
$result = $this->epayProtocolService->handleSubmit($request);
|
||||
$type = $result['response_type'] ?? '';
|
||||
|
||||
// 业务处理:创建订单并获取支付参数
|
||||
$result = $this->epayService->submit($params, $request);
|
||||
$payParams = $result['pay_params'] ?? [];
|
||||
|
||||
// 根据支付参数类型返回响应
|
||||
if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) {
|
||||
return redirect($payParams['url']);
|
||||
if ($type === 'redirect' && !empty($result['url'])) {
|
||||
return redirect($result['url']);
|
||||
}
|
||||
|
||||
if (($payParams['type'] ?? '') === 'form') {
|
||||
if (!empty($payParams['html'])) {
|
||||
return response($payParams['html'])->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']);
|
||||
}
|
||||
return $this->renderForm($payParams);
|
||||
if ($type === 'form_html') {
|
||||
return response((string)($result['html'] ?? ''))
|
||||
->withHeaders(['Content-Type' => 'text/html; charset=UTF-8']);
|
||||
}
|
||||
|
||||
if ($type === 'form_params') {
|
||||
return $this->renderForm((array)($result['form'] ?? []));
|
||||
}
|
||||
|
||||
// 如果没有匹配的类型,返回错误
|
||||
return $this->fail('支付参数生成失败');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
@@ -62,20 +50,12 @@ class EpayController extends BaseController
|
||||
*/
|
||||
public function mapi(Request $request)
|
||||
{
|
||||
$data = $request->post();
|
||||
|
||||
try {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('mapi')
|
||||
->validate();
|
||||
|
||||
$result = $this->epayService->mapi($params, $request);
|
||||
|
||||
return json($result);
|
||||
return json($this->epayProtocolService->handleMapi($request));
|
||||
} catch (\Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $e->getMessage(),
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -85,33 +65,12 @@ class EpayController extends BaseController
|
||||
*/
|
||||
public function api(Request $request)
|
||||
{
|
||||
$data = array_merge($request->get(), $request->post());
|
||||
|
||||
try {
|
||||
$act = strtolower($data['act'] ?? '');
|
||||
|
||||
if ($act === 'order') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_order')
|
||||
->validate();
|
||||
$result = $this->epayService->api($params);
|
||||
} elseif ($act === 'refund') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_refund')
|
||||
->validate();
|
||||
$result = $this->epayService->api($params);
|
||||
} else {
|
||||
$result = [
|
||||
'code' => 0,
|
||||
'msg' => '不支持的操作类型',
|
||||
];
|
||||
}
|
||||
|
||||
return json($result);
|
||||
return json($this->epayProtocolService->handleApi($request));
|
||||
} catch (\Throwable $e) {
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $e->getMessage(),
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace app\http\api\controller;
|
||||
|
||||
use app\common\base\BaseController;
|
||||
use app\services\PayOrderService;
|
||||
use app\services\PayNotifyService;
|
||||
use app\services\PluginService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 支付控制器(OpenAPI)
|
||||
@@ -12,30 +14,61 @@ use support\Request;
|
||||
class PayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService
|
||||
) {}
|
||||
protected PayNotifyService $payNotifyService,
|
||||
protected PluginService $pluginService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public function create(Request $request) {}
|
||||
public function create(Request $request)
|
||||
{
|
||||
return $this->fail('not implemented', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询订单
|
||||
*/
|
||||
public function query(Request $request) {}
|
||||
public function query(Request $request)
|
||||
{
|
||||
return $this->fail('not implemented', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭订单
|
||||
*/
|
||||
public function close(Request $request) {}
|
||||
public function close(Request $request)
|
||||
{
|
||||
return $this->fail('not implemented', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单退款
|
||||
*/
|
||||
public function refund(Request $request) {}
|
||||
public function refund(Request $request)
|
||||
{
|
||||
return $this->fail('not implemented', 501);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步通知
|
||||
*/
|
||||
public function notify(Request $request) {}
|
||||
public function notify(Request $request, string $pluginCode)
|
||||
{
|
||||
try {
|
||||
$plugin = $this->pluginService->getPluginInstance($pluginCode);
|
||||
$result = $this->payNotifyService->handleNotify($pluginCode, $request);
|
||||
$ackSuccess = method_exists($plugin, 'notifySuccess') ? $plugin->notifySuccess() : 'success';
|
||||
$ackFail = method_exists($plugin, 'notifyFail') ? $plugin->notifyFail() : 'fail';
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return $ackFail instanceof Response ? $ackFail : response((string)$ackFail);
|
||||
}
|
||||
|
||||
return $ackSuccess instanceof Response ? $ackSuccess : response((string)$ackSuccess);
|
||||
} catch (\Throwable $e) {
|
||||
return response('fail');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
app/models/CallbackInbox.php
Normal file
34
app/models/CallbackInbox.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\models;
|
||||
|
||||
use app\common\base\BaseModel;
|
||||
|
||||
/**
|
||||
* 回调幂等收件箱
|
||||
*
|
||||
* 对应表:ma_callback_inbox
|
||||
*/
|
||||
class CallbackInbox extends BaseModel
|
||||
{
|
||||
protected $table = 'ma_callback_inbox';
|
||||
|
||||
protected $fillable = [
|
||||
'event_key',
|
||||
'plugin_code',
|
||||
'order_id',
|
||||
'chan_trade_no',
|
||||
'payload',
|
||||
'process_status',
|
||||
'processed_at',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'process_status' => 'integer',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
43
app/repositories/CallbackInboxRepository.php
Normal file
43
app/repositories/CallbackInboxRepository.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace app\repositories;
|
||||
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\CallbackInbox;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
/**
|
||||
* 回调幂等收件箱仓储
|
||||
*/
|
||||
class CallbackInboxRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new CallbackInbox());
|
||||
}
|
||||
|
||||
public function findByEventKey(string $eventKey): ?CallbackInbox
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
->where('event_key', $eventKey)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试创建幂等事件,重复时返回 false。
|
||||
*/
|
||||
public function createIfAbsent(array $data): bool
|
||||
{
|
||||
try {
|
||||
$this->model->newQuery()->create($data);
|
||||
return true;
|
||||
} catch (QueryException $e) {
|
||||
// 1062: duplicate entry
|
||||
if ((int)($e->errorInfo[1] ?? 0) === 1062) {
|
||||
return false;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ namespace app\repositories;
|
||||
use app\common\base\BaseRepository;
|
||||
use app\models\PaymentChannel;
|
||||
|
||||
/**
|
||||
* 支付通道仓储
|
||||
*/
|
||||
class PaymentChannelRepository extends BaseRepository
|
||||
{
|
||||
public function __construct()
|
||||
@@ -15,9 +12,6 @@ class PaymentChannelRepository extends BaseRepository
|
||||
parent::__construct(new PaymentChannel());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据商户、应用、支付方式查找可用通道
|
||||
*/
|
||||
public function findAvailableChannel(int $merchantId, int $merchantAppId, int $methodId): ?PaymentChannel
|
||||
{
|
||||
return $this->model->newQuery()
|
||||
@@ -35,4 +29,49 @@ class PaymentChannelRepository extends BaseRepository
|
||||
->where('chan_code', $chanCode)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->buildSearchQuery($filters);
|
||||
$query->orderBy('sort', 'asc')->orderByDesc('id');
|
||||
|
||||
return $query->paginate($pageSize, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function searchList(array $filters = [])
|
||||
{
|
||||
return $this->buildSearchQuery($filters)
|
||||
->orderBy('sort', 'asc')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function buildSearchQuery(array $filters = [])
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
if (!empty($filters['merchant_id'])) {
|
||||
$query->where('merchant_id', (int)$filters['merchant_id']);
|
||||
}
|
||||
if (!empty($filters['merchant_app_id'])) {
|
||||
$query->where('merchant_app_id', (int)$filters['merchant_app_id']);
|
||||
}
|
||||
if (!empty($filters['method_id'])) {
|
||||
$query->where('method_id', (int)$filters['method_id']);
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) {
|
||||
$query->where('status', (int)$filters['status']);
|
||||
}
|
||||
if (!empty($filters['plugin_code'])) {
|
||||
$query->where('plugin_code', (string)$filters['plugin_code']);
|
||||
}
|
||||
if (!empty($filters['chan_code'])) {
|
||||
$query->where('chan_code', 'like', '%' . $filters['chan_code'] . '%');
|
||||
}
|
||||
if (!empty($filters['chan_name'])) {
|
||||
$query->where('chan_name', 'like', '%' . $filters['chan_name'] . '%');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ use app\models\PaymentOrder;
|
||||
*/
|
||||
class PaymentOrderRepository extends BaseRepository
|
||||
{
|
||||
public const STATUS_PENDING = 0;
|
||||
public const STATUS_SUCCESS = 1;
|
||||
public const STATUS_FAIL = 2;
|
||||
public const STATUS_CLOSED = 3;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(new PaymentOrder());
|
||||
@@ -58,6 +63,82 @@ class PaymentOrderRepository extends BaseRepository
|
||||
* 后台订单列表:支持筛选与模糊搜索
|
||||
*/
|
||||
public function searchPaginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->buildSearchQuery($filters);
|
||||
$query->orderByDesc('id');
|
||||
return $query->paginate($pageSize, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
public function searchList(array $filters = [], int $limit = 5000)
|
||||
{
|
||||
return $this->buildSearchQuery($filters)
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function aggregateByChannel(array $channelIds = [], array $filters = []): array
|
||||
{
|
||||
if (empty($channelIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = $this->model->newQuery()
|
||||
->selectRaw(
|
||||
'channel_id,
|
||||
COUNT(*) AS total_orders,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_orders,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS pending_orders,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS fail_orders,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS closed_orders,
|
||||
COALESCE(SUM(amount), 0) AS total_amount,
|
||||
COALESCE(SUM(CASE WHEN status = ? THEN amount ELSE 0 END), 0) AS success_amount,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() THEN 1 ELSE 0 END) AS today_orders,
|
||||
COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() THEN amount ELSE 0 END), 0) AS today_amount,
|
||||
SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN 1 ELSE 0 END) AS today_success_orders,
|
||||
COALESCE(SUM(CASE WHEN DATE(created_at) = CURDATE() AND status = ? THEN amount ELSE 0 END), 0) AS today_success_amount,
|
||||
MAX(created_at) AS last_order_at,
|
||||
MAX(CASE WHEN status = ? THEN pay_at ELSE NULL END) AS last_success_at',
|
||||
[
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_FAIL,
|
||||
self::STATUS_CLOSED,
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_SUCCESS,
|
||||
]
|
||||
)
|
||||
->whereIn('channel_id', $channelIds);
|
||||
|
||||
if (!empty($filters['merchant_id'])) {
|
||||
$query->where('merchant_id', (int)$filters['merchant_id']);
|
||||
}
|
||||
if (!empty($filters['merchant_app_id'])) {
|
||||
$query->where('merchant_app_id', (int)$filters['merchant_app_id']);
|
||||
}
|
||||
if (!empty($filters['method_id'])) {
|
||||
$query->where('method_id', (int)$filters['method_id']);
|
||||
}
|
||||
if (!empty($filters['created_from'])) {
|
||||
$query->where('created_at', '>=', $filters['created_from']);
|
||||
}
|
||||
if (!empty($filters['created_to'])) {
|
||||
$query->where('created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
|
||||
$rows = $query->groupBy('channel_id')->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$result[(int)$row->channel_id] = $row->toArray();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildSearchQuery(array $filters = [])
|
||||
{
|
||||
$query = $this->model->newQuery();
|
||||
|
||||
@@ -73,6 +154,28 @@ class PaymentOrderRepository extends BaseRepository
|
||||
if (!empty($filters['channel_id'])) {
|
||||
$query->where('channel_id', (int)$filters['channel_id']);
|
||||
}
|
||||
if (!empty($filters['route_policy_name'])) {
|
||||
$query->whereRaw(
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.policy.policy_name')) like ?",
|
||||
['%' . $filters['route_policy_name'] . '%']
|
||||
);
|
||||
}
|
||||
if (($filters['route_state'] ?? '') !== '' && $filters['route_state'] !== null) {
|
||||
$routeState = (string)$filters['route_state'];
|
||||
if ($routeState === 'error') {
|
||||
$query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NOT NULL");
|
||||
} elseif ($routeState === 'none') {
|
||||
$query->whereRaw("JSON_EXTRACT(extra, '$.route_error') IS NULL");
|
||||
$query->whereRaw(
|
||||
"(JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = '')"
|
||||
);
|
||||
} else {
|
||||
$query->whereRaw(
|
||||
"JSON_EXTRACT(extra, '$.route_error') IS NULL AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.routing.source')) = ?",
|
||||
[$routeState]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '' && $filters['status'] !== null) {
|
||||
$query->where('status', (int)$filters['status']);
|
||||
}
|
||||
@@ -89,7 +192,6 @@ class PaymentOrderRepository extends BaseRepository
|
||||
$query->where('created_at', '<=', $filters['created_to']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('id');
|
||||
return $query->paginate($pageSize, ['*'], 'page', $page);
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,230 @@
|
||||
<?php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 管理后台路由定义
|
||||
*
|
||||
* 接口前缀:/adminapi
|
||||
* 跨域中间件:Cors
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use app\http\admin\controller\AuthController;
|
||||
use app\common\middleware\Cors;
|
||||
use app\http\admin\controller\AdminController;
|
||||
use app\http\admin\controller\MenuController;
|
||||
use app\http\admin\controller\SystemController;
|
||||
use app\http\admin\controller\AuthController;
|
||||
use app\http\admin\controller\ChannelController;
|
||||
use app\http\admin\controller\PluginController;
|
||||
use app\http\admin\controller\MerchantController;
|
||||
use app\http\admin\controller\FinanceController;
|
||||
use app\http\admin\controller\MenuController;
|
||||
use app\http\admin\controller\MerchantAppController;
|
||||
use app\http\admin\controller\MerchantController;
|
||||
use app\http\admin\controller\OrderController;
|
||||
use app\http\admin\controller\PayMethodController;
|
||||
use app\http\admin\controller\PayPluginController;
|
||||
use app\http\admin\controller\OrderController;
|
||||
use app\common\middleware\Cors;
|
||||
use app\http\admin\controller\PluginController;
|
||||
use app\http\admin\controller\SystemController;
|
||||
use app\http\admin\middleware\AuthMiddleware;
|
||||
use Webman\Route;
|
||||
|
||||
Route::group('/adminapi', function () {
|
||||
// 认证相关(无需JWT验证)
|
||||
Route::get('/captcha', [AuthController::class, 'captcha'])->name('captcha')->setParams(['real_name' => '验证码']);
|
||||
Route::post('/login', [AuthController::class, 'login'])->name('login')->setParams(['real_name' => '登录']);
|
||||
Route::get('/captcha', [AuthController::class, 'captcha'])
|
||||
->name('captcha')
|
||||
->setParams(['real_name' => 'adminCaptcha']);
|
||||
Route::post('/login', [AuthController::class, 'login'])
|
||||
->name('login')
|
||||
->setParams(['real_name' => 'adminLogin']);
|
||||
|
||||
// 需要认证的路由组
|
||||
Route::group('', function () {
|
||||
// 用户相关(需要JWT验证)
|
||||
Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])->name('getUserInfo')->setParams(['real_name' => '获取管理员信息']);
|
||||
|
||||
// 菜单相关(需要JWT验证)
|
||||
Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])->name('getRouters')->setParams(['real_name' => '获取菜单']);
|
||||
|
||||
// 系统相关(需要JWT验证)
|
||||
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])->name('getDict')->setParams(['real_name' => '获取字典']);
|
||||
|
||||
// 系统配置相关(需要JWT验证)
|
||||
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])->name('getTabsConfig')->setParams(['real_name' => '获取系统配置tabs']);
|
||||
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])->name('getFormConfig')->setParams(['real_name' => '获取系统配置form']);
|
||||
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])->name('submitConfig')->setParams(['real_name' => '提交系统配置']);
|
||||
|
||||
// 通道管理相关(需要JWT验证)
|
||||
Route::get('/channel/list', [ChannelController::class, 'list'])->name('list')->setParams(['real_name' => '获取通道列表']);
|
||||
Route::get('/channel/detail', [ChannelController::class, 'detail'])->name('detail')->setParams(['real_name' => '获取通道详情']);
|
||||
Route::post('/channel/save', [ChannelController::class, 'save'])->name('save')->setParams(['real_name' => '保存通道']);
|
||||
|
||||
// 插件管理相关(需要JWT验证)
|
||||
Route::get('/channel/plugins', [PluginController::class, 'plugins'])->name('plugins')->setParams(['real_name' => '获取插件列表']);
|
||||
Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])->name('configSchema')->setParams(['real_name' => '获取插件配置schema']);
|
||||
Route::get('/channel/plugin/products', [PluginController::class, 'products'])->name('products')->setParams(['real_name' => '获取插件产品列表']);
|
||||
Route::get('/user/getUserInfo', [AdminController::class, 'getUserInfo'])
|
||||
->name('getUserInfo')
|
||||
->setParams(['real_name' => 'getUserInfo']);
|
||||
Route::get('/menu/getRouters', [MenuController::class, 'getRouters'])
|
||||
->name('getRouters')
|
||||
->setParams(['real_name' => 'getRouters']);
|
||||
|
||||
// 商户管理
|
||||
Route::get('/merchant/list', [MerchantController::class, 'list'])->name('merchantList')->setParams(['real_name' => '商户列表']);
|
||||
Route::get('/merchant/detail', [MerchantController::class, 'detail'])->name('merchantDetail')->setParams(['real_name' => '商户详情']);
|
||||
Route::post('/merchant/save', [MerchantController::class, 'save'])->name('merchantSave')->setParams(['real_name' => '保存商户']);
|
||||
Route::post('/merchant/toggle', [MerchantController::class, 'toggle'])->name('merchantToggle')->setParams(['real_name' => '启用禁用商户']);
|
||||
Route::get('/system/getDict[/{code}]', [SystemController::class, 'getDict'])
|
||||
->name('getDict')
|
||||
->setParams(['real_name' => 'getSystemDict']);
|
||||
Route::get('/system/base-config/tabs', [SystemController::class, 'getTabsConfig'])
|
||||
->name('getTabsConfig')
|
||||
->setParams(['real_name' => 'getSystemTabs']);
|
||||
Route::get('/system/base-config/form/{tabKey}', [SystemController::class, 'getFormConfig'])
|
||||
->name('getFormConfig')
|
||||
->setParams(['real_name' => 'getSystemForm']);
|
||||
Route::post('/system/base-config/submit/{tabKey}', [SystemController::class, 'submitConfig'])
|
||||
->name('submitConfig')
|
||||
->setParams(['real_name' => 'submitSystemConfig']);
|
||||
Route::get('/system/log/files', [SystemController::class, 'logFiles'])
|
||||
->name('systemLogFiles')
|
||||
->setParams(['real_name' => 'systemLogFiles']);
|
||||
Route::get('/system/log/summary', [SystemController::class, 'logSummary'])
|
||||
->name('systemLogSummary')
|
||||
->setParams(['real_name' => 'systemLogSummary']);
|
||||
Route::get('/system/log/content', [SystemController::class, 'logContent'])
|
||||
->name('systemLogContent')
|
||||
->setParams(['real_name' => 'systemLogContent']);
|
||||
Route::get('/system/notice/overview', [SystemController::class, 'noticeOverview'])
|
||||
->name('systemNoticeOverview')
|
||||
->setParams(['real_name' => 'systemNoticeOverview']);
|
||||
Route::post('/system/notice/test', [SystemController::class, 'noticeTest'])
|
||||
->name('systemNoticeTest')
|
||||
->setParams(['real_name' => 'systemNoticeTest']);
|
||||
|
||||
// 商户应用管理
|
||||
Route::get('/merchant-app/list', [MerchantAppController::class, 'list'])->name('merchantAppList')->setParams(['real_name' => '商户应用列表']);
|
||||
Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail'])->name('merchantAppDetail')->setParams(['real_name' => '商户应用详情']);
|
||||
Route::post('/merchant-app/save', [MerchantAppController::class, 'save'])->name('merchantAppSave')->setParams(['real_name' => '保存商户应用']);
|
||||
Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret'])->name('merchantAppResetSecret')->setParams(['real_name' => '重置应用密钥']);
|
||||
Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle'])->name('merchantAppToggle')->setParams(['real_name' => '启用禁用商户应用']);
|
||||
Route::get('/finance/reconciliation', [FinanceController::class, 'reconciliation'])
|
||||
->name('financeReconciliation')
|
||||
->setParams(['real_name' => 'financeReconciliation']);
|
||||
Route::get('/finance/settlement', [FinanceController::class, 'settlement'])
|
||||
->name('financeSettlement')
|
||||
->setParams(['real_name' => 'financeSettlement']);
|
||||
Route::get('/finance/batch-settlement', [FinanceController::class, 'batchSettlement'])
|
||||
->name('financeBatchSettlement')
|
||||
->setParams(['real_name' => 'financeBatchSettlement']);
|
||||
Route::get('/finance/settlement-record', [FinanceController::class, 'settlementRecord'])
|
||||
->name('financeSettlementRecord')
|
||||
->setParams(['real_name' => 'financeSettlementRecord']);
|
||||
Route::get('/finance/split', [FinanceController::class, 'split'])
|
||||
->name('financeSplit')
|
||||
->setParams(['real_name' => 'financeSplit']);
|
||||
Route::get('/finance/fee', [FinanceController::class, 'fee'])
|
||||
->name('financeFee')
|
||||
->setParams(['real_name' => 'financeFee']);
|
||||
Route::get('/finance/invoice', [FinanceController::class, 'invoice'])
|
||||
->name('financeInvoice')
|
||||
->setParams(['real_name' => 'financeInvoice']);
|
||||
|
||||
// 支付方式管理
|
||||
Route::get('/pay-method/list', [PayMethodController::class, 'list'])->name('payMethodList')->setParams(['real_name' => '支付方式列表']);
|
||||
Route::post('/pay-method/save', [PayMethodController::class, 'save'])->name('payMethodSave')->setParams(['real_name' => '保存支付方式']);
|
||||
Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle'])->name('payMethodToggle')->setParams(['real_name' => '启用禁用支付方式']);
|
||||
Route::get('/channel/list', [ChannelController::class, 'list'])
|
||||
->name('channelList')
|
||||
->setParams(['real_name' => 'channelList']);
|
||||
Route::get('/channel/detail', [ChannelController::class, 'detail'])
|
||||
->name('channelDetail')
|
||||
->setParams(['real_name' => 'channelDetail']);
|
||||
Route::get('/channel/monitor', [ChannelController::class, 'monitor'])
|
||||
->name('channelMonitor')
|
||||
->setParams(['real_name' => 'channelMonitor']);
|
||||
Route::get('/channel/polling', [ChannelController::class, 'polling'])
|
||||
->name('channelPolling')
|
||||
->setParams(['real_name' => 'channelPolling']);
|
||||
Route::get('/channel/policy/list', [ChannelController::class, 'policyList'])
|
||||
->name('channelPolicyList')
|
||||
->setParams(['real_name' => 'channelPolicyList']);
|
||||
Route::post('/channel/save', [ChannelController::class, 'save'])
|
||||
->name('channelSave')
|
||||
->setParams(['real_name' => 'channelSave']);
|
||||
Route::post('/channel/toggle', [ChannelController::class, 'toggle'])
|
||||
->name('channelToggle')
|
||||
->setParams(['real_name' => 'channelToggle']);
|
||||
Route::post('/channel/policy/save', [ChannelController::class, 'policySave'])
|
||||
->name('channelPolicySave')
|
||||
->setParams(['real_name' => 'channelPolicySave']);
|
||||
Route::post('/channel/policy/preview', [ChannelController::class, 'policyPreview'])
|
||||
->name('channelPolicyPreview')
|
||||
->setParams(['real_name' => 'channelPolicyPreview']);
|
||||
Route::post('/channel/policy/delete', [ChannelController::class, 'policyDelete'])
|
||||
->name('channelPolicyDelete')
|
||||
->setParams(['real_name' => 'channelPolicyDelete']);
|
||||
|
||||
// 插件注册表管理
|
||||
Route::get('/pay-plugin/list', [PayPluginController::class, 'list'])->name('payPluginList')->setParams(['real_name' => '支付插件注册表列表']);
|
||||
Route::post('/pay-plugin/save', [PayPluginController::class, 'save'])->name('payPluginSave')->setParams(['real_name' => '保存支付插件注册表']);
|
||||
Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle'])->name('payPluginToggle')->setParams(['real_name' => '启用禁用支付插件']);
|
||||
Route::get('/channel/plugins', [PluginController::class, 'plugins'])
|
||||
->name('channelPlugins')
|
||||
->setParams(['real_name' => 'channelPlugins']);
|
||||
Route::get('/channel/plugin/config-schema', [PluginController::class, 'configSchema'])
|
||||
->name('channelPluginConfigSchema')
|
||||
->setParams(['real_name' => 'channelPluginConfigSchema']);
|
||||
Route::get('/channel/plugin/products', [PluginController::class, 'products'])
|
||||
->name('channelPluginProducts')
|
||||
->setParams(['real_name' => 'channelPluginProducts']);
|
||||
|
||||
// 订单管理
|
||||
Route::get('/order/list', [OrderController::class, 'list'])->name('orderList')->setParams(['real_name' => '订单列表']);
|
||||
Route::get('/order/detail', [OrderController::class, 'detail'])->name('orderDetail')->setParams(['real_name' => '订单详情']);
|
||||
Route::post('/order/refund', [OrderController::class, 'refund'])->name('orderRefund')->setParams(['real_name' => '订单退款']);
|
||||
Route::get('/merchant/list', [MerchantController::class, 'list'])
|
||||
->name('merchantList')
|
||||
->setParams(['real_name' => 'merchantList']);
|
||||
Route::get('/merchant/detail', [MerchantController::class, 'detail'])
|
||||
->name('merchantDetail')
|
||||
->setParams(['real_name' => 'merchantDetail']);
|
||||
Route::get('/merchant/profile/detail', [MerchantController::class, 'profileDetail'])
|
||||
->name('merchantProfileDetail')
|
||||
->setParams(['real_name' => 'merchantProfileDetail']);
|
||||
Route::get('/merchant/statistics', [MerchantController::class, 'statistics'])
|
||||
->name('merchantStatistics')
|
||||
->setParams(['real_name' => 'merchantStatistics']);
|
||||
Route::get('/merchant/funds', [MerchantController::class, 'funds'])
|
||||
->name('merchantFunds')
|
||||
->setParams(['real_name' => 'merchantFunds']);
|
||||
Route::get('/merchant/audit', [MerchantController::class, 'audit'])
|
||||
->name('merchantAudit')
|
||||
->setParams(['real_name' => 'merchantAudit']);
|
||||
Route::get('/merchant/group/list', [MerchantController::class, 'groupList'])
|
||||
->name('merchantGroupList')
|
||||
->setParams(['real_name' => 'merchantGroupList']);
|
||||
Route::post('/merchant/group/save', [MerchantController::class, 'groupSave'])
|
||||
->name('merchantGroupSave')
|
||||
->setParams(['real_name' => 'merchantGroupSave']);
|
||||
Route::post('/merchant/group/delete', [MerchantController::class, 'groupDelete'])
|
||||
->name('merchantGroupDelete')
|
||||
->setParams(['real_name' => 'merchantGroupDelete']);
|
||||
Route::get('/merchant/package/list', [MerchantController::class, 'packageList'])
|
||||
->name('merchantPackageList')
|
||||
->setParams(['real_name' => 'merchantPackageList']);
|
||||
Route::post('/merchant/package/save', [MerchantController::class, 'packageSave'])
|
||||
->name('merchantPackageSave')
|
||||
->setParams(['real_name' => 'merchantPackageSave']);
|
||||
Route::post('/merchant/package/delete', [MerchantController::class, 'packageDelete'])
|
||||
->name('merchantPackageDelete')
|
||||
->setParams(['real_name' => 'merchantPackageDelete']);
|
||||
Route::post('/merchant/save', [MerchantController::class, 'save'])
|
||||
->name('merchantSave')
|
||||
->setParams(['real_name' => 'merchantSave']);
|
||||
Route::post('/merchant/profile/save', [MerchantController::class, 'profileSave'])
|
||||
->name('merchantProfileSave')
|
||||
->setParams(['real_name' => 'merchantProfileSave']);
|
||||
Route::post('/merchant/audit-action', [MerchantController::class, 'auditAction'])
|
||||
->name('merchantAuditAction')
|
||||
->setParams(['real_name' => 'merchantAuditAction']);
|
||||
Route::post('/merchant/toggle', [MerchantController::class, 'toggle'])
|
||||
->name('merchantToggle')
|
||||
->setParams(['real_name' => 'merchantToggle']);
|
||||
|
||||
Route::get('/merchant-app/list', [MerchantAppController::class, 'list'])
|
||||
->name('merchantAppList')
|
||||
->setParams(['real_name' => 'merchantAppList']);
|
||||
Route::get('/merchant-app/detail', [MerchantAppController::class, 'detail'])
|
||||
->name('merchantAppDetail')
|
||||
->setParams(['real_name' => 'merchantAppDetail']);
|
||||
Route::get('/merchant-app/config/detail', [MerchantAppController::class, 'configDetail'])
|
||||
->name('merchantAppConfigDetail')
|
||||
->setParams(['real_name' => 'merchantAppConfigDetail']);
|
||||
Route::post('/merchant-app/save', [MerchantAppController::class, 'save'])
|
||||
->name('merchantAppSave')
|
||||
->setParams(['real_name' => 'merchantAppSave']);
|
||||
Route::post('/merchant-app/config/save', [MerchantAppController::class, 'configSave'])
|
||||
->name('merchantAppConfigSave')
|
||||
->setParams(['real_name' => 'merchantAppConfigSave']);
|
||||
Route::post('/merchant-app/reset-secret', [MerchantAppController::class, 'resetSecret'])
|
||||
->name('merchantAppResetSecret')
|
||||
->setParams(['real_name' => 'merchantAppResetSecret']);
|
||||
Route::post('/merchant-app/toggle', [MerchantAppController::class, 'toggle'])
|
||||
->name('merchantAppToggle')
|
||||
->setParams(['real_name' => 'merchantAppToggle']);
|
||||
|
||||
Route::get('/pay-method/list', [PayMethodController::class, 'list'])
|
||||
->name('payMethodList')
|
||||
->setParams(['real_name' => 'payMethodList']);
|
||||
Route::post('/pay-method/save', [PayMethodController::class, 'save'])
|
||||
->name('payMethodSave')
|
||||
->setParams(['real_name' => 'payMethodSave']);
|
||||
Route::post('/pay-method/toggle', [PayMethodController::class, 'toggle'])
|
||||
->name('payMethodToggle')
|
||||
->setParams(['real_name' => 'payMethodToggle']);
|
||||
|
||||
Route::get('/pay-plugin/list', [PayPluginController::class, 'list'])
|
||||
->name('payPluginList')
|
||||
->setParams(['real_name' => 'payPluginList']);
|
||||
Route::post('/pay-plugin/save', [PayPluginController::class, 'save'])
|
||||
->name('payPluginSave')
|
||||
->setParams(['real_name' => 'payPluginSave']);
|
||||
Route::post('/pay-plugin/toggle', [PayPluginController::class, 'toggle'])
|
||||
->name('payPluginToggle')
|
||||
->setParams(['real_name' => 'payPluginToggle']);
|
||||
|
||||
Route::get('/order/list', [OrderController::class, 'list'])
|
||||
->name('orderList')
|
||||
->setParams(['real_name' => 'orderList']);
|
||||
Route::get('/order/export', [OrderController::class, 'export'])
|
||||
->name('orderExport')
|
||||
->setParams(['real_name' => 'orderExport']);
|
||||
Route::get('/order/detail', [OrderController::class, 'detail'])
|
||||
->name('orderDetail')
|
||||
->setParams(['real_name' => 'orderDetail']);
|
||||
Route::post('/order/refund', [OrderController::class, 'refund'])
|
||||
->name('orderRefund')
|
||||
->setParams(['real_name' => 'orderRefund']);
|
||||
})->middleware([AuthMiddleware::class]);
|
||||
})->middleware([Cors::class]);
|
||||
})->middleware([Cors::class]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API 路由定义(易支付接口标准)
|
||||
* API 路由定义(易支付兼容 + 通用回调)
|
||||
*/
|
||||
|
||||
use Webman\Route;
|
||||
use app\http\api\controller\EpayController;
|
||||
use app\http\api\controller\PayController;
|
||||
|
||||
Route::group('', function () {
|
||||
// 页面跳转支付
|
||||
@@ -16,4 +17,7 @@ Route::group('', function () {
|
||||
|
||||
// API接口
|
||||
Route::get('/api.php', [EpayController::class, 'api']);
|
||||
|
||||
// 第三方支付异步回调(按插件区分)
|
||||
Route::any('/notify/{pluginCode}', [PayController::class, 'notify']);
|
||||
});
|
||||
|
||||
103
app/services/ChannelRoutePolicyService.php
Normal file
103
app/services/ChannelRoutePolicyService.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
|
||||
class ChannelRoutePolicyService extends BaseService
|
||||
{
|
||||
private const CONFIG_KEY = 'channel_route_policies';
|
||||
|
||||
public function __construct(
|
||||
protected SystemConfigService $configService
|
||||
) {
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$raw = $this->configService->getValue(self::CONFIG_KEY, '[]');
|
||||
|
||||
if (is_array($raw)) {
|
||||
$policies = $raw;
|
||||
} else {
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
$policies = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
usort($policies, function (array $left, array $right) {
|
||||
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
|
||||
});
|
||||
|
||||
return $policies;
|
||||
}
|
||||
|
||||
public function save(array $policyData): array
|
||||
{
|
||||
$policies = $this->list();
|
||||
$id = trim((string)($policyData['id'] ?? ''));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$stored = [
|
||||
'id' => $id !== '' ? $id : $this->generateId(),
|
||||
'policy_name' => trim((string)($policyData['policy_name'] ?? '')),
|
||||
'merchant_id' => (int)($policyData['merchant_id'] ?? 0),
|
||||
'merchant_app_id' => (int)($policyData['merchant_app_id'] ?? 0),
|
||||
'method_code' => trim((string)($policyData['method_code'] ?? '')),
|
||||
'plugin_code' => trim((string)($policyData['plugin_code'] ?? '')),
|
||||
'route_mode' => trim((string)($policyData['route_mode'] ?? 'priority')),
|
||||
'status' => (int)($policyData['status'] ?? 1),
|
||||
'circuit_breaker_threshold' => max(0, min(100, (int)($policyData['circuit_breaker_threshold'] ?? 50))),
|
||||
'failover_cooldown' => max(0, (int)($policyData['failover_cooldown'] ?? 10)),
|
||||
'remark' => trim((string)($policyData['remark'] ?? '')),
|
||||
'items' => array_values($policyData['items'] ?? []),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$found = false;
|
||||
foreach ($policies as &$policy) {
|
||||
if (($policy['id'] ?? '') !== $stored['id']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stored['created_at'] = $policy['created_at'] ?? $now;
|
||||
$policy = $stored;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
unset($policy);
|
||||
|
||||
if (!$found) {
|
||||
$stored['created_at'] = $now;
|
||||
$policies[] = $stored;
|
||||
}
|
||||
|
||||
$this->configService->setValue(self::CONFIG_KEY, $policies);
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
public function delete(string $id): bool
|
||||
{
|
||||
$id = trim($id);
|
||||
if ($id === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policies = $this->list();
|
||||
$filtered = array_values(array_filter($policies, function (array $policy) use ($id) {
|
||||
return ($policy['id'] ?? '') !== $id;
|
||||
}));
|
||||
|
||||
if (count($filtered) === count($policies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->configService->setValue(self::CONFIG_KEY, $filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateId(): string
|
||||
{
|
||||
return 'rp_' . date('YmdHis') . mt_rand(1000, 9999);
|
||||
}
|
||||
}
|
||||
@@ -6,37 +6,608 @@ use app\common\base\BaseService;
|
||||
use app\exceptions\NotFoundException;
|
||||
use app\models\PaymentChannel;
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
use app\repositories\PaymentMethodRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
|
||||
/**
|
||||
* 通道路由服务
|
||||
*
|
||||
* 负责根据商户、应用、支付方式选择合适的通道
|
||||
* 负责根据商户、应用、支付方式和已保存策略选择可用通道,
|
||||
* 同时也为后台提供策略草稿预览能力,保证预览和真实下单尽量一致。
|
||||
*/
|
||||
class ChannelRouterService extends BaseService
|
||||
{
|
||||
private const HEALTH_LOOKBACK_DAYS = 7;
|
||||
|
||||
public function __construct(
|
||||
protected PaymentChannelRepository $channelRepository
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected ChannelRoutePolicyService $routePolicyService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择通道
|
||||
*
|
||||
* @param int $merchantId 商户ID
|
||||
* @param int $merchantAppId 商户应用ID
|
||||
* @param int $methodId 支付方式ID
|
||||
* @return PaymentChannel
|
||||
* @throws NotFoundException
|
||||
* 向后兼容:只返回选中的通道模型。
|
||||
*/
|
||||
public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId): PaymentChannel
|
||||
public function chooseChannel(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): PaymentChannel
|
||||
{
|
||||
$channel = $this->channelRepository->findAvailableChannel($merchantId, $merchantAppId, $methodId);
|
||||
$decision = $this->chooseChannelWithDecision($merchantId, $merchantAppId, $methodId, $amount);
|
||||
return $decision['channel'];
|
||||
}
|
||||
|
||||
if (!$channel) {
|
||||
throw new NotFoundException("未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}");
|
||||
/**
|
||||
* 返回完整路由决策信息,便于下单链路记录调度痕迹。
|
||||
*
|
||||
* @return array{
|
||||
* channel:PaymentChannel,
|
||||
* source:string,
|
||||
* route_mode:string,
|
||||
* policy:?array,
|
||||
* candidates:array<int, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function chooseChannelWithDecision(int $merchantId, int $merchantAppId, int $methodId, float $amount = 0): array
|
||||
{
|
||||
$routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount);
|
||||
$method = $routingContext['method'];
|
||||
$contexts = $routingContext['contexts'];
|
||||
|
||||
$decision = $this->chooseByPolicy(
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
(string)$method->method_code,
|
||||
$contexts
|
||||
);
|
||||
|
||||
if ($decision !== null) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
return $channel;
|
||||
$decision = $this->chooseFallback($contexts);
|
||||
if ($decision !== null) {
|
||||
return $decision;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
$this->buildNoChannelMessage($merchantId, $merchantAppId, (string)$method->method_name, $contexts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览一个尚未保存的策略草稿在当前真实通道环境下会如何命中。
|
||||
*/
|
||||
public function previewPolicyDraft(
|
||||
int $merchantId,
|
||||
int $merchantAppId,
|
||||
int $methodId,
|
||||
array $policy,
|
||||
float $amount = 0
|
||||
): array {
|
||||
$routingContext = $this->loadRoutingContexts($merchantId, $merchantAppId, $methodId, $amount);
|
||||
$method = $routingContext['method'];
|
||||
$contexts = $routingContext['contexts'];
|
||||
$previewPolicy = $this->normalizePreviewPolicy($policy, $merchantId, $merchantAppId, (string)$method->method_code);
|
||||
$evaluation = $this->evaluatePolicy($previewPolicy, $contexts);
|
||||
|
||||
$selectedChannel = null;
|
||||
if ($evaluation['selected_candidate'] !== null) {
|
||||
$selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null;
|
||||
if ($selectedContext !== null) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $selectedContext['channel'];
|
||||
$selectedChannel = [
|
||||
'id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'matched' => $selectedChannel !== null,
|
||||
'source' => 'preview',
|
||||
'route_mode' => (string)($previewPolicy['route_mode'] ?? 'priority'),
|
||||
'policy' => $this->buildPolicyMeta($previewPolicy),
|
||||
'selected_channel' => $selectedChannel,
|
||||
'candidates' => $evaluation['candidates'],
|
||||
'summary' => [
|
||||
'candidate_count' => count($evaluation['candidates']),
|
||||
'available_count' => count($evaluation['available_candidates']),
|
||||
'blocked_count' => count($evaluation['candidates']) - count($evaluation['available_candidates']),
|
||||
],
|
||||
'message' => $selectedChannel !== null ? '本次模拟已命中策略通道' : '当前策略下没有可用通道',
|
||||
];
|
||||
}
|
||||
|
||||
private function chooseByPolicy(int $merchantId, int $merchantAppId, string $methodCode, array $contexts): ?array
|
||||
{
|
||||
return $this->chooseByPolicies(
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodCode,
|
||||
$contexts,
|
||||
$this->routePolicyService->list()
|
||||
);
|
||||
}
|
||||
|
||||
private function chooseFallback(array $contexts): ?array
|
||||
{
|
||||
$candidates = [];
|
||||
foreach ($contexts as $context) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
'available' => $context['available'],
|
||||
'reasons' => $context['reasons'],
|
||||
'priority' => (int)$channel->sort,
|
||||
'weight' => 100,
|
||||
'role' => 'normal',
|
||||
'health_score' => $context['health_score'],
|
||||
'success_rate' => $context['success_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
$availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available']));
|
||||
if ($availableCandidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($availableCandidates, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($right['health_score'] ?? 0) === ($left['health_score'] ?? 0)) {
|
||||
return ($right['success_rate'] ?? 0) <=> ($left['success_rate'] ?? 0);
|
||||
}
|
||||
return ($right['health_score'] ?? 0) <=> ($left['health_score'] ?? 0);
|
||||
}
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
$selectedCandidate = $availableCandidates[0];
|
||||
$selectedContext = $contexts[(int)$selectedCandidate['channel_id']] ?? null;
|
||||
if (!$selectedContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel' => $selectedContext['channel'],
|
||||
'source' => 'fallback',
|
||||
'route_mode' => 'sort',
|
||||
'policy' => null,
|
||||
'candidates' => $candidates,
|
||||
];
|
||||
}
|
||||
|
||||
private function loadRoutingContexts(int $merchantId, int $merchantAppId, int $methodId, float $amount): array
|
||||
{
|
||||
$method = $this->methodRepository->find($methodId);
|
||||
if (!$method) {
|
||||
throw new NotFoundException("未找到支付方式:{$methodId}");
|
||||
}
|
||||
|
||||
$channels = $this->channelRepository->searchList([
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
]);
|
||||
|
||||
if ($channels->isEmpty()) {
|
||||
throw new NotFoundException(
|
||||
"未找到可用的支付通道:商户ID={$merchantId}, 应用ID={$merchantAppId}, 支付方式ID={$methodId}"
|
||||
);
|
||||
}
|
||||
|
||||
$todayRange = $this->getDateRange(1);
|
||||
$recentRange = $this->getDateRange(self::HEALTH_LOOKBACK_DAYS);
|
||||
$channelIds = [];
|
||||
foreach ($channels as $channel) {
|
||||
$channelIds[] = (int)$channel->id;
|
||||
}
|
||||
|
||||
$todayStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
'created_from' => $todayRange['created_from'],
|
||||
'created_to' => $todayRange['created_to'],
|
||||
]);
|
||||
$recentStatsMap = $this->orderRepository->aggregateByChannel($channelIds, [
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_id' => $methodId,
|
||||
'created_from' => $recentRange['created_from'],
|
||||
'created_to' => $recentRange['created_to'],
|
||||
]);
|
||||
|
||||
$contexts = [];
|
||||
foreach ($channels as $channel) {
|
||||
$contexts[(int)$channel->id] = $this->buildChannelContext(
|
||||
$channel,
|
||||
$todayStatsMap[(int)$channel->id] ?? [],
|
||||
$recentStatsMap[(int)$channel->id] ?? [],
|
||||
$amount
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'method' => $method,
|
||||
'contexts' => $contexts,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildChannelContext(PaymentChannel $channel, array $todayStats, array $recentStats, float $amount): array
|
||||
{
|
||||
$reasons = [];
|
||||
$status = (int)$channel->status;
|
||||
|
||||
$todayOrders = (int)($todayStats['total_orders'] ?? 0);
|
||||
$todaySuccessAmount = round((float)($todayStats['success_amount'] ?? 0), 2);
|
||||
$recentTotalOrders = (int)($recentStats['total_orders'] ?? 0);
|
||||
$recentSuccessOrders = (int)($recentStats['success_orders'] ?? 0);
|
||||
$recentPendingOrders = (int)($recentStats['pending_orders'] ?? 0);
|
||||
$recentFailOrders = (int)($recentStats['fail_orders'] ?? 0);
|
||||
|
||||
$dailyLimit = (float)$channel->daily_limit;
|
||||
$dailyCount = (int)$channel->daily_cnt;
|
||||
$minAmount = $channel->min_amount === null ? null : (float)$channel->min_amount;
|
||||
$maxAmount = $channel->max_amount === null ? null : (float)$channel->max_amount;
|
||||
|
||||
if ($status !== 1) {
|
||||
$reasons[] = '通道已禁用';
|
||||
}
|
||||
if ($amount > 0 && $minAmount !== null && $amount < $minAmount) {
|
||||
$reasons[] = '低于最小支付金额';
|
||||
}
|
||||
if ($amount > 0 && $maxAmount !== null && $maxAmount > 0 && $amount > $maxAmount) {
|
||||
$reasons[] = '超过最大支付金额';
|
||||
}
|
||||
if ($dailyLimit > 0 && $todaySuccessAmount + max(0, $amount) > $dailyLimit) {
|
||||
$reasons[] = '超出单日限额';
|
||||
}
|
||||
if ($dailyCount > 0 && $todayOrders + 1 > $dailyCount) {
|
||||
$reasons[] = '超出单日笔数限制';
|
||||
}
|
||||
|
||||
$successRate = $recentTotalOrders > 0 ? round($recentSuccessOrders / $recentTotalOrders * 100, 2) : 0;
|
||||
$dailyLimitUsageRate = $dailyLimit > 0 ? round(min(100, ($todaySuccessAmount / $dailyLimit) * 100), 2) : null;
|
||||
$healthScore = $this->calculateHealthScore(
|
||||
$status,
|
||||
$recentTotalOrders,
|
||||
$recentSuccessOrders,
|
||||
$recentPendingOrders,
|
||||
$recentFailOrders,
|
||||
$dailyLimitUsageRate
|
||||
);
|
||||
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'available' => $reasons === [],
|
||||
'reasons' => $reasons,
|
||||
'success_rate' => $successRate,
|
||||
'health_score' => $healthScore,
|
||||
'today_orders' => $todayOrders,
|
||||
'today_success_amount' => $todaySuccessAmount,
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateHealthScore(
|
||||
int $status,
|
||||
int $totalOrders,
|
||||
int $successOrders,
|
||||
int $pendingOrders,
|
||||
int $failOrders,
|
||||
?float $todayLimitUsageRate
|
||||
): int {
|
||||
if ($status !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($totalOrders === 0) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
$successRate = $totalOrders > 0 ? ($successOrders / $totalOrders * 100) : 0;
|
||||
$healthScore = 90;
|
||||
|
||||
if ($successRate < 95) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
if ($successRate < 80) {
|
||||
$healthScore -= 15;
|
||||
}
|
||||
if ($successRate < 60) {
|
||||
$healthScore -= 20;
|
||||
}
|
||||
if ($failOrders > 0) {
|
||||
$healthScore -= min(15, $failOrders * 3);
|
||||
}
|
||||
if ($pendingOrders > max(3, (int)floor($successOrders / 2))) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
if ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 90) {
|
||||
$healthScore -= 20;
|
||||
} elseif ($todayLimitUsageRate !== null && $todayLimitUsageRate >= 75) {
|
||||
$healthScore -= 10;
|
||||
}
|
||||
|
||||
return max(0, min(100, $healthScore));
|
||||
}
|
||||
|
||||
private function chooseByPolicies(
|
||||
int $merchantId,
|
||||
int $merchantAppId,
|
||||
string $methodCode,
|
||||
array $contexts,
|
||||
array $policies
|
||||
): ?array {
|
||||
$matchedPolicies = array_values(array_filter($policies, function (array $policy) use (
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodCode
|
||||
) {
|
||||
if ((int)($policy['status'] ?? 0) !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (($policy['method_code'] ?? '') !== $methodCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policyMerchantId = (int)($policy['merchant_id'] ?? 0);
|
||||
if ($policyMerchantId > 0 && $policyMerchantId !== $merchantId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$policyAppId = (int)($policy['merchant_app_id'] ?? 0);
|
||||
if ($policyAppId > 0 && $policyAppId !== $merchantAppId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_array($policy['items'] ?? null) && $policy['items'] !== [];
|
||||
}));
|
||||
|
||||
if ($matchedPolicies === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($matchedPolicies, function (array $left, array $right) {
|
||||
$leftScore = $this->calculatePolicySpecificity($left);
|
||||
$rightScore = $this->calculatePolicySpecificity($right);
|
||||
if ($leftScore === $rightScore) {
|
||||
return strcmp((string)($right['updated_at'] ?? ''), (string)($left['updated_at'] ?? ''));
|
||||
}
|
||||
return $rightScore <=> $leftScore;
|
||||
});
|
||||
|
||||
foreach ($matchedPolicies as $policy) {
|
||||
$evaluation = $this->evaluatePolicy($policy, $contexts);
|
||||
if ($evaluation['selected_candidate'] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$selectedContext = $contexts[(int)$evaluation['selected_candidate']['channel_id']] ?? null;
|
||||
if (!$selectedContext) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
'channel' => $selectedContext['channel'],
|
||||
'source' => 'policy',
|
||||
'route_mode' => (string)($policy['route_mode'] ?? 'priority'),
|
||||
'policy' => $this->buildPolicyMeta($policy),
|
||||
'candidates' => $evaluation['candidates'],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizePolicyItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($items as $index => $item) {
|
||||
$normalized[] = [
|
||||
'channel_id' => (int)($item['channel_id'] ?? 0),
|
||||
'role' => (string)($item['role'] ?? ($index === 0 ? 'primary' : 'backup')),
|
||||
'weight' => max(0, (int)($item['weight'] ?? 100)),
|
||||
'priority' => max(1, (int)($item['priority'] ?? ($index + 1))),
|
||||
];
|
||||
}
|
||||
|
||||
usort($normalized, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($left['role'] ?? '') === ($right['role'] ?? '')) {
|
||||
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
|
||||
}
|
||||
|
||||
return ($left['role'] ?? '') === 'primary' ? -1 : 1;
|
||||
}
|
||||
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function evaluatePolicy(array $policy, array $contexts): array
|
||||
{
|
||||
$items = $this->normalizePolicyItems($policy['items'] ?? []);
|
||||
$candidates = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$channelId = (int)($item['channel_id'] ?? 0);
|
||||
$context = $contexts[$channelId] ?? null;
|
||||
if (!$context) {
|
||||
$candidates[] = [
|
||||
'channel_id' => $channelId,
|
||||
'chan_code' => '',
|
||||
'chan_name' => '',
|
||||
'available' => false,
|
||||
'reasons' => ['通道不存在或不属于当前应用'],
|
||||
'priority' => (int)($item['priority'] ?? 1),
|
||||
'weight' => (int)($item['weight'] ?? 100),
|
||||
'role' => (string)($item['role'] ?? 'backup'),
|
||||
'health_score' => 0,
|
||||
'success_rate' => 0,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$pluginCode = trim((string)($policy['plugin_code'] ?? ''));
|
||||
$policyReasons = [];
|
||||
if ($pluginCode !== '' && (string)$channel->plugin_code !== $pluginCode) {
|
||||
$policyReasons[] = '插件与策略限定不匹配';
|
||||
}
|
||||
|
||||
$available = $context['available'] && $policyReasons === [];
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'chan_code' => (string)$channel->chan_code,
|
||||
'chan_name' => (string)$channel->chan_name,
|
||||
'available' => $available,
|
||||
'reasons' => $available ? [] : array_values(array_unique(array_merge($context['reasons'], $policyReasons))),
|
||||
'priority' => (int)($item['priority'] ?? 1),
|
||||
'weight' => (int)($item['weight'] ?? 100),
|
||||
'role' => (string)($item['role'] ?? 'backup'),
|
||||
'health_score' => $context['health_score'],
|
||||
'success_rate' => $context['success_rate'],
|
||||
];
|
||||
}
|
||||
|
||||
$availableCandidates = array_values(array_filter($candidates, fn(array $item) => (bool)$item['available']));
|
||||
$selectedCandidate = $availableCandidates === []
|
||||
? null
|
||||
: $this->pickCandidateByMode($availableCandidates, (string)($policy['route_mode'] ?? 'priority'));
|
||||
|
||||
return [
|
||||
'candidates' => $candidates,
|
||||
'available_candidates' => $availableCandidates,
|
||||
'selected_candidate' => $selectedCandidate,
|
||||
];
|
||||
}
|
||||
|
||||
private function pickCandidateByMode(array $candidates, string $routeMode): array
|
||||
{
|
||||
usort($candidates, function (array $left, array $right) {
|
||||
if (($left['priority'] ?? 0) === ($right['priority'] ?? 0)) {
|
||||
if (($left['role'] ?? '') === ($right['role'] ?? '')) {
|
||||
return ($right['weight'] ?? 0) <=> ($left['weight'] ?? 0);
|
||||
}
|
||||
|
||||
return ($left['role'] ?? '') === 'primary' ? -1 : 1;
|
||||
}
|
||||
|
||||
return ($left['priority'] ?? 0) <=> ($right['priority'] ?? 0);
|
||||
});
|
||||
|
||||
if ($routeMode !== 'weight') {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$totalWeight = 0;
|
||||
foreach ($candidates as $candidate) {
|
||||
$totalWeight += max(0, (int)($candidate['weight'] ?? 0));
|
||||
}
|
||||
|
||||
if ($totalWeight <= 0) {
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
$cursor = mt_rand(1, $totalWeight);
|
||||
foreach ($candidates as $candidate) {
|
||||
$cursor -= max(0, (int)($candidate['weight'] ?? 0));
|
||||
if ($cursor <= 0) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function calculatePolicySpecificity(array $policy): int
|
||||
{
|
||||
$score = 0;
|
||||
if ((int)($policy['merchant_id'] ?? 0) > 0) {
|
||||
$score += 10;
|
||||
}
|
||||
if ((int)($policy['merchant_app_id'] ?? 0) > 0) {
|
||||
$score += 20;
|
||||
}
|
||||
if (trim((string)($policy['plugin_code'] ?? '')) !== '') {
|
||||
$score += 5;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
private function buildPolicyMeta(array $policy): array
|
||||
{
|
||||
return [
|
||||
'id' => (string)($policy['id'] ?? ''),
|
||||
'policy_name' => (string)($policy['policy_name'] ?? ''),
|
||||
'plugin_code' => (string)($policy['plugin_code'] ?? ''),
|
||||
'circuit_breaker_threshold' => (int)($policy['circuit_breaker_threshold'] ?? 0),
|
||||
'failover_cooldown' => (int)($policy['failover_cooldown'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildNoChannelMessage(int $merchantId, int $merchantAppId, string $methodName, array $contexts): string
|
||||
{
|
||||
$messages = [];
|
||||
foreach ($contexts as $context) {
|
||||
/** @var PaymentChannel $channel */
|
||||
$channel = $context['channel'];
|
||||
$reasonText = $context['reasons'] === [] ? '无可用原因记录' : implode('、', $context['reasons']);
|
||||
$messages[] = sprintf('%s(%s):%s', (string)$channel->chan_name, (string)$channel->chan_code, $reasonText);
|
||||
}
|
||||
|
||||
usort($messages, fn(string $left, string $right) => strcmp($left, $right));
|
||||
$messages = array_slice($messages, 0, 3);
|
||||
|
||||
$suffix = $messages === [] ? '' : ',原因:' . implode(';', $messages);
|
||||
|
||||
return sprintf(
|
||||
'未找到可用的支付通道:商户ID=%d,应用ID=%d,支付方式=%s%s',
|
||||
$merchantId,
|
||||
$merchantAppId,
|
||||
$methodName,
|
||||
$suffix
|
||||
);
|
||||
}
|
||||
|
||||
private function getDateRange(int $days): array
|
||||
{
|
||||
$days = max(1, $days);
|
||||
return [
|
||||
'created_from' => date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days')),
|
||||
'created_to' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePreviewPolicy(array $policy, int $merchantId, int $merchantAppId, string $methodCode): array
|
||||
{
|
||||
$routeMode = trim((string)($policy['route_mode'] ?? 'priority'));
|
||||
if (!in_array($routeMode, ['priority', 'weight', 'failover'], true)) {
|
||||
$routeMode = 'priority';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => trim((string)($policy['id'] ?? 'preview_policy')),
|
||||
'policy_name' => trim((string)($policy['policy_name'] ?? '策略草稿')),
|
||||
'merchant_id' => $merchantId,
|
||||
'merchant_app_id' => $merchantAppId,
|
||||
'method_code' => $methodCode,
|
||||
'plugin_code' => trim((string)($policy['plugin_code'] ?? '')),
|
||||
'route_mode' => $routeMode,
|
||||
'status' => 1,
|
||||
'circuit_breaker_threshold' => max(0, min(100, (int)($policy['circuit_breaker_threshold'] ?? 50))),
|
||||
'failover_cooldown' => max(0, (int)($policy['failover_cooldown'] ?? 10)),
|
||||
'items' => $this->normalizePolicyItems($policy['items'] ?? []),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
199
app/services/PayNotifyService.php
Normal file
199
app/services/PayNotifyService.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\repositories\CallbackInboxRepository;
|
||||
use app\repositories\PaymentChannelRepository;
|
||||
use app\repositories\PaymentCallbackLogRepository;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* 支付回调处理服务
|
||||
*
|
||||
* 流程:验签 -> 幂等 -> 更新订单 -> 创建商户通知任务。
|
||||
*/
|
||||
class PayNotifyService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PluginService $pluginService,
|
||||
protected PaymentStateService $paymentStateService,
|
||||
protected CallbackInboxRepository $callbackInboxRepository,
|
||||
protected PaymentChannelRepository $channelRepository,
|
||||
protected PaymentCallbackLogRepository $callbackLogRepository,
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected NotifyService $notifyService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool,already?:bool,msg:string,order_id?:string}
|
||||
*/
|
||||
public function handleNotify(string $pluginCode, Request $request): array
|
||||
{
|
||||
$rawPayload = array_merge($request->get(), $request->post());
|
||||
$candidateOrderId = $this->extractOrderIdFromPayload($rawPayload);
|
||||
$order = $candidateOrderId !== '' ? $this->orderRepository->findByOrderId($candidateOrderId) : null;
|
||||
|
||||
try {
|
||||
$plugin = $this->pluginService->getPluginInstance($pluginCode);
|
||||
|
||||
// 验签前初始化插件配置,保证如支付宝证书验签等能力可用。
|
||||
if ($order && (int)$order->channel_id > 0) {
|
||||
$channel = $this->channelRepository->find((int)$order->channel_id);
|
||||
if ($channel) {
|
||||
if ((string)$channel->plugin_code !== $pluginCode) {
|
||||
return ['ok' => false, 'msg' => 'plugin mismatch'];
|
||||
}
|
||||
$channelConfig = array_merge(
|
||||
$channel->getConfigArray(),
|
||||
['enabled_products' => $channel->getEnabledProducts()]
|
||||
);
|
||||
$plugin->init($channelConfig);
|
||||
}
|
||||
}
|
||||
|
||||
$notifyData = $plugin->notify($request);
|
||||
} catch (\Throwable $e) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $candidateOrderId,
|
||||
'channel_id' => $order ? (int)$order->channel_id : 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 0,
|
||||
'process_status' => 0,
|
||||
'process_result' => $e->getMessage(),
|
||||
]);
|
||||
return ['ok' => false, 'msg' => 'verify failed'];
|
||||
}
|
||||
|
||||
$orderId = (string)($notifyData['pay_order_id'] ?? '');
|
||||
$status = strtolower((string)($notifyData['status'] ?? ''));
|
||||
$chanTradeNo = (string)($notifyData['chan_trade_no'] ?? '');
|
||||
|
||||
if ($orderId === '') {
|
||||
return ['ok' => false, 'msg' => 'missing pay_order_id'];
|
||||
}
|
||||
|
||||
// 已验签但状态非 success 时,也走状态机进行失败态收敛。
|
||||
if ($status !== 'success') {
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if ($order) {
|
||||
try {
|
||||
$this->paymentStateService->markFailed($order);
|
||||
} catch (\Throwable $e) {
|
||||
// 非法迁移不影响回调日志记录
|
||||
}
|
||||
}
|
||||
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $orderId,
|
||||
'channel_id' => $order ? (int)$order->channel_id : 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($rawPayload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => 'notify status is not success',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'msg' => 'notify status is not success'];
|
||||
}
|
||||
|
||||
$eventKey = $this->buildEventKey($pluginCode, $orderId, $chanTradeNo, $notifyData);
|
||||
$payload = $rawPayload;
|
||||
|
||||
$inserted = $this->callbackInboxRepository->createIfAbsent([
|
||||
'event_key' => $eventKey,
|
||||
'plugin_code' => $pluginCode,
|
||||
'order_id' => $orderId,
|
||||
'chan_trade_no' => $chanTradeNo,
|
||||
'payload' => $payload,
|
||||
'process_status' => 0,
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
if (!$inserted) {
|
||||
return ['ok' => true, 'already' => true, 'msg' => 'success', 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
$order = $this->orderRepository->findByOrderId($orderId);
|
||||
if (!$order) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $orderId,
|
||||
'channel_id' => 0,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => 'order not found',
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'msg' => 'order not found'];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->transaction(function () use ($order, $chanTradeNo, $payload, $pluginCode) {
|
||||
$this->paymentStateService->markPaid($order, $chanTradeNo);
|
||||
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $order->order_id,
|
||||
'channel_id' => (int)$order->channel_id,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 1,
|
||||
'process_result' => 'success:' . $pluginCode,
|
||||
]);
|
||||
|
||||
$this->notifyService->createNotifyTask($order->order_id);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->callbackLogRepository->createLog([
|
||||
'order_id' => $order->order_id,
|
||||
'channel_id' => (int)$order->channel_id,
|
||||
'callback_type' => 'notify',
|
||||
'request_data' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
'verify_status' => 1,
|
||||
'process_status' => 0,
|
||||
'process_result' => $e->getMessage(),
|
||||
]);
|
||||
return ['ok' => false, 'msg' => 'process failed'];
|
||||
}
|
||||
|
||||
$event = $this->callbackInboxRepository->findByEventKey($eventKey);
|
||||
if ($event) {
|
||||
$this->callbackInboxRepository->updateById((int)$event->id, [
|
||||
'process_status' => 1,
|
||||
'processed_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
return ['ok' => true, 'msg' => 'success', 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
private function buildEventKey(string $pluginCode, string $orderId, string $chanTradeNo, array $notifyData): string
|
||||
{
|
||||
$base = $pluginCode . '|' . $orderId . '|' . $chanTradeNo . '|' . ($notifyData['status'] ?? '');
|
||||
return sha1($base);
|
||||
}
|
||||
|
||||
private function extractOrderIdFromPayload(array $payload): string
|
||||
{
|
||||
$candidates = [
|
||||
$payload['pay_order_id'] ?? null,
|
||||
$payload['order_id'] ?? null,
|
||||
$payload['out_trade_no'] ?? null,
|
||||
$payload['trade_no'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $id) {
|
||||
$value = trim((string)$id);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class PayOrderService extends BaseService
|
||||
protected PaymentOrderRepository $orderRepository,
|
||||
protected PaymentMethodRepository $methodRepository,
|
||||
protected PluginService $pluginService,
|
||||
protected PaymentStateService $paymentStateService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -168,12 +169,7 @@ class PayOrderService extends BaseService
|
||||
|
||||
// 8. 如果是全额退款则关闭订单
|
||||
if ($refundAmount >= $order->amount) {
|
||||
$this->orderRepository->updateById($order->id, [
|
||||
'status' => PaymentOrder::STATUS_CLOSED,
|
||||
'extra' => array_merge($order->extra ?? [], [
|
||||
'refund_info' => $refundResult,
|
||||
]),
|
||||
]);
|
||||
$this->paymentStateService->closeAfterFullRefund($order, $refundResult);
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -42,6 +42,7 @@ class PayService extends BaseService
|
||||
// 1. 创建订单(幂等)
|
||||
/** @var PaymentOrder $order */
|
||||
$order = $this->payOrderService->createOrder($orderData);
|
||||
$extra = $order->extra ?? [];
|
||||
|
||||
// 2. 查询支付方式
|
||||
$method = $this->methodRepository->find($order->method_id);
|
||||
@@ -50,11 +51,30 @@ class PayService extends BaseService
|
||||
}
|
||||
|
||||
// 3. 通道路由
|
||||
$channel = $this->channelRouterService->chooseChannel(
|
||||
$order->merchant_id,
|
||||
$order->merchant_app_id,
|
||||
$order->method_id
|
||||
);
|
||||
try {
|
||||
$routeDecision = $this->channelRouterService->chooseChannelWithDecision(
|
||||
(int)$order->merchant_id,
|
||||
(int)$order->merchant_app_id,
|
||||
(int)$order->method_id,
|
||||
(float)$order->amount
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$extra['route_error'] = [
|
||||
'message' => $e->getMessage(),
|
||||
'at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$this->orderRepository->updateById((int)$order->id, ['extra' => $extra]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/** @var \app\models\PaymentChannel $channel */
|
||||
$channel = $routeDecision['channel'];
|
||||
unset($extra['route_error']);
|
||||
$extra['routing'] = $this->buildRoutingSnapshot($routeDecision, $channel);
|
||||
$this->orderRepository->updateById((int)$order->id, [
|
||||
'channel_id' => (int)$channel->id,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
// 4. 实例化插件并初始化(通过插件服务)
|
||||
$plugin = $this->pluginService->getPluginInstance($channel->plugin_code);
|
||||
@@ -85,7 +105,7 @@ class PayService extends BaseService
|
||||
'amount' => $order->amount,
|
||||
'subject' => $order->subject,
|
||||
'body' => $order->body,
|
||||
'extra' => $order->extra ?? [],
|
||||
'extra' => $extra,
|
||||
'_env' => $env,
|
||||
];
|
||||
|
||||
@@ -98,7 +118,6 @@ class PayService extends BaseService
|
||||
$realAmount = round($amount - $fee, 2);
|
||||
|
||||
// 8. 更新订单(通道、支付参数、实际金额)
|
||||
$extra = $order->extra ?? [];
|
||||
$extra['pay_params'] = $payResult['pay_params'] ?? null;
|
||||
$chanOrderNo = $payResult['chan_order_no'] ?? $payResult['channel_order_no'] ?? '';
|
||||
$chanTradeNo = $payResult['chan_trade_no'] ?? $payResult['channel_trade_no'] ?? '';
|
||||
@@ -119,6 +138,35 @@ class PayService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRoutingSnapshot(array $routeDecision, \app\models\PaymentChannel $channel): array
|
||||
{
|
||||
$policy = is_array($routeDecision['policy'] ?? null) ? $routeDecision['policy'] : null;
|
||||
$candidates = [];
|
||||
foreach (($routeDecision['candidates'] ?? []) as $candidate) {
|
||||
$candidates[] = [
|
||||
'channel_id' => (int)($candidate['channel_id'] ?? 0),
|
||||
'chan_code' => (string)($candidate['chan_code'] ?? ''),
|
||||
'chan_name' => (string)($candidate['chan_name'] ?? ''),
|
||||
'available' => (bool)($candidate['available'] ?? false),
|
||||
'priority' => (int)($candidate['priority'] ?? 0),
|
||||
'weight' => (int)($candidate['weight'] ?? 0),
|
||||
'role' => (string)($candidate['role'] ?? ''),
|
||||
'reasons' => array_values($candidate['reasons'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'source' => (string)($routeDecision['source'] ?? 'fallback'),
|
||||
'route_mode' => (string)($routeDecision['route_mode'] ?? 'sort'),
|
||||
'policy' => $policy,
|
||||
'selected_channel_id' => (int)$channel->id,
|
||||
'selected_channel_code' => (string)$channel->chan_code,
|
||||
'selected_channel_name' => (string)$channel->chan_name,
|
||||
'candidates' => array_slice($candidates, 0, 10),
|
||||
'selected_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求 UA 检测环境
|
||||
*/
|
||||
|
||||
113
app/services/PaymentStateService.php
Normal file
113
app/services/PaymentStateService.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\services;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exceptions\BadRequestException;
|
||||
use app\models\PaymentOrder;
|
||||
use app\repositories\PaymentOrderRepository;
|
||||
|
||||
/**
|
||||
* 支付订单状态机服务(最小版)
|
||||
*
|
||||
* 约束核心状态迁移:
|
||||
* - PENDING -> SUCCESS/FAIL/CLOSED
|
||||
* - SUCCESS -> CLOSED
|
||||
*/
|
||||
class PaymentStateService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected PaymentOrderRepository $orderRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回调支付成功。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function markPaid(PaymentOrder $order, string $chanTradeNo = '', ?string $payAt = null): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_SUCCESS)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_SUCCESS);
|
||||
}
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_SUCCESS,
|
||||
'pay_at' => $payAt ?: date('Y-m-d H:i:s'),
|
||||
'chan_trade_no' => $chanTradeNo !== '' ? $chanTradeNo : (string)$order->chan_trade_no,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记支付失败(用于已验签的失败回调)。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function markFailed(PaymentOrder $order): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_FAIL) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_FAIL)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_FAIL);
|
||||
}
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_FAIL,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全额退款后关单。
|
||||
*
|
||||
* @return bool true=状态有变更, false=幂等无变更
|
||||
*/
|
||||
public function closeAfterFullRefund(PaymentOrder $order, array $refundInfo = []): bool
|
||||
{
|
||||
$from = (int)$order->status;
|
||||
if ($from === PaymentOrder::STATUS_CLOSED) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->canTransit($from, PaymentOrder::STATUS_CLOSED)) {
|
||||
throw new BadRequestException("illegal status transition: {$from} -> " . PaymentOrder::STATUS_CLOSED);
|
||||
}
|
||||
|
||||
$extra = $order->extra ?? [];
|
||||
$extra['refund_info'] = $refundInfo;
|
||||
|
||||
$ok = $this->orderRepository->updateById((int)$order->id, [
|
||||
'status' => PaymentOrder::STATUS_CLOSED,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
return (bool)$ok;
|
||||
}
|
||||
|
||||
private function canTransit(int $from, int $to): bool
|
||||
{
|
||||
$allowed = [
|
||||
PaymentOrder::STATUS_PENDING => [
|
||||
PaymentOrder::STATUS_SUCCESS,
|
||||
PaymentOrder::STATUS_FAIL,
|
||||
PaymentOrder::STATUS_CLOSED,
|
||||
],
|
||||
PaymentOrder::STATUS_SUCCESS => [
|
||||
PaymentOrder::STATUS_CLOSED,
|
||||
],
|
||||
PaymentOrder::STATUS_FAIL => [],
|
||||
PaymentOrder::STATUS_CLOSED => [],
|
||||
];
|
||||
|
||||
return in_array($to, $allowed[$from] ?? [], true);
|
||||
}
|
||||
}
|
||||
106
app/services/api/EpayProtocolService.php
Normal file
106
app/services/api/EpayProtocolService.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace app\services\api;
|
||||
|
||||
use app\validation\EpayValidator;
|
||||
use support\Request;
|
||||
|
||||
/**
|
||||
* Epay 协议层服务
|
||||
*
|
||||
* 负责协议参数提取、校验和协议结果映射,不承载支付核心业务。
|
||||
*/
|
||||
class EpayProtocolService
|
||||
{
|
||||
public function __construct(
|
||||
protected EpayService $epayService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 submit.php 请求
|
||||
*
|
||||
* @return array{response_type:string,url?:string,html?:string,form?:array}
|
||||
*/
|
||||
public function handleSubmit(Request $request): array
|
||||
{
|
||||
$data = match ($request->method()) {
|
||||
'GET' => $request->get(),
|
||||
'POST' => $request->post(),
|
||||
default => $request->all(),
|
||||
};
|
||||
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('submit')
|
||||
->validate();
|
||||
|
||||
$result = $this->epayService->submit($params, $request);
|
||||
$payParams = $result['pay_params'] ?? [];
|
||||
|
||||
if (($payParams['type'] ?? '') === 'redirect' && !empty($payParams['url'])) {
|
||||
return [
|
||||
'response_type' => 'redirect',
|
||||
'url' => $payParams['url'],
|
||||
];
|
||||
}
|
||||
|
||||
if (($payParams['type'] ?? '') === 'form') {
|
||||
if (!empty($payParams['html'])) {
|
||||
return [
|
||||
'response_type' => 'form_html',
|
||||
'html' => $payParams['html'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'response_type' => 'form_params',
|
||||
'form' => $payParams,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'response_type' => 'error',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 mapi.php 请求
|
||||
*/
|
||||
public function handleMapi(Request $request): array
|
||||
{
|
||||
$params = EpayValidator::make($request->post())
|
||||
->withScene('mapi')
|
||||
->validate();
|
||||
|
||||
return $this->epayService->mapi($params, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 api.php 请求
|
||||
*/
|
||||
public function handleApi(Request $request): array
|
||||
{
|
||||
$data = array_merge($request->get(), $request->post());
|
||||
$act = strtolower((string)($data['act'] ?? ''));
|
||||
|
||||
if ($act === 'order') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_order')
|
||||
->validate();
|
||||
return $this->epayService->api($params);
|
||||
}
|
||||
|
||||
if ($act === 'refund') {
|
||||
$params = EpayValidator::make($data)
|
||||
->withScene('api_refund')
|
||||
->validate();
|
||||
return $this->epayService->api($params);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'msg' => '不支持的操作类型',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ class EpayService extends BaseService
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'code' => 1,
|
||||
'msg' => '退款成功',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
{
|
||||
"formId": "basic-config",
|
||||
"title": "基础设置",
|
||||
"submitText": "保存设置",
|
||||
"title": "基础配置",
|
||||
"submitText": "保存配置",
|
||||
"submitUrl": "/adminapi/system/base-config/submit/basic",
|
||||
"cacheKey": "basic_config_cache",
|
||||
"refreshAfterSubmit": true,
|
||||
@@ -37,10 +37,10 @@
|
||||
{
|
||||
"type": "input",
|
||||
"field": "site_logo",
|
||||
"title": "站点Logo",
|
||||
"title": "站点 Logo",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "请输入Logo地址或上传Logo"
|
||||
"placeholder": "请输入 Logo 地址"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
"title": "备案号",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "请输入ICP备案号"
|
||||
"placeholder": "请输入 ICP 备案号"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -81,4 +81,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
{
|
||||
"formId": "email-config",
|
||||
"title": "邮件设置",
|
||||
"submitText": "保存设置",
|
||||
"title": "邮件配置",
|
||||
"submitText": "保存配置",
|
||||
"submitUrl": "/adminapi/system/base-config/submit/email",
|
||||
"cacheKey": "email_config_cache",
|
||||
"refreshAfterSubmit": true,
|
||||
@@ -9,7 +9,7 @@
|
||||
{
|
||||
"type": "input",
|
||||
"field": "smtp_host",
|
||||
"title": "SMTP服务器",
|
||||
"title": "SMTP 主机",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "例如:smtp.qq.com"
|
||||
@@ -17,14 +17,14 @@
|
||||
"validate": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "SMTP服务器不能为空"
|
||||
"message": "SMTP 主机不能为空"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "inputNumber",
|
||||
"field": "smtp_port",
|
||||
"title": "SMTP端口",
|
||||
"title": "SMTP 端口",
|
||||
"value": 465,
|
||||
"props": {
|
||||
"min": 1,
|
||||
@@ -34,14 +34,14 @@
|
||||
"validate": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "SMTP端口不能为空"
|
||||
"message": "SMTP 端口不能为空"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "switch",
|
||||
"field": "smtp_ssl",
|
||||
"title": "启用SSL",
|
||||
"title": "启用 SSL",
|
||||
"value": true,
|
||||
"props": {
|
||||
"checkedText": "是",
|
||||
@@ -51,46 +51,46 @@
|
||||
{
|
||||
"type": "input",
|
||||
"field": "smtp_username",
|
||||
"title": "SMTP用户名",
|
||||
"title": "SMTP 用户名",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "请输入SMTP用户名"
|
||||
"placeholder": "请输入 SMTP 用户名"
|
||||
},
|
||||
"validate": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "SMTP用户名不能为空"
|
||||
"message": "SMTP 用户名不能为空"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"field": "smtp_password",
|
||||
"title": "SMTP密码",
|
||||
"title": "SMTP 密码",
|
||||
"value": "",
|
||||
"props": {
|
||||
"type": "password",
|
||||
"placeholder": "请输入SMTP密码或授权码"
|
||||
"placeholder": "请输入 SMTP 密码或授权码"
|
||||
},
|
||||
"validate": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "SMTP密码不能为空"
|
||||
"message": "SMTP 密码不能为空"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"field": "from_email",
|
||||
"title": "发件人邮箱",
|
||||
"title": "发件邮箱",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "请输入发件人邮箱地址"
|
||||
"placeholder": "请输入发件邮箱地址"
|
||||
},
|
||||
"validate": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "发件人邮箱不能为空"
|
||||
"message": "发件邮箱不能为空"
|
||||
},
|
||||
{
|
||||
"type": "email",
|
||||
@@ -101,7 +101,7 @@
|
||||
{
|
||||
"type": "input",
|
||||
"field": "from_name",
|
||||
"title": "发件人名称",
|
||||
"title": "发件名称",
|
||||
"value": "",
|
||||
"props": {
|
||||
"placeholder": "请输入发件人名称"
|
||||
@@ -109,4 +109,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
{
|
||||
"formId": "permission-config",
|
||||
"title": "权限设置",
|
||||
"submitText": "保存设置",
|
||||
"title": "权限配置",
|
||||
"submitText": "保存配置",
|
||||
"submitUrl": "/adminapi/system/base-config/submit/permission",
|
||||
"cacheKey": "permission_config_cache",
|
||||
"refreshAfterSubmit": true,
|
||||
@@ -96,4 +96,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"key": "basic",
|
||||
"title": "基础设置",
|
||||
"title": "基础配置",
|
||||
"icon": "settings",
|
||||
"description": "配置系统基础信息,包括站点名称、Logo、备案号等",
|
||||
"description": "配置系统基础信息,包括站点名称、Logo、备案号和分页默认值。",
|
||||
"sort": 1,
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"key": "email",
|
||||
"title": "邮件设置",
|
||||
"title": "邮件配置",
|
||||
"icon": "email",
|
||||
"description": "配置邮件服务器相关参数,包括SMTP服务器、端口、账号密码等",
|
||||
"description": "配置 SMTP 主机、端口、账号和发件人信息,用于通知发送与联通检查。",
|
||||
"sort": 2,
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"key": "permission",
|
||||
"title": "权限设置",
|
||||
"title": "权限配置",
|
||||
"icon": "lock",
|
||||
"description": "配置系统权限相关参数,预留权限控制功能",
|
||||
"description": "配置后台权限控制、会话超时、密码强度和登录限制等安全参数。",
|
||||
"sort": 3,
|
||||
"disabled": false
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
20
database/patch_callback_inbox.sql
Normal file
20
database/patch_callback_inbox.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ma_callback_inbox` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`event_key` char(40) NOT NULL DEFAULT '' COMMENT '幂等事件键(SHA1)',
|
||||
`plugin_code` varchar(32) NOT NULL DEFAULT '' COMMENT '插件编码',
|
||||
`order_id` varchar(32) NOT NULL DEFAULT '' COMMENT '系统订单号',
|
||||
`chan_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '渠道交易号',
|
||||
`payload` json DEFAULT NULL COMMENT '回调原始数据',
|
||||
`process_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '处理状态:0-待处理 1-已处理',
|
||||
`processed_at` datetime DEFAULT NULL COMMENT '处理完成时间',
|
||||
`created_at` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_event_key` (`event_key`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_plugin_code` (`plugin_code`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付回调幂等收件箱';
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# 认证策略设计说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
采用**策略模式**替代中间件方式处理认证,具有以下优势:
|
||||
|
||||
1. **灵活扩展**:可以轻松添加新的接口标准(如易支付、OpenAPI、自定义标准等)
|
||||
2. **按需使用**:控制器可以根据需要选择认证策略,而不是在路由层面强制
|
||||
3. **易于测试**:策略类可以独立测试,不依赖中间件
|
||||
4. **代码复用**:不同接口可以共享相同的认证逻辑
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 1. 核心接口
|
||||
|
||||
**`AuthStrategyInterface`** - 认证策略接口
|
||||
```php
|
||||
interface AuthStrategyInterface
|
||||
{
|
||||
public function authenticate(Request $request): MerchantApp;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 策略实现
|
||||
|
||||
#### EpayAuthStrategy(易支付认证)
|
||||
- 使用 `pid` + `key` + `MD5签名`
|
||||
- 参数格式:`application/x-www-form-urlencoded`
|
||||
- 签名算法:MD5(排序后的参数字符串 + KEY)
|
||||
|
||||
#### OpenApiAuthStrategy(OpenAPI认证)
|
||||
- 使用 `app_id` + `timestamp` + `nonce` + `HMAC-SHA256签名`
|
||||
- 支持请求头或参数传递
|
||||
- 签名算法:HMAC-SHA256(签名字符串, app_secret)
|
||||
|
||||
### 3. 认证服务
|
||||
|
||||
**`AuthService`** - 认证服务,负责:
|
||||
- 自动检测接口标准类型
|
||||
- 根据类型选择对应的认证策略
|
||||
- 支持手动注册新的认证策略
|
||||
|
||||
```php
|
||||
// 自动检测
|
||||
$app = $authService->authenticate($request);
|
||||
|
||||
// 指定策略类型
|
||||
$app = $authService->authenticate($request, 'epay');
|
||||
|
||||
// 注册新策略
|
||||
$authService->registerStrategy('custom', CustomAuthStrategy::class);
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 控制器中使用
|
||||
|
||||
```php
|
||||
class PayController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
protected PayOrderService $payOrderService,
|
||||
protected AuthService $authService
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
// 自动检测或指定策略类型
|
||||
$app = $this->authService->authenticate($request, 'epay');
|
||||
|
||||
// 使用 $app 进行后续业务处理
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的认证策略
|
||||
|
||||
1. **实现策略接口**
|
||||
```php
|
||||
class CustomAuthStrategy implements AuthStrategyInterface
|
||||
{
|
||||
public function authenticate(Request $request): MerchantApp
|
||||
{
|
||||
// 实现自定义认证逻辑
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **注册策略**
|
||||
```php
|
||||
// 在服务提供者或启动文件中
|
||||
$authService = new AuthService();
|
||||
$authService->registerStrategy('custom', CustomAuthStrategy::class);
|
||||
```
|
||||
|
||||
3. **在控制器中使用**
|
||||
```php
|
||||
$app = $this->authService->authenticate($request, 'custom');
|
||||
```
|
||||
|
||||
## 自动检测机制
|
||||
|
||||
`AuthService` 会根据请求特征自动检测接口标准:
|
||||
|
||||
- **易支付**:检测到 `pid` 参数
|
||||
- **OpenAPI**:检测到 `X-App-Id` 请求头或 `app_id` 参数
|
||||
|
||||
如果无法自动检测,可以手动指定策略类型。
|
||||
|
||||
## 优势对比
|
||||
|
||||
### 中间件方式(旧方案)
|
||||
- ❌ 路由配置复杂,每个接口标准需要不同的中间件
|
||||
- ❌ 难以在同一路由支持多种认证方式
|
||||
- ❌ 扩展新标准需要修改路由配置
|
||||
|
||||
### 策略模式(新方案)
|
||||
- ✅ 控制器按需选择认证策略
|
||||
- ✅ 同一路由可以支持多种认证方式(通过参数区分)
|
||||
- ✅ 扩展新标准只需实现策略接口并注册
|
||||
- ✅ 代码更清晰,职责分离
|
||||
|
||||
## 路由配置
|
||||
|
||||
由于不再使用中间件,路由配置更简洁:
|
||||
|
||||
```php
|
||||
// 易支付接口
|
||||
Route::any('/submit.php', [PayController::class, 'submit']);
|
||||
Route::post('/mapi.php', [PayController::class, 'mapi']);
|
||||
Route::get('/api.php', [PayController::class, 'queryOrder']);
|
||||
|
||||
// 所有接口都在控制器内部进行认证,无需中间件
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过策略模式重构认证逻辑,系统具备了:
|
||||
- **高扩展性**:轻松添加新的接口标准
|
||||
- **高灵活性**:控制器可以自由选择认证方式
|
||||
- **高可维护性**:代码结构清晰,易于理解和维护
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
# 支付订单表设计说明
|
||||
|
||||
## 一、订单表设计原因
|
||||
|
||||
### 1.1 订单号设计(双重订单号)
|
||||
|
||||
**系统订单号 (`pay_order_id`)**
|
||||
- **作用**:系统内部唯一标识,用于查询、对账、退款等操作
|
||||
- **生成规则**:`P` + `YYYYMMDDHHmmss` + `6位随机数`(如:P20240101120000123456)
|
||||
- **唯一性**:通过 `uk_pay_order_id` 唯一索引保证
|
||||
- **优势**:
|
||||
- 全局唯一,不受商户影响
|
||||
- 便于系统内部查询和关联
|
||||
- 对账时作为主键
|
||||
|
||||
**商户订单号 (`mch_order_no`)**
|
||||
- **作用**:商户传入的订单号,用于幂等性校验
|
||||
- **唯一性**:通过 `uk_mch_order_no(merchant_id, mch_order_no)` 联合唯一索引保证
|
||||
- **优势**:
|
||||
- 同一商户下订单号唯一,防止重复提交
|
||||
- 商户侧可以自定义订单号规则
|
||||
- 支持商户订单号查询订单
|
||||
|
||||
**为什么需要两个订单号?**
|
||||
- 系统订单号:保证全局唯一,便于系统内部管理
|
||||
- 商户订单号:保证商户侧唯一,防止重复支付(幂等性)
|
||||
|
||||
### 1.2 关联关系设计
|
||||
|
||||
**商户与应用关联 (`merchant_id` + `app_id`)**
|
||||
- **作用**:标识订单所属商户和应用
|
||||
- **用途**:
|
||||
- 权限控制(商户只能查询自己的订单)
|
||||
- 对账统计(按商户/应用维度)
|
||||
- 通知路由(根据应用配置的通知地址)
|
||||
|
||||
**支付通道关联 (`channel_id`)**
|
||||
- **作用**:记录实际使用的支付通道
|
||||
- **用途**:
|
||||
- 退款时找到对应的插件和配置
|
||||
- 对账时关联通道信息
|
||||
- 统计通道使用情况
|
||||
|
||||
**支付方式与产品 (`method_code` + `product_code`)**
|
||||
- **method_code**:支付方式(alipay/wechat/unionpay)
|
||||
- 用于统计、筛选、报表
|
||||
- **product_code**:支付产品(alipay_h5/alipay_life/wechat_jsapi等)
|
||||
- 由插件根据用户环境自动选择
|
||||
- 用于记录实际使用的支付产品
|
||||
|
||||
### 1.3 金额字段设计
|
||||
|
||||
**订单金额 (`amount`)**
|
||||
- 商户实际收款金额(扣除手续费前)
|
||||
- 用于退款金额校验、对账
|
||||
|
||||
**手续费 (`fee`)**
|
||||
- 可选字段,记录通道手续费
|
||||
- 用于对账、结算、利润统计
|
||||
- 如果不需要详细记录手续费,可以留空或通过 `extra` 存储
|
||||
|
||||
**币种 (`currency`)**
|
||||
- 默认 CNY,支持国际化扩展
|
||||
- 预留字段,便于后续支持多币种
|
||||
|
||||
### 1.4 状态流转设计
|
||||
|
||||
```
|
||||
PENDING(待支付)
|
||||
├─> SUCCESS(支付成功)← 收到渠道回调并验签通过
|
||||
├─> FAIL(支付失败)← 用户取消、超时、渠道返回失败
|
||||
└─> CLOSED(已关闭)← 全额退款后
|
||||
```
|
||||
|
||||
**状态说明**:
|
||||
- **PENDING**:订单创建后,等待用户支付
|
||||
- **SUCCESS**:支付成功,已收到渠道回调并验签通过
|
||||
- **FAIL**:支付失败(用户取消、订单超时、渠道返回失败等)
|
||||
- **CLOSED**:已关闭(全额退款后)
|
||||
|
||||
### 1.5 渠道信息设计
|
||||
|
||||
**渠道订单号 (`channel_order_no`)**
|
||||
- 渠道返回的订单号
|
||||
- 用于查询订单状态、退款等操作
|
||||
|
||||
**渠道交易号 (`channel_trade_no`)**
|
||||
- 部分渠道有交易号概念(如支付宝的 trade_no)
|
||||
- 用于对账、查询等
|
||||
|
||||
### 1.6 通知机制设计
|
||||
|
||||
**通知状态 (`notify_status`)**
|
||||
- 0:未通知
|
||||
- 1:已通知成功
|
||||
|
||||
**通知次数 (`notify_count`)**
|
||||
- 记录通知次数,用于重试控制
|
||||
- 配合 `ma_notify_task` 表实现异步通知
|
||||
|
||||
### 1.7 扩展性设计
|
||||
|
||||
**扩展字段 (`extra`)**
|
||||
- JSON 格式,存储:
|
||||
- 支付参数(`pay_params`):前端支付所需的参数
|
||||
- 退款信息(`refund_info`):退款结果
|
||||
- 自定义字段:业务扩展字段
|
||||
|
||||
**订单过期时间 (`expire_time`)**
|
||||
- 用于自动关闭超时订单
|
||||
- 默认 30 分钟,可配置
|
||||
|
||||
## 二、索引设计说明
|
||||
|
||||
### 2.1 唯一索引
|
||||
|
||||
- **`uk_pay_order_id`**:保证系统订单号唯一
|
||||
- **`uk_mch_order_no(merchant_id, mch_order_no)`**:保证同一商户下商户订单号唯一(幂等性)
|
||||
|
||||
### 2.2 普通索引
|
||||
|
||||
- **`idx_merchant_app(merchant_id, app_id)`**:商户/应用维度查询
|
||||
- **`idx_channel_id`**:通道维度查询
|
||||
- **`idx_method_code`**:支付方式维度统计
|
||||
- **`idx_status`**:状态筛选
|
||||
- **`idx_pay_time`**:按支付时间查询(对账、统计)
|
||||
- **`idx_created_at`**:按创建时间查询(分页、统计)
|
||||
|
||||
## 三、可能遗漏的字段(后续扩展)
|
||||
|
||||
### 3.1 退款相关字段
|
||||
|
||||
如果后续需要支持**部分退款**或**多次退款**,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`refund_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已退款金额(累计)',
|
||||
`refund_status` varchar(20) NOT NULL DEFAULT '' COMMENT '退款状态:PENDING-退款中, SUCCESS-退款成功, FAIL-退款失败',
|
||||
`refund_time` datetime DEFAULT NULL COMMENT '最后退款时间',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 退款信息存储在 `extra['refund_info']` 中
|
||||
- 全额退款后订单状态改为 `CLOSED`
|
||||
- 如果只需要全额退款,当前设计已足够
|
||||
|
||||
### 3.2 结算相关字段
|
||||
|
||||
如果后续需要**分账/结算**功能,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`settlement_status` varchar(20) NOT NULL DEFAULT '' COMMENT '结算状态:PENDING-待结算, SUCCESS-已结算, FAIL-结算失败',
|
||||
`settlement_time` datetime DEFAULT NULL COMMENT '结算时间',
|
||||
`settlement_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '结算金额',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 结算信息可以通过 `extra` 存储
|
||||
- 如果不需要复杂的结算流程,当前设计已足够
|
||||
|
||||
### 3.3 风控相关字段
|
||||
|
||||
如果需要**风控功能**,可以考虑添加:
|
||||
|
||||
```sql
|
||||
`risk_level` varchar(20) NOT NULL DEFAULT '' COMMENT '风险等级:LOW-低, MEDIUM-中, HIGH-高',
|
||||
`risk_score` int(11) NOT NULL DEFAULT 0 COMMENT '风险评分',
|
||||
`risk_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '风险原因',
|
||||
```
|
||||
|
||||
**当前设计**:
|
||||
- 风控信息可以通过 `extra` 存储
|
||||
- 如果不需要复杂的风控系统,当前设计已足够
|
||||
|
||||
### 3.4 其他扩展字段
|
||||
|
||||
- **`user_id`**:用户ID(如果需要关联用户)
|
||||
- **`device_info`**:设备信息(用于风控)
|
||||
- **`remark`**:备注(管理员备注)
|
||||
- **`close_reason`**:关闭原因(用户取消/超时/管理员关闭等)
|
||||
|
||||
## 四、设计原则总结
|
||||
|
||||
1. **幂等性**:通过 `uk_mch_order_no` 保证同一商户下订单号唯一
|
||||
2. **可追溯性**:记录完整的订单信息、渠道信息、时间信息
|
||||
3. **可扩展性**:通过 `extra` JSON 字段存储扩展信息
|
||||
4. **性能优化**:合理的索引设计,支持常见查询场景
|
||||
5. **业务完整性**:覆盖订单全生命周期(创建→支付→退款→关闭)
|
||||
|
||||
## 五、与代码的对应关系
|
||||
|
||||
| SQL 字段 | 代码字段 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `pay_order_id` | `pay_order_id` | 系统订单号 |
|
||||
| `merchant_id` | `merchant_id` | 商户ID |
|
||||
| `app_id` | `app_id` | 应用ID |
|
||||
| `mch_order_no` | `mch_order_no` | 商户订单号 |
|
||||
| `method_code` | `method_code` | 支付方式 |
|
||||
| `product_code` | `product_code` | 支付产品 |
|
||||
| `channel_id` | `channel_id` | 通道ID |
|
||||
| `amount` | `amount` | 订单金额 |
|
||||
| `currency` | `currency` | 币种 |
|
||||
| `status` | `status` | 订单状态 |
|
||||
| `channel_order_no` | `channel_order_no` | 渠道订单号 |
|
||||
| `channel_trade_no` | `channel_trade_no` | 渠道交易号 |
|
||||
| `extra` | `extra` | 扩展字段(JSON) |
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **字段命名统一**:SQL 和代码中的字段名必须一致
|
||||
2. **索引维护**:定期检查索引使用情况,优化慢查询
|
||||
3. **数据归档**:历史订单数据量大时,考虑归档策略
|
||||
4. **JSON 字段**:`extra` 字段使用 JSON 类型,便于扩展但查询性能略低
|
||||
5. **时间字段**:`pay_time`、`expire_time` 等时间字段使用 `datetime` 类型,便于查询和统计
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
# 支付订单发起流程说明
|
||||
|
||||
## 一、业务系统调用统一下单接口
|
||||
|
||||
### 1. 接口地址
|
||||
```
|
||||
POST /api/pay/unifiedOrder
|
||||
```
|
||||
|
||||
### 2. 请求头(签名认证)
|
||||
```
|
||||
X-App-Id: app001 # 应用ID
|
||||
X-Timestamp: 1704067200 # 时间戳(Unix秒)
|
||||
X-Nonce: abc123xyz # 随机字符串
|
||||
X-Signature: calculated_signature # 签名(HMAC-SHA256)
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 3. 签名算法
|
||||
|
||||
**待签名字符串**:
|
||||
```
|
||||
app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={body_sha256}
|
||||
```
|
||||
|
||||
**计算签名**:
|
||||
```php
|
||||
$bodySha256 = hash('sha256', json_encode($requestBody));
|
||||
$signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={bodySha256}";
|
||||
$signature = hash_hmac('sha256', $signString, $appSecret);
|
||||
```
|
||||
|
||||
### 4. 请求体示例
|
||||
|
||||
```json
|
||||
{
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"currency": "CNY",
|
||||
"subject": "测试商品",
|
||||
"body": "测试商品描述"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `mch_order_no`:商户订单号(必填,唯一,用于幂等)
|
||||
- `pay_method`:支付方式(必填,如:alipay、wechat、unionpay)
|
||||
- `amount`:订单金额(必填,单位:元)
|
||||
- `currency`:币种(可选,默认:CNY)
|
||||
- `subject`:订单标题(必填)
|
||||
- `body`:订单描述(可选)
|
||||
|
||||
### 5. 调用示例(cURL)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-App-Id: app001" \
|
||||
-H "X-Timestamp: 1704067200" \
|
||||
-H "X-Nonce: abc123xyz" \
|
||||
-H "X-Signature: calculated_signature" \
|
||||
-d '{
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"subject": "测试商品",
|
||||
"body": "测试商品描述"
|
||||
}'
|
||||
```
|
||||
|
||||
### 6. PHP调用示例
|
||||
|
||||
```php
|
||||
<?php
|
||||
$appId = 'app001';
|
||||
$appSecret = 'your_app_secret';
|
||||
$baseUrl = 'http://localhost:8787';
|
||||
|
||||
// 准备请求数据
|
||||
$requestBody = [
|
||||
'mch_order_no' => 'ORDER202401011200001',
|
||||
'pay_method' => 'alipay',
|
||||
'amount' => 100.00,
|
||||
'subject' => '测试商品',
|
||||
'body' => '测试商品描述'
|
||||
];
|
||||
|
||||
// 计算签名
|
||||
$timestamp = time();
|
||||
$nonce = uniqid();
|
||||
$bodyJson = json_encode($requestBody);
|
||||
$bodySha256 = hash('sha256', $bodyJson);
|
||||
$signString = "app_id={$appId}×tamp={$timestamp}&nonce={$nonce}&method=POST&path=/api/pay/unifiedOrder&body_sha256={$bodySha256}";
|
||||
$signature = hash_hmac('sha256', $signString, $appSecret);
|
||||
|
||||
// 发送请求
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $baseUrl . '/api/pay/unifiedOrder');
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyJson);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
"X-App-Id: {$appId}",
|
||||
"X-Timestamp: {$timestamp}",
|
||||
"X-Nonce: {$nonce}",
|
||||
"X-Signature: {$signature}",
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if ($httpCode === 200 && $result['code'] === 200) {
|
||||
echo "支付订单号:" . $result['data']['pay_order_id'] . "\n";
|
||||
echo "支付参数:" . json_encode($result['data']['pay_params'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||
} else {
|
||||
echo "错误:" . $result['msg'] . "\n";
|
||||
}
|
||||
```
|
||||
|
||||
## 二、服务端处理流程
|
||||
|
||||
### 流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant BizSystem as 业务系统
|
||||
participant OpenAPI as OpenAPI接口
|
||||
participant AuthMW as 签名中间件
|
||||
participant PayService as 订单服务
|
||||
participant ChannelRouter as 通道路由
|
||||
participant PluginFactory as 插件工厂
|
||||
participant Plugin as 支付插件
|
||||
participant Channel as 第三方渠道
|
||||
|
||||
BizSystem->>OpenAPI: POST /api/pay/unifiedOrder
|
||||
OpenAPI->>AuthMW: 验证签名
|
||||
AuthMW->>PayService: 调用统一下单
|
||||
PayService->>PayService: 1. 验证商户应用
|
||||
PayService->>PayService: 2. 幂等校验
|
||||
PayService->>PayService: 3. 创建支付订单
|
||||
PayService->>ChannelRouter: 4. 选择通道
|
||||
ChannelRouter-->>PayService: 返回通道信息
|
||||
PayService->>PluginFactory: 5. 实例化插件
|
||||
PluginFactory-->>PayService: 返回插件实例
|
||||
PayService->>Plugin: 6. 初始化插件(init)
|
||||
PayService->>Plugin: 7. 环境检测
|
||||
PayService->>Plugin: 8. 调用统一下单(unifiedOrder)
|
||||
Plugin->>Plugin: 8.1 根据环境选择产品
|
||||
Plugin->>Channel: 8.2 调用第三方接口
|
||||
Channel-->>Plugin: 返回支付参数
|
||||
Plugin-->>PayService: 返回支付结果
|
||||
PayService->>PayService: 9. 更新订单信息
|
||||
PayService-->>OpenAPI: 返回结果
|
||||
OpenAPI-->>BizSystem: 返回支付参数
|
||||
```
|
||||
|
||||
### 详细步骤说明
|
||||
|
||||
#### 步骤1:签名验证(中间件)
|
||||
- `OpenApiAuthMiddleware` 验证请求头中的签名
|
||||
- 验证时间戳(5分钟内有效)
|
||||
- 验证签名是否正确
|
||||
- 将应用信息注入到请求对象
|
||||
|
||||
#### 步骤2:验证商户应用
|
||||
- 根据 `app_id` 查询 `ma_merchant_app` 表
|
||||
- 检查应用状态是否启用
|
||||
|
||||
#### 步骤3:幂等校验
|
||||
- 根据 `merchant_id + mch_order_no` 查询是否已存在订单
|
||||
- 如果存在,直接返回已有订单信息(支持幂等)
|
||||
|
||||
#### 步骤4:创建支付订单
|
||||
- 生成支付订单号(格式:`P20240101120000123456`)
|
||||
- 创建 `ma_pay_order` 记录
|
||||
- 状态:`PENDING`(待支付)
|
||||
- 过期时间:30分钟后
|
||||
|
||||
#### 步骤5:通道路由选择
|
||||
- 根据 `merchant_id + app_id + method_code` 查找可用通道
|
||||
- 从 `ma_pay_channel` 表中查询
|
||||
- 选择第一个可用的通道(后续可扩展权重、容灾策略)
|
||||
|
||||
#### 步骤6:实例化插件
|
||||
- 在 `PayService` 中根据 `ma_pay_plugin` 注册表解析插件:优先使用表中的 `class_name`,否则按约定使用 `app\common\payment\{Code}Payment` 实例化插件
|
||||
- 例如:`plugin_code = 'lakala'` → 实例化 `LakalaPlugin`
|
||||
|
||||
#### 步骤7:初始化插件
|
||||
- 调用 `$plugin->init($methodCode, $channelConfig)`
|
||||
- 插件内部切换到指定支付方式的配置和逻辑
|
||||
- 例如:拉卡拉插件初始化到 `alipay` 模式
|
||||
|
||||
#### 步骤8:环境检测
|
||||
- 从请求头 `User-Agent` 判断用户环境
|
||||
- 环境类型:
|
||||
- `PC`:PC桌面浏览器
|
||||
- `H5`:H5手机浏览器
|
||||
- `WECHAT`:微信内浏览器
|
||||
- `ALIPAY_CLIENT`:支付宝客户端
|
||||
|
||||
#### 步骤9:调用插件统一下单
|
||||
- 调用 `$plugin->unifiedOrder($orderData, $channelConfig, $env)`
|
||||
- 插件内部处理:
|
||||
1. **产品选择**:从通道的 `enabled_products` 中,根据环境自动选择一个产品
|
||||
- 例如:H5环境 → 选择 `alipay_h5`
|
||||
- 例如:支付宝客户端 → 选择 `alipay_life`
|
||||
2. **调用第三方接口**:根据产品和支付方式,调用对应的第三方支付接口
|
||||
- 例如:拉卡拉插件的支付宝H5接口
|
||||
3. **返回支付参数**:返回给业务系统的支付参数
|
||||
|
||||
#### 步骤10:更新订单
|
||||
- 更新订单的 `product_code`(实际使用的产品)
|
||||
- 更新订单的 `channel_id`
|
||||
- 更新订单的 `channel_order_no`(渠道订单号)
|
||||
- 保存 `pay_params` 到 `extra` 字段
|
||||
|
||||
## 三、响应数据格式
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"pay_order_id": "P20240101120000123456",
|
||||
"status": "PENDING",
|
||||
"pay_params": {
|
||||
"type": "redirect",
|
||||
"url": "https://mapi.alipay.com/gateway.do?..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 支付参数类型
|
||||
|
||||
根据不同的支付产品和环境,`pay_params` 的格式不同:
|
||||
|
||||
#### 1. 跳转支付(H5/PC扫码)
|
||||
```json
|
||||
{
|
||||
"type": "redirect",
|
||||
"url": "https://mapi.alipay.com/gateway.do?xxx"
|
||||
}
|
||||
```
|
||||
业务系统需要:**跳转到该URL**
|
||||
|
||||
#### 2. 表单提交(H5)
|
||||
```json
|
||||
{
|
||||
"type": "form",
|
||||
"method": "POST",
|
||||
"action": "https://mapi.alipay.com/gateway.do",
|
||||
"fields": {
|
||||
"app_id": "xxx",
|
||||
"method": "alipay.trade.wap.pay",
|
||||
"biz_content": "{...}"
|
||||
}
|
||||
}
|
||||
```
|
||||
业务系统需要:**自动提交表单**
|
||||
|
||||
#### 3. JSAPI支付(微信内/支付宝生活号)
|
||||
```json
|
||||
{
|
||||
"type": "jsapi",
|
||||
"appId": "wx1234567890",
|
||||
"timeStamp": "1704067200",
|
||||
"nonceStr": "abc123",
|
||||
"package": "prepay_id=wx1234567890",
|
||||
"signType": "MD5",
|
||||
"paySign": "calculated_signature"
|
||||
}
|
||||
```
|
||||
业务系统需要:**调用微信/支付宝JSAPI**
|
||||
|
||||
#### 4. 二维码支付(PC扫码)
|
||||
```json
|
||||
{
|
||||
"type": "qrcode",
|
||||
"qrcode_url": "https://qr.alipay.com/xxx",
|
||||
"qrcode_data": "data:image/png;base64,..."
|
||||
}
|
||||
```
|
||||
业务系统需要:**展示二维码**
|
||||
|
||||
## 四、用户支付流程
|
||||
|
||||
### 1. 业务系统处理支付参数
|
||||
|
||||
根据 `pay_params.type` 进行不同处理:
|
||||
|
||||
```javascript
|
||||
// 前端处理示例
|
||||
const payParams = response.data.pay_params;
|
||||
|
||||
switch (payParams.type) {
|
||||
case 'redirect':
|
||||
// 跳转支付
|
||||
window.location.href = payParams.url;
|
||||
break;
|
||||
|
||||
case 'form':
|
||||
// 表单提交
|
||||
const form = document.createElement('form');
|
||||
form.method = payParams.method;
|
||||
form.action = payParams.action;
|
||||
Object.keys(payParams.fields).forEach(key => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = payParams.fields[key];
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
break;
|
||||
|
||||
case 'jsapi':
|
||||
// 微信JSAPI支付
|
||||
WeixinJSBridge.invoke('getBrandWCPayRequest', {
|
||||
appId: payParams.appId,
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: payParams.signType,
|
||||
paySign: payParams.paySign
|
||||
}, function(res) {
|
||||
if (res.err_msg === "get_brand_wcpay_request:ok") {
|
||||
// 支付成功
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'qrcode':
|
||||
// 展示二维码
|
||||
document.getElementById('qrcode').src = payParams.qrcode_data;
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户完成支付
|
||||
|
||||
- 用户在第三方支付平台完成支付
|
||||
- 第三方平台异步回调到支付中心
|
||||
|
||||
### 3. 支付中心处理回调
|
||||
|
||||
- 接收回调:`POST /api/notify/alipay` 或 `/api/notify/wechat`
|
||||
- 验签:使用插件验证回调签名
|
||||
- 更新订单状态:`PENDING` → `SUCCESS` 或 `FAIL`
|
||||
- 创建通知任务:异步通知业务系统
|
||||
|
||||
### 4. 业务系统接收通知
|
||||
|
||||
- 支付中心异步通知业务系统的 `notify_url`
|
||||
- 业务系统验证签名并处理订单
|
||||
|
||||
## 五、查询订单接口
|
||||
|
||||
### 接口地址
|
||||
```
|
||||
GET /api/pay/query?pay_order_id=P20240101120000123456
|
||||
```
|
||||
|
||||
### 请求头(需要签名)
|
||||
```
|
||||
X-App-Id: app001
|
||||
X-Timestamp: 1704067200
|
||||
X-Nonce: abc123xyz
|
||||
X-Signature: calculated_signature
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"pay_order_id": "P20240101120000123456",
|
||||
"mch_order_no": "ORDER202401011200001",
|
||||
"status": "SUCCESS",
|
||||
"amount": 100.00,
|
||||
"pay_time": "2024-01-01 12:00:30"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 六、完整调用示例(Node.js)
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
|
||||
class PaymentClient {
|
||||
constructor(appId, appSecret, baseUrl) {
|
||||
this.appId = appId;
|
||||
this.appSecret = appSecret;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
// 计算签名
|
||||
calculateSignature(method, path, body, timestamp, nonce) {
|
||||
const bodySha256 = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
||||
const signString = `app_id=${this.appId}×tamp=${timestamp}&nonce=${nonce}&method=${method}&path=${path}&body_sha256=${bodySha256}`;
|
||||
return crypto.createHmac('sha256', this.appSecret).update(signString).digest('hex');
|
||||
}
|
||||
|
||||
// 统一下单
|
||||
async unifiedOrder(orderData) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
const method = 'POST';
|
||||
const path = '/api/pay/unifiedOrder';
|
||||
|
||||
const signature = this.calculateSignature(method, path, orderData, timestamp, nonce);
|
||||
|
||||
const response = await axios.post(`${this.baseUrl}${path}`, orderData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Id': this.appId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 查询订单
|
||||
async queryOrder(payOrderId) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const nonce = Math.random().toString(36).substring(7);
|
||||
const method = 'GET';
|
||||
const path = `/api/pay/query?pay_order_id=${payOrderId}`;
|
||||
|
||||
const signature = this.calculateSignature(method, path, {}, timestamp, nonce);
|
||||
|
||||
const response = await axios.get(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
'X-App-Id': this.appId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const client = new PaymentClient('app001', 'your_app_secret', 'http://localhost:8787');
|
||||
|
||||
// 统一下单
|
||||
client.unifiedOrder({
|
||||
mch_order_no: 'ORDER202401011200001',
|
||||
pay_method: 'alipay',
|
||||
amount: 100.00,
|
||||
subject: '测试商品',
|
||||
body: '测试商品描述'
|
||||
}).then(result => {
|
||||
console.log('支付参数:', result.data.pay_params);
|
||||
// 根据 pay_params.type 处理支付
|
||||
}).catch(err => {
|
||||
console.error('下单失败:', err.message);
|
||||
});
|
||||
```
|
||||
|
||||
## 七、注意事项
|
||||
|
||||
1. **幂等性**:相同的 `mch_order_no` 多次调用,返回同一订单信息
|
||||
2. **签名有效期**:时间戳5分钟内有效
|
||||
3. **订单过期**:订单默认30分钟过期
|
||||
4. **环境检测**:系统自动根据UA判断环境,选择合适的产品
|
||||
5. **异步通知**:支付成功后,系统会异步通知业务系统的 `notify_url`
|
||||
6. **订单查询**:业务系统可通过查询接口主动查询订单状态
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
# 支付系统核心实现说明
|
||||
|
||||
## 概述
|
||||
|
||||
已实现支付系统核心功能,包括:
|
||||
- 插件化支付通道系统(支持一个插件多个支付方式)
|
||||
- OpenAPI统一支付网关
|
||||
- 通道管理与配置
|
||||
- 订单管理与状态机
|
||||
- 异步通知机制
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
执行以下SQL脚本创建表结构:
|
||||
|
||||
```bash
|
||||
mysql -u用户名 -p 数据库名 < database/mvp_payment_tables.sql
|
||||
```
|
||||
|
||||
## 核心架构
|
||||
|
||||
### 1. 插件系统
|
||||
|
||||
- **插件接口**:`app/common/contracts/PayPluginInterface.php`
|
||||
- **抽象基类**:`app/common/contracts/AbstractPayPlugin.php`(提供环境检测、产品选择等通用功能)
|
||||
- **插件类示例**:`app/common/payment/LakalaPayment.php`(命名规范:`XxxPayment`)
|
||||
- **插件解析**:由 `PayService`、`PayOrderService`、`PluginService` 直接根据 `ma_pay_plugin` 注册表中配置的 `plugin_code` / `class_name` 解析并实例化插件(默认约定类名为 `app\common\payment\{Code}Payment`)
|
||||
|
||||
**插件特点**:
|
||||
- 一个插件可以支持多个支付方式(如拉卡拉插件支持 alipay/wechat/unionpay)
|
||||
- **支付产品由插件内部定义**,不需要数据库字典表
|
||||
- 插件根据用户环境(PC/H5/微信内/支付宝客户端)自动选择已开通的产品
|
||||
- 通道配置中,用户只需勾选确认开启了哪些产品(产品编码由插件定义)
|
||||
- 有些支付平台不区分产品,插件会根据通道配置自行处理
|
||||
- 通道配置表单由插件动态生成
|
||||
|
||||
### 2. 数据模型
|
||||
|
||||
- `Merchant`:商户
|
||||
- `MerchantApp`:商户应用(AppId/AppSecret)
|
||||
- `PayMethod`:支付方式(alipay/wechat等)
|
||||
- `PayChannel`:支付通道(绑定到"插件+支付方式",配置已开通的产品列表)
|
||||
- `PayOrder`:支付订单
|
||||
- `NotifyTask`:商户通知任务
|
||||
|
||||
**注意**:支付产品不由数据库管理,而是由插件通过 `getSupportedProducts()` 方法定义。通道配置中的 `enabled_products` 字段存储的是用户勾选的产品编码数组。
|
||||
|
||||
### 3. 服务层
|
||||
|
||||
- `PayOrderService`:订单业务编排(统一下单、查询)
|
||||
- `ChannelRouterService`:通道路由选择
|
||||
- `NotifyService`:商户通知服务
|
||||
|
||||
### 4. API接口
|
||||
|
||||
#### OpenAPI(对外支付网关)
|
||||
|
||||
- `POST /api/pay/unifiedOrder`:统一下单(需要签名认证)
|
||||
- `GET /api/pay/query`:查询订单(需要签名认证)
|
||||
- `POST /api/notify/alipay`:支付宝回调
|
||||
- `POST /api/notify/wechat`:微信回调
|
||||
|
||||
#### 管理后台API
|
||||
|
||||
- `GET /adminapi/channel/plugins`:获取所有可用插件
|
||||
- `GET /adminapi/channel/plugin/config-schema`:获取插件配置表单Schema
|
||||
- `GET /adminapi/channel/plugin/products`:获取插件支持的支付产品
|
||||
- `GET /adminapi/channel/list`:通道列表
|
||||
- `GET /adminapi/channel/detail`:通道详情
|
||||
- `POST /adminapi/channel/save`:保存通道
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 创建商户和应用
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_merchant (merchant_no, merchant_name, funds_mode, status)
|
||||
VALUES ('M001', '测试商户', 'direct', 1);
|
||||
|
||||
INSERT INTO ma_merchant_app (merchant_id, app_id, app_secret, app_name, notify_url, status)
|
||||
VALUES (1, 'app001', 'secret_key_here', '测试应用', 'https://example.com/notify', 1);
|
||||
```
|
||||
|
||||
### 2. 配置支付通道
|
||||
|
||||
**配置流程**:
|
||||
1. 创建通道:选择支付方式、支付插件,配置通道基本信息(显示名称、分成比例、通道成本、通道模式、限额等)
|
||||
2. 配置插件参数:通道创建后,再配置该通道的插件参数信息(通过插件的配置表单动态生成)
|
||||
|
||||
通过管理后台或直接操作数据库:
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_pay_channel (
|
||||
merchant_id, app_id, channel_code, channel_name,
|
||||
plugin_code, method_code, enabled_products, config_json,
|
||||
split_ratio, channel_cost, channel_mode,
|
||||
daily_limit, daily_count, min_amount, max_amount,
|
||||
status
|
||||
) VALUES (
|
||||
1, 1, 'CH001', '拉卡拉-支付宝通道',
|
||||
'lakala', 'alipay',
|
||||
'["alipay_h5", "alipay_life"]',
|
||||
'{"merchant_id": "lakala_merchant", "secret_key": "xxx", "api_url": "https://api.lakala.com"}',
|
||||
100.00, 0.00, 'wallet',
|
||||
0.00, 0, NULL, NULL,
|
||||
1
|
||||
);
|
||||
```
|
||||
|
||||
**通道字段说明**:
|
||||
- `split_ratio`: 分成比例(%),默认100.00
|
||||
- `channel_cost`: 通道成本(%),默认0.00
|
||||
- `channel_mode`: 通道模式,`wallet`-支付金额扣除手续费后加入商户余额,`direct`-直连到商户
|
||||
- `daily_limit`: 单日限额(元),0表示不限制
|
||||
- `daily_count`: 单日限笔,0表示不限制
|
||||
- `min_amount`: 单笔最小金额(元),NULL表示不限制
|
||||
- `max_amount`: 单笔最大金额(元),NULL表示不限制
|
||||
|
||||
### 3. 调用统一下单接口
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/pay/unifiedOrder \
|
||||
-H "X-App-Id: app001" \
|
||||
-H "X-Timestamp: 1234567890" \
|
||||
-H "X-Nonce: abc123" \
|
||||
-H "X-Signature: calculated_signature" \
|
||||
-d '{
|
||||
"mch_order_no": "ORDER001",
|
||||
"pay_method": "alipay",
|
||||
"amount": 100.00,
|
||||
"subject": "测试订单",
|
||||
"body": "测试订单描述"
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. 签名算法
|
||||
|
||||
```
|
||||
signString = "app_id={app_id}×tamp={timestamp}&nonce={nonce}&method={method}&path={path}&body_sha256={body_sha256}"
|
||||
signature = HMAC-SHA256(signString, app_secret)
|
||||
```
|
||||
|
||||
## 扩展新插件
|
||||
|
||||
1. 创建插件类,继承 `AbstractPayPlugin`,并按照 `XxxPayment` 命名放在 `app/common/payment` 目录:
|
||||
|
||||
```php
|
||||
namespace app\common\payment;
|
||||
|
||||
use app\common\contracts\AbstractPayPlugin;
|
||||
|
||||
class AlipayPayment extends AbstractPayPlugin
|
||||
{
|
||||
public static function getCode(): string { return 'alipay'; }
|
||||
public static function getName(): string { return '支付宝直连'; }
|
||||
public static function getSupportedMethods(): array { return ['alipay']; }
|
||||
// ... 实现其他方法
|
||||
}
|
||||
```
|
||||
|
||||
2. 在 `ma_pay_plugin` 表中注册插件信息(也可通过后台管理界面维护):
|
||||
|
||||
```sql
|
||||
INSERT INTO ma_pay_plugin (plugin_code, plugin_name, class_name, status)
|
||||
VALUES ('alipay', '支付宝直连', 'app\\common\\payment\\AlipayPayment', 1);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **支付产品定义**:支付产品由插件内部通过 `getSupportedProducts()` 方法定义,不需要数据库字典表。通道配置时,用户只需勾选已开通的产品编码。
|
||||
2. **环境检测**:插件基类提供 `detectEnvironment()` 方法,可根据UA判断环境
|
||||
3. **产品选择**:插件根据环境从通道已开通产品中自动选择。如果通道配置为空或不区分产品,插件会根据配置自行处理。
|
||||
4. **通知重试**:使用 `NotifyMerchantJob` 异步重试通知,支持指数退避
|
||||
5. **幂等性**:统一下单接口支持幂等,相同 `mch_order_no` 返回已有订单
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 账务系统(账户、分录、余额)
|
||||
- 结算系统(可结算金额、结算批次、打款)
|
||||
- 对账系统(渠道账单导入、差异处理)
|
||||
- 风控系统(规则引擎、风险预警)
|
||||
|
||||
414
doc/project_overview.md
Normal file
414
doc/project_overview.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# MPay V2 Project Overview
|
||||
|
||||
更新日期:2026-03-13
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个基于 Webman 的多商户支付中台项目,当前主要目标是:
|
||||
|
||||
- 提供后台管理能力,维护商户、应用、支付方式、支付插件、支付通道、订单与系统配置
|
||||
- 为商户应用提供统一支付能力
|
||||
- 当前已优先兼容 `epay` 协议,后续可继续扩展更多外部支付协议
|
||||
- 通过“支付插件 + 通道配置”的方式对接第三方渠道
|
||||
|
||||
结合当前代码与数据库,项目已经具备“多商户 -> 多应用 -> 多通道 -> 多插件”的基础骨架。
|
||||
|
||||
## 2. 技术栈与运行环境
|
||||
|
||||
### 后端技术栈
|
||||
|
||||
- PHP `>= 8.1`
|
||||
- Webman `^2.1`
|
||||
- webman/database
|
||||
- webman/redis
|
||||
- webman/cache
|
||||
- webman/console
|
||||
- webman/captcha
|
||||
- webman/event
|
||||
- webman/redis-queue
|
||||
- firebase/php-jwt
|
||||
- yansongda/pay `~3.7.0`
|
||||
|
||||
### 当前环境配置要点
|
||||
|
||||
- HTTP 服务监听:`0.0.0.0:8787`
|
||||
- 数据库:MySQL
|
||||
- 缓存与队列:Redis
|
||||
- 管理后台认证:JWT
|
||||
- 当前 `.env` 已配置远程 MySQL / Redis 地址,开发前需要确认本机网络可达
|
||||
|
||||
## 3. 当前环境可调用的 MCP 能力
|
||||
|
||||
本次会话中,已确认可以直接用于本项目的 MCP / 环境能力如下:
|
||||
|
||||
### MySQL MCP
|
||||
|
||||
- 可直接执行 SQL
|
||||
- 可读取当前开发库表结构与数据
|
||||
- 已确认能访问 `mpay_admin` 相关表,例如:
|
||||
- `ma_merchant`
|
||||
- `ma_merchant_app`
|
||||
- `ma_pay_channel`
|
||||
- `ma_pay_order`
|
||||
- `ma_notify_task`
|
||||
- `ma_callback_inbox`
|
||||
- `ma_pay_callback_log`
|
||||
|
||||
适合后续继续做:
|
||||
|
||||
- 表结构核对
|
||||
- 初始化数据检查
|
||||
- 回调与订单状态排查
|
||||
- 开发联调时快速确认通道配置
|
||||
|
||||
### Playwright MCP
|
||||
|
||||
- 可进行浏览器打开、点击、表单填写、快照、截图、网络请求分析
|
||||
- 适合后续验证:
|
||||
- 管理后台登录流程
|
||||
- 通道配置页面交互
|
||||
- 提交支付后的跳转页/表单页
|
||||
- 回调相关前端可视流程
|
||||
|
||||
### MCP 资源浏览
|
||||
|
||||
- 可列出 MCP 资源
|
||||
- 可读取资源内容
|
||||
- 当前未返回资源模板
|
||||
|
||||
### 非 MCP 但对开发有用的本地能力
|
||||
|
||||
- Shell 命令执行
|
||||
- 工作区文件读写
|
||||
- 代码补丁编辑
|
||||
|
||||
## 4. 业务模型总览
|
||||
|
||||
### 4.1 商户模型
|
||||
|
||||
- 表:`ma_merchant`
|
||||
- 作用:定义商户主体
|
||||
- 关键字段:
|
||||
- `merchant_no`
|
||||
- `merchant_name`
|
||||
- `funds_mode`
|
||||
- `status`
|
||||
|
||||
### 4.2 商户应用模型
|
||||
|
||||
- 表:`ma_merchant_app`
|
||||
- 作用:商户可创建多个应用,每个应用具备独立 `app_id` / `app_secret`
|
||||
- 关键字段:
|
||||
- `merchant_id`
|
||||
- `api_type`
|
||||
- `app_id`
|
||||
- `app_secret`
|
||||
- `app_name`
|
||||
- `status`
|
||||
|
||||
当前代码中,`app_id` 既是应用标识,也是外部协议鉴权入口;`epay` 兼容链路直接用它作为 `pid`。
|
||||
|
||||
### 4.3 支付方式模型
|
||||
|
||||
- 表:`ma_pay_method`
|
||||
- 作用:维护支付方式字典
|
||||
- 当前库内数据:
|
||||
- `alipay`
|
||||
- `wechat`
|
||||
- `unionpay`
|
||||
|
||||
### 4.4 支付插件模型
|
||||
|
||||
- 表:`ma_pay_plugin`
|
||||
- 作用:把“支付通道配置”与“PHP 插件实现类”解耦
|
||||
- 插件需要同时实现:
|
||||
- `PaymentInterface`
|
||||
- `PayPluginInterface`
|
||||
|
||||
当前代码里已有两个插件类:
|
||||
|
||||
- `app/common/payment/LakalaPayment.php`
|
||||
- `app/common/payment/AlipayPayment.php`
|
||||
|
||||
但当前数据库只注册了 `lakala`,还没有把 `alipay` 作为活动插件注册进现网开发库。
|
||||
|
||||
### 4.5 支付通道模型
|
||||
|
||||
- 表:`ma_pay_channel`
|
||||
- 作用:把“商户应用 + 支付方式 + 插件 + 参数配置”绑定起来
|
||||
- 关键字段:
|
||||
- `merchant_id`
|
||||
- `merchant_app_id`
|
||||
- `plugin_code`
|
||||
- `method_id`
|
||||
- `config_json`
|
||||
- `split_ratio`
|
||||
- `chan_cost`
|
||||
- `chan_mode`
|
||||
- `daily_limit`
|
||||
- `daily_cnt`
|
||||
- `min_amount`
|
||||
- `max_amount`
|
||||
- `status`
|
||||
- `sort`
|
||||
|
||||
这正对应你描述的核心业务特点:一个应用下可配置多个支付通道,每个通道可挂接不同插件与参数。
|
||||
|
||||
### 4.6 支付订单模型
|
||||
|
||||
- 表:`ma_pay_order`
|
||||
- 作用:统一存放系统支付订单
|
||||
- 关键特性:
|
||||
- 系统订单号:`order_id`
|
||||
- 商户订单号:`mch_order_no`
|
||||
- 幂等唯一键:`(merchant_id, merchant_app_id, mch_order_no)`
|
||||
- `extra` JSON 用于存放 `notify_url`、`return_url`、`pay_params`、退款信息等
|
||||
|
||||
### 4.7 回调与通知模型
|
||||
|
||||
- `ma_callback_inbox`:回调幂等收件箱
|
||||
- `ma_pay_callback_log`:回调日志
|
||||
- `ma_notify_task`:商户异步通知任务
|
||||
|
||||
这三张表说明项目已经为“渠道回调幂等 + 日志留痕 + 商户通知补偿”预留了比较完整的基础设施。
|
||||
|
||||
## 5. 代码分层与关键入口
|
||||
|
||||
### 外部接口入口
|
||||
|
||||
- `app/http/api/controller/EpayController.php`
|
||||
- `app/http/api/controller/PayController.php`
|
||||
|
||||
### 支付主流程服务
|
||||
|
||||
- `app/services/api/EpayProtocolService.php`
|
||||
- `app/services/api/EpayService.php`
|
||||
- `app/services/PayService.php`
|
||||
- `app/services/PayOrderService.php`
|
||||
- `app/services/ChannelRouterService.php`
|
||||
- `app/services/PluginService.php`
|
||||
- `app/services/PayNotifyService.php`
|
||||
- `app/services/NotifyService.php`
|
||||
- `app/services/PaymentStateService.php`
|
||||
|
||||
### 支付插件契约
|
||||
|
||||
- `app/common/contracts/PaymentInterface.php`
|
||||
- `app/common/contracts/PayPluginInterface.php`
|
||||
- `app/common/base/BasePayment.php`
|
||||
|
||||
### 管理后台接口
|
||||
|
||||
- 商户:`MerchantController`
|
||||
- 商户应用:`MerchantAppController`
|
||||
- 支付方式:`PayMethodController`
|
||||
- 插件注册:`PayPluginController`
|
||||
- 通道:`ChannelController`
|
||||
- 订单:`OrderController`
|
||||
- 系统配置:`SystemController`
|
||||
- 登录认证:`AuthController`
|
||||
|
||||
## 6. 当前已落地的对外接口
|
||||
|
||||
### 路由现状
|
||||
|
||||
当前 `app/routes/api.php` 实际挂载的对外接口为:
|
||||
|
||||
- `GET|POST /submit.php`
|
||||
- `POST /mapi.php`
|
||||
- `GET /api.php`
|
||||
- `ANY /notify/{pluginCode}`
|
||||
|
||||
### 兼容协议现状
|
||||
|
||||
当前真正已打通的是 `epay` 风格接口:
|
||||
|
||||
- `submit.php`:页面跳转支付
|
||||
- `mapi.php`:API 下单
|
||||
- `api.php?act=order`:查单
|
||||
- `api.php?act=refund`:退款
|
||||
|
||||
### OpenAPI 现状
|
||||
|
||||
`PayController` 中存在以下方法:
|
||||
|
||||
- `create`
|
||||
- `query`
|
||||
- `close`
|
||||
- `refund`
|
||||
|
||||
但当前都还是 `501 not implemented`,并且对应路由尚未挂载,因此“通用 OpenAPI”目前仍是预留骨架,不是已上线能力。
|
||||
|
||||
## 7. 核心支付链路
|
||||
|
||||
### 7.1 Epay 下单链路
|
||||
|
||||
1. 商户调用 `submit.php` 或 `mapi.php`
|
||||
2. `EpayProtocolService` 负责参数提取与校验
|
||||
3. `EpayService` 使用 `app_secret` 做 MD5 验签
|
||||
4. 构造统一内部订单数据
|
||||
5. `PayOrderService` 创建订单,并通过联合唯一键保证幂等
|
||||
6. `ChannelRouterService` 根据 `merchant_id + merchant_app_id + method_id` 选取通道
|
||||
7. `PluginService` 从注册表解析插件类并实例化
|
||||
8. 插件执行 `pay()`
|
||||
9. `PayService` 回写:
|
||||
- `channel_id`
|
||||
- `chan_order_no`
|
||||
- `chan_trade_no`
|
||||
- `fee`
|
||||
- `real_amount`
|
||||
- `extra.pay_params`
|
||||
10. 转换成 `epay` 所需返回结构给调用方
|
||||
|
||||
### 7.2 回调处理链路
|
||||
|
||||
1. 第三方渠道回调 `/notify/{pluginCode}`
|
||||
2. `PayNotifyService` 调插件 `notify()` 验签与解析
|
||||
3. 通过 `ma_callback_inbox` 做幂等去重
|
||||
4. 状态机更新订单状态
|
||||
5. 写入回调日志
|
||||
6. 创建商户通知任务
|
||||
|
||||
### 7.3 商户通知链路
|
||||
|
||||
1. `NotifyService` 根据订单 `extra.notify_url` 创建通知任务
|
||||
2. 通知内容写入 `ma_notify_task`
|
||||
3. `sendNotify()` 使用 HTTP POST JSON 回调商户
|
||||
4. 若商户返回 HTTP 200 且 body 为 `success`,视为通知成功
|
||||
|
||||
## 8. 插件与通道现状
|
||||
|
||||
### `LakalaPayment`
|
||||
|
||||
状态:示例插件 / mock 插件
|
||||
|
||||
现状:
|
||||
|
||||
- `pay()` 已实现,但只是返回模拟二维码字符串
|
||||
- `query()` 未实现
|
||||
- `close()` 未实现
|
||||
- `refund()` 未实现
|
||||
- `notify()` 未实现
|
||||
|
||||
这意味着当前库里虽然已经能“创建订单并拿到拉起参数”,但还不能完成真实的拉卡拉闭环。
|
||||
|
||||
### `AlipayPayment`
|
||||
|
||||
状态:代码层面相对完整
|
||||
|
||||
已实现:
|
||||
|
||||
- `pay()`
|
||||
- `query()`
|
||||
- `close()`
|
||||
- `refund()`
|
||||
- `notify()`
|
||||
|
||||
特点:
|
||||
|
||||
- 基于 `yansongda/pay`
|
||||
- 支持产品类型:
|
||||
- `alipay_web`
|
||||
- `alipay_h5`
|
||||
- `alipay_scan`
|
||||
- `alipay_app`
|
||||
- 可根据环境自动选产品
|
||||
|
||||
注意:
|
||||
|
||||
- 当前开发库没有注册 `alipay` 插件记录
|
||||
- 当前通道也没有指向 `AlipayPayment`
|
||||
|
||||
所以它虽然写在代码里,但当前数据库并没有真正启用它。
|
||||
|
||||
## 9. 管理后台现状
|
||||
|
||||
后台已经覆盖以下核心维护能力:
|
||||
|
||||
- 验证码登录 + JWT 鉴权
|
||||
- 商户管理
|
||||
- 商户应用管理
|
||||
- 支付方式管理
|
||||
- 支付插件注册管理
|
||||
- 支付通道管理
|
||||
- 订单列表 / 详情 / 退款
|
||||
- 系统基础配置管理
|
||||
|
||||
这部分说明“支付中心后台”已经不是空架子,而是可以承接后续运营配置的。
|
||||
|
||||
## 10. 当前开发库快照(基于 2026-03-13 实际查询)
|
||||
|
||||
### 数据量
|
||||
|
||||
- `ma_admin`: 1
|
||||
- `ma_merchant`: 1
|
||||
- `ma_merchant_app`: 1
|
||||
- `ma_pay_method`: 3
|
||||
- `ma_pay_plugin`: 1
|
||||
- `ma_pay_channel`: 2
|
||||
- `ma_pay_order`: 1
|
||||
- `ma_notify_task`: 0
|
||||
- `ma_callback_inbox`: 0
|
||||
- `ma_pay_callback_log`: 0
|
||||
|
||||
### 当前商户与应用
|
||||
|
||||
- 商户:`M001 / 测试商户`
|
||||
- 应用:`1001 / 测试应用-易支付`
|
||||
- 应用类型:`epay`
|
||||
|
||||
### 当前活动插件
|
||||
|
||||
- `lakala -> app\\common\\payment\\LakalaPayment`
|
||||
|
||||
### 当前通道
|
||||
|
||||
- `lakala_alipay`
|
||||
- `lakala_wechat`
|
||||
|
||||
### 当前示例订单
|
||||
|
||||
- 订单号:`P20260312160833644578`
|
||||
- 商户单号:`TEST123`
|
||||
- 状态:`PENDING`
|
||||
- 通道:`channel_id = 1`
|
||||
- `extra.pay_params` 为 mock 二维码
|
||||
|
||||
## 11. 当前代码与需求的对应关系
|
||||
|
||||
你给出的项目特点,与当前实现的对应情况如下:
|
||||
|
||||
### 已匹配的部分
|
||||
|
||||
- 多商户:已支持
|
||||
- 一个商户多个应用:已支持
|
||||
- 一个应用多个支付通道:已支持
|
||||
- 通道可绑定支付方式:已支持
|
||||
- 通道可绑定支付插件:已支持
|
||||
- 通道可存储插件参数:已支持
|
||||
- 通道可配置手续费:已支持,当前会参与 `fee` / `real_amount` 计算
|
||||
- 商户通过 `APPID` 发起支付:已支持,当前主要在 `epay` 兼容链路中落地
|
||||
- 创建订单并调用第三方插件:已支持
|
||||
|
||||
### 仅完成“数据建模”,尚未完全落地执行的部分
|
||||
|
||||
- 每日限额:字段已存在,但当前下单/路由流程未校验
|
||||
- 每日笔数限制:字段已存在,但当前未校验
|
||||
- 最小/最大金额限制:字段已存在,但当前未校验
|
||||
- 更复杂的路由策略:当前仅按 `sort` 取第一条可用通道
|
||||
- 多协议统一 OpenAPI:控制器骨架存在,但未真正接入
|
||||
|
||||
## 12. 后续阅读建议
|
||||
|
||||
如果下一次继续开发,建议优先从以下文件继续进入:
|
||||
|
||||
- 支付入口:`app/http/api/controller/EpayController.php`
|
||||
- 协议适配:`app/services/api/EpayProtocolService.php`
|
||||
- 业务主流程:`app/services/PayService.php`
|
||||
- 订单创建:`app/services/PayOrderService.php`
|
||||
- 回调处理:`app/services/PayNotifyService.php`
|
||||
- 插件管理:`app/services/PluginService.php`
|
||||
- 拉卡拉插件:`app/common/payment/LakalaPayment.php`
|
||||
- 支付宝插件:`app/common/payment/AlipayPayment.php`
|
||||
- 通道配置:`app/http/admin/controller/ChannelController.php`
|
||||
|
||||
320
doc/project_progress.md
Normal file
320
doc/project_progress.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# MPay V2 Development Progress
|
||||
|
||||
更新日期:2026-03-13
|
||||
|
||||
本文档用于记录当前项目完成度、明显缺口和建议推进顺序,方便后续继续开发时快速接手。
|
||||
|
||||
## 1. 当前总体判断
|
||||
|
||||
项目已经完成了“支付中台基础骨架 + 后台配置能力 + Epay 协议首条链路”的主体搭建。
|
||||
|
||||
更准确地说:
|
||||
|
||||
- 数据模型已经比较完整
|
||||
- 后台配置能力已经具备可用性
|
||||
- 支付流程主链路已经跑通到“下单并返回拉起参数”
|
||||
- 真正需要继续补的是“真实渠道闭环、规则执行、异步补偿、通用协议扩展”
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 基础框架与环境
|
||||
|
||||
- Webman 项目骨架已搭建
|
||||
- MySQL / Redis / JWT / Cache / Event / Redis Queue 依赖已接入
|
||||
- 管理后台与 API 路由已拆分
|
||||
|
||||
### 2.2 管理后台能力
|
||||
|
||||
- 验证码登录
|
||||
- JWT 鉴权中间件
|
||||
- 管理员信息查询
|
||||
- 菜单与系统配置读取
|
||||
- 商户 CRUD
|
||||
- 商户应用 CRUD
|
||||
- 支付方式 CRUD
|
||||
- 支付插件注册 CRUD
|
||||
- 支付通道 CRUD
|
||||
- 订单列表 / 详情 / 后台发起退款
|
||||
|
||||
### 2.3 核心支付数据结构
|
||||
|
||||
已建表并落地:
|
||||
|
||||
- 商户
|
||||
- 商户应用
|
||||
- 支付方式
|
||||
- 插件注册
|
||||
- 支付通道
|
||||
- 支付订单
|
||||
- 回调日志
|
||||
- 商户通知任务
|
||||
- 回调幂等收件箱
|
||||
|
||||
### 2.4 下单主链路
|
||||
|
||||
已打通:
|
||||
|
||||
- Epay 参数校验
|
||||
- Epay MD5 验签
|
||||
- 商户应用识别
|
||||
- 幂等订单创建
|
||||
- 通道路由
|
||||
- 插件实例化
|
||||
- 插件下单
|
||||
- 订单回写支付参数
|
||||
- 返回兼容 Epay 的响应结构
|
||||
|
||||
### 2.5 支付状态基础设施
|
||||
|
||||
- 订单状态机服务已存在
|
||||
- 成功 / 失败 / 全额退款关单状态迁移已定义
|
||||
- 回调日志记录能力已存在
|
||||
- 回调幂等收件箱已存在
|
||||
- 商户通知任务创建逻辑已存在
|
||||
|
||||
### 2.6 插件体系
|
||||
|
||||
已建立统一插件契约:
|
||||
|
||||
- `PaymentInterface`
|
||||
- `PayPluginInterface`
|
||||
- `BasePayment`
|
||||
|
||||
说明后续继续接入新渠道时,整体扩展方式已经明确。
|
||||
|
||||
## 3. 部分完成
|
||||
|
||||
### 3.1 Epay 兼容是“主路径”,但还不是“全量兼容”
|
||||
|
||||
当前已实现:
|
||||
|
||||
- `submit.php`
|
||||
- `mapi.php`
|
||||
- `api.php?act=order`
|
||||
- `api.php?act=refund`
|
||||
|
||||
但 `doc/epay.md` 中提到的一些能力,如 `query`、`settle`、`orders` 等,代码中暂未实现。
|
||||
|
||||
### 3.2 支付宝插件代码较完整,但未在当前数据库启用
|
||||
|
||||
现状:
|
||||
|
||||
- `AlipayPayment.php` 已实现
|
||||
- 当前开发库 `ma_pay_plugin` 中只有 `lakala`
|
||||
|
||||
这意味着支付宝更多处于“代码已写好、配置未接入”的状态。
|
||||
|
||||
### 3.3 回调后通知商户的基础逻辑存在,但补偿闭环还不完整
|
||||
|
||||
已完成:
|
||||
|
||||
- 创建通知任务
|
||||
- 发送通知
|
||||
- 失败重试时间计算
|
||||
|
||||
待确认 / 待补齐:
|
||||
|
||||
- 当前没有看到明确的任务投递入口
|
||||
- 也没有看到定时调度 `NotifyMerchantJob` 的配置闭环
|
||||
- `NotifyMerchantJob` 虽然存在,但尚未形成明确的可运行消费链路
|
||||
|
||||
更保守地说,商户通知补偿链路还没有真正闭环。
|
||||
|
||||
## 4. 待完成
|
||||
|
||||
### 4.1 通用 OpenAPI
|
||||
|
||||
当前状态:
|
||||
|
||||
- `PayController` 只有骨架
|
||||
- `create/query/close/refund` 都返回 `501`
|
||||
- `OpenApiAuthMiddleware` 已存在,但未挂到路由
|
||||
|
||||
建议判断:这是下一阶段最适合补完的能力之一。
|
||||
|
||||
### 4.2 拉卡拉真实对接
|
||||
|
||||
当前状态:
|
||||
|
||||
- `LakalaPayment::pay()` 只返回 mock 二维码
|
||||
- `query/close/refund/notify` 全部未实现
|
||||
|
||||
影响:
|
||||
|
||||
- 现在只能用于打通订单创建流程
|
||||
- 还不能进行真实线上支付联调
|
||||
|
||||
### 4.3 通道路由规则执行
|
||||
|
||||
数据库已设计的字段很多,但运行期并未全部生效:
|
||||
|
||||
- `daily_limit` 未校验
|
||||
- `daily_cnt` 未校验
|
||||
- `min_amount` 未校验
|
||||
- `max_amount` 未校验
|
||||
- `split_ratio` 当前只存储,未看到清算分账逻辑
|
||||
- 路由策略目前只是“按排序取第一条可用通道”
|
||||
|
||||
这块是项目从“能下单”走向“可运营”的关键缺口。
|
||||
|
||||
### 4.4 Epay 协议映射细节
|
||||
|
||||
当前内部支付方式代码使用:
|
||||
|
||||
- `alipay`
|
||||
- `wechat`
|
||||
|
||||
但传统 Epay 常见值通常还有:
|
||||
|
||||
- `wxpay`
|
||||
- `qqpay`
|
||||
|
||||
当前代码里没有看到统一别名映射层,说明“协议兼容”仍偏接口形态兼容,而不是完整字段语义兼容。
|
||||
|
||||
### 4.5 插件注册与初始化数据同步
|
||||
|
||||
代码、SQL、数据库现状存在轻微偏差:
|
||||
|
||||
- `database/dev_seed.sql` 里准备了 `alipay` 和 `lakala`
|
||||
- 当前开发库只看到 `lakala`
|
||||
|
||||
建议后续把“代码存在但数据库未启用”的状态统一起来,减少联调歧义。
|
||||
|
||||
### 4.6 通道安全与敏感配置
|
||||
|
||||
当前通道配置直接存在 `config_json` 中,后续建议补充:
|
||||
|
||||
- 敏感字段加密存储
|
||||
- 后台展示脱敏
|
||||
- 配置变更审计日志
|
||||
|
||||
### 4.7 测试体系
|
||||
|
||||
当前仓库里没有看到成体系的:
|
||||
|
||||
- 单元测试
|
||||
- 协议测试
|
||||
- 插件对接测试
|
||||
- 回调幂等测试
|
||||
- 退款回归测试
|
||||
|
||||
这会让后续迭代的回归成本越来越高。
|
||||
|
||||
## 5. 风险与注意点
|
||||
|
||||
### 5.1 当前“多通道”能力更偏配置层,而不是调度层
|
||||
|
||||
虽然表结构和后台已经支持多通道,但运行时路由还比较简单,不能完全体现:
|
||||
|
||||
- 限额控制
|
||||
- 金额区间控制
|
||||
- 通道健康度切换
|
||||
- 优先级与容灾
|
||||
|
||||
### 5.2 退款能力目前偏基础版
|
||||
|
||||
当前退款服务已存在,但从实现上看:
|
||||
|
||||
- 更适合单次退款 / 全额退款
|
||||
- 全额退款后直接把订单关闭
|
||||
- 没有独立退款单模型
|
||||
- 没有完整的部分退款累计能力
|
||||
|
||||
### 5.3 回调成功后的订单与通知一致性要继续加强
|
||||
|
||||
当前已经有:
|
||||
|
||||
- 幂等收件箱
|
||||
- 状态机
|
||||
- 通知任务表
|
||||
|
||||
这是很好的基础。
|
||||
|
||||
但真正生产级还建议再补:
|
||||
|
||||
- 事务边界说明
|
||||
- 异常重放工具
|
||||
- 回调人工补单工具
|
||||
- 通知签名
|
||||
|
||||
## 6. 建议优先级
|
||||
|
||||
### P0:优先补完,直接影响可用性
|
||||
|
||||
1. 实现真实渠道插件,至少先补完一个可联调通道
|
||||
2. 补完 OpenAPI 主链路
|
||||
3. 在路由阶段执行金额限制 / 限额 / 笔数规则
|
||||
4. 打通商户通知任务的实际调度与重试闭环
|
||||
|
||||
### P1:补齐可运营能力
|
||||
|
||||
1. 增加支付方式别名映射,提升 Epay 兼容度
|
||||
2. 把 `AlipayPayment` 正式接入插件注册与通道配置
|
||||
3. 增加后台对通道能力、产品、环境的可视化说明
|
||||
4. 增加日志检索与问题排查手段
|
||||
|
||||
### P2:走向平台化
|
||||
|
||||
1. 增加更多协议兼容层
|
||||
2. 增加清算 / 分账 / 对账
|
||||
3. 增加风控规则
|
||||
4. 增加监控、告警、报表
|
||||
|
||||
## 7. 建议后续开发方向
|
||||
|
||||
### 方向一:先做“一个真实可用通道”
|
||||
|
||||
建议优先把某一个通道做成完整闭环:
|
||||
|
||||
- 下单
|
||||
- 回调
|
||||
- 查单
|
||||
- 关单
|
||||
- 退款
|
||||
|
||||
这样项目就能从“框架完成”升级为“真实可上线联调”。
|
||||
|
||||
### 方向二:补通用 OpenAPI
|
||||
|
||||
原因:
|
||||
|
||||
- 你已经明确后续可能兼容更多接口
|
||||
- 当前通用控制器和鉴权中间件已经有雏形
|
||||
- 补完之后,项目会从“单协议适配器”升级为“统一支付网关”
|
||||
|
||||
### 方向三:把通道路由做成真正的策略引擎
|
||||
|
||||
建议把下面这些字段从“仅存储”升级为“真实执行”:
|
||||
|
||||
- 金额范围
|
||||
- 单日限额
|
||||
- 单日限笔
|
||||
- 通道优先级
|
||||
- 通道健康状态
|
||||
- 权重或降级策略
|
||||
|
||||
### 方向四:补测试与排障工具
|
||||
|
||||
优先建议增加:
|
||||
|
||||
- 下单幂等测试
|
||||
- 回调幂等测试
|
||||
- 退款状态测试
|
||||
- 协议字段兼容测试
|
||||
- 一键重发通知工具
|
||||
|
||||
## 8. 推荐继续开发顺序
|
||||
|
||||
如果下一次直接继续往下做,我建议按这个顺序推进:
|
||||
|
||||
1. 选定一个真实渠道作为首个闭环目标
|
||||
2. 补完该插件的 `notify/query/refund/close`
|
||||
3. 接入并验证商户通知补偿链路
|
||||
4. 在 `ChannelRouterService` 前后补齐通道规则校验
|
||||
5. 正式实现 `PayController`
|
||||
6. 抽象协议适配层,准备支持更多接口
|
||||
7. 增加测试与后台排障能力
|
||||
|
||||
## 9. 当前一句话结论
|
||||
|
||||
这是一个“骨架已经成型、第一条协议已打通、非常适合继续往生产级推进”的支付中台项目;下一阶段的重点不是重写,而是把已有设计真正补成闭环。
|
||||
Reference in New Issue
Block a user