feat(admin): 统一后台 API 资源鉴权并完善投注风控快照与回补
This commit is contained in:
22
README.md
22
README.md
@@ -69,6 +69,28 @@ php artisan schedule:work
|
|||||||
|
|
||||||
只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS,则 **三项都开**。
|
只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS,则 **三项都开**。
|
||||||
|
|
||||||
|
## 后台权限体检
|
||||||
|
|
||||||
|
后台权限现在提供了一条可直接接入 CI 的体检命令,用来检查:
|
||||||
|
|
||||||
|
- 受保护后台路由是否都已登记到 `admin_api_resources`
|
||||||
|
- `permission_required` 资源是否已绑定 `admin_api_resource_bindings`
|
||||||
|
- `admin_role_menu_actions` 与 `admin_role_api_resources` 是否漂移
|
||||||
|
|
||||||
|
本地可直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan lottery:admin-auth-audit
|
||||||
|
```
|
||||||
|
|
||||||
|
或通过 Composer 脚本执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer test:admin-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
如果后续接 GitHub Actions、GitLab CI 或其他流水线,直接复用 `composer test:admin-auth` 即可。
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|||||||
230
app/Console/Commands/AuditAdminAuthorizationCommand.php
Normal file
230
app/Console/Commands/AuditAdminAuthorizationCommand.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Routing\Route as IlluminateRoute;
|
||||||
|
|
||||||
|
final class AuditAdminAuthorizationCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'lottery:admin-auth-audit
|
||||||
|
{--skip-route-coverage : 跳过受保护后台路由是否已注册 API 资源的检查}
|
||||||
|
{--skip-resource-bindings : 跳过 permission_required 资源是否绑定动作权限的检查}
|
||||||
|
{--skip-role-resource-sync : 跳过 role_menu_actions 与 role_api_resources 一致性检查}';
|
||||||
|
|
||||||
|
protected $description = '检查后台权限配置是否存在路由覆盖缺失、资源绑定缺失或角色资源漂移';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$issues = [];
|
||||||
|
|
||||||
|
if (! (bool) $this->option('skip-route-coverage')) {
|
||||||
|
$issues = array_merge($issues, $this->checkProtectedAdminRouteCoverage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $this->option('skip-resource-bindings')) {
|
||||||
|
$issues = array_merge($issues, $this->checkPermissionResourceBindings());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $this->option('skip-role-resource-sync')) {
|
||||||
|
$issues = array_merge($issues, $this->checkRoleApiResourceSync());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($issues === []) {
|
||||||
|
$this->info('Admin authorization audit passed.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->error(sprintf('Admin authorization audit found %d issue(s).', count($issues)));
|
||||||
|
foreach ($issues as $issue) {
|
||||||
|
$this->line(sprintf('- [%s] %s', $issue['type'], $issue['message']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{type: string, message: string}>
|
||||||
|
*/
|
||||||
|
private function checkProtectedAdminRouteCoverage(): array
|
||||||
|
{
|
||||||
|
$protectedRoutes = $this->protectedAdminRoutes();
|
||||||
|
if ($protectedRoutes === []) {
|
||||||
|
return [[
|
||||||
|
'type' => 'route_coverage',
|
||||||
|
'message' => 'No protected admin routes were discovered from the current route collection.',
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resourceRouteNames = DB::table('admin_api_resources')
|
||||||
|
->where('status', 1)
|
||||||
|
->pluck('route_name')
|
||||||
|
->filter(static fn ($routeName): bool => is_string($routeName) && $routeName !== '')
|
||||||
|
->map(fn (string $routeName): string => $this->normalizeAdminRouteName($routeName))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$resourceRouteNameSet = array_fill_keys($resourceRouteNames, true);
|
||||||
|
$issues = [];
|
||||||
|
|
||||||
|
foreach ($protectedRoutes as $route) {
|
||||||
|
if (! isset($resourceRouteNameSet[$route['name']])) {
|
||||||
|
$issues[] = [
|
||||||
|
'type' => 'route_coverage',
|
||||||
|
'message' => sprintf(
|
||||||
|
'Protected route `%s %s` (`%s`) has no active row in `admin_api_resources`.',
|
||||||
|
$route['method'],
|
||||||
|
$route['uri'],
|
||||||
|
$route['name'],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{type: string, message: string}>
|
||||||
|
*/
|
||||||
|
private function checkPermissionResourceBindings(): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('admin_api_resources as ar')
|
||||||
|
->leftJoin('admin_api_resource_bindings as arb', 'arb.api_resource_id', '=', 'ar.id')
|
||||||
|
->select('ar.code', 'ar.route_name', DB::raw('count(arb.id) as binding_count'))
|
||||||
|
->where('ar.status', 1)
|
||||||
|
->where('ar.auth_mode', 'permission_required')
|
||||||
|
->groupBy('ar.id', 'ar.code', 'ar.route_name')
|
||||||
|
->havingRaw('count(arb.id) = 0')
|
||||||
|
->orderBy('ar.code')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$issues = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$issues[] = [
|
||||||
|
'type' => 'resource_bindings',
|
||||||
|
'message' => sprintf(
|
||||||
|
'API resource `%s` (`%s`) requires permission but has no row in `admin_api_resource_bindings`.',
|
||||||
|
(string) $row->code,
|
||||||
|
(string) $row->route_name,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{type: string, message: string}>
|
||||||
|
*/
|
||||||
|
private function checkRoleApiResourceSync(): array
|
||||||
|
{
|
||||||
|
$expectedRows = DB::table('admin_role_menu_actions as rma')
|
||||||
|
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||||
|
->select('rma.role_id', 'arb.api_resource_id')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$expectedSet = [];
|
||||||
|
foreach ($expectedRows as $row) {
|
||||||
|
$expectedSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actualRows = DB::table('admin_role_api_resources')
|
||||||
|
->select('role_id', 'api_resource_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$actualSet = [];
|
||||||
|
foreach ($actualRows as $row) {
|
||||||
|
$actualSet[$this->roleApiKey((int) $row->role_id, (int) $row->api_resource_id)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roleSlugs = DB::table('admin_roles')->pluck('slug', 'id')->all();
|
||||||
|
$resourceCodes = DB::table('admin_api_resources')->pluck('code', 'id')->all();
|
||||||
|
$issues = [];
|
||||||
|
|
||||||
|
foreach (array_keys($expectedSet) as $key) {
|
||||||
|
if (isset($actualSet[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
|
||||||
|
$issues[] = [
|
||||||
|
'type' => 'role_resource_sync',
|
||||||
|
'message' => sprintf(
|
||||||
|
'Missing role-resource grant: role `%s` should include API resource `%s`.',
|
||||||
|
(string) ($roleSlugs[$roleId] ?? $roleId),
|
||||||
|
(string) ($resourceCodes[$resourceId] ?? $resourceId),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($actualSet) as $key) {
|
||||||
|
if (isset($expectedSet[$key])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$roleId, $resourceId] = array_map('intval', explode(':', $key, 2));
|
||||||
|
$issues[] = [
|
||||||
|
'type' => 'role_resource_sync',
|
||||||
|
'message' => sprintf(
|
||||||
|
'Extra role-resource grant: role `%s` has API resource `%s` without any supporting action binding.',
|
||||||
|
(string) ($roleSlugs[$roleId] ?? $roleId),
|
||||||
|
(string) ($resourceCodes[$resourceId] ?? $resourceId),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{name: string, method: string, uri: string}>
|
||||||
|
*/
|
||||||
|
private function protectedAdminRoutes(): array
|
||||||
|
{
|
||||||
|
$routes = [];
|
||||||
|
|
||||||
|
/** @var IlluminateRoute $route */
|
||||||
|
foreach (Route::getRoutes() as $route) {
|
||||||
|
$middleware = $route->gatherMiddleware();
|
||||||
|
if (! in_array('lottery.admin', $middleware, true)
|
||||||
|
&& ! in_array('App\Http\Middleware\EnsureAdminApi', $middleware, true)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $route->getName();
|
||||||
|
if (! is_string($name) || $name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$methods = array_values(array_filter(
|
||||||
|
$route->methods(),
|
||||||
|
static fn (string $method): bool => $method !== 'HEAD',
|
||||||
|
));
|
||||||
|
|
||||||
|
$routes[] = [
|
||||||
|
'name' => $this->normalizeAdminRouteName($name),
|
||||||
|
'method' => $methods[0] ?? 'GET',
|
||||||
|
'uri' => '/'.ltrim($route->uri(), '/'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($routes, static fn (array $a, array $b): int => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]);
|
||||||
|
|
||||||
|
return $routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAdminRouteName(string $routeName): string
|
||||||
|
{
|
||||||
|
return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function roleApiKey(int $roleId, int $apiResourceId): string
|
||||||
|
{
|
||||||
|
return $roleId.':'.$apiResourceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ final class AdminPlayerTicketItemsIndexController extends Controller
|
|||||||
])
|
])
|
||||||
->orderByDesc('ticket_items.id');
|
->orderByDesc('ticket_items.id');
|
||||||
|
|
||||||
if ($drawNo !== '') {
|
if (is_string($drawNo) && $drawNo !== '') {
|
||||||
$query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo));
|
$query->whereHas('draw', fn ($q) => $q->where('draw_no', $drawNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
app/Http/Middleware/EnsureAdminApiResourcePermission.php
Normal file
88
app/Http/Middleware/EnsureAdminApiResourcePermission.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class EnsureAdminApiResourcePermission
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
if (! $admin instanceof AdminUser) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
trans('admin.unauthenticated', [], $request->lotteryLocale()),
|
||||||
|
ErrorCode::AdminUnauthenticated->value,
|
||||||
|
null,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$route = $request->route();
|
||||||
|
$routeName = is_object($route) ? $route->getName() : null;
|
||||||
|
if (! is_string($routeName) || $routeName === '') {
|
||||||
|
return ApiResponse::error('后台路由缺少 route name,无法执行资源鉴权。', ErrorCode::InternalError->value, null, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedRouteName = $this->normalizeAdminRouteName($routeName);
|
||||||
|
$resource = DB::table('admin_api_resources')
|
||||||
|
->where('route_name', $normalizedRouteName)
|
||||||
|
->where('status', 1)
|
||||||
|
->first(['id', 'code', 'auth_mode']);
|
||||||
|
|
||||||
|
if ($resource === null) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
sprintf('后台 API 资源未配置:%s', $normalizedRouteName),
|
||||||
|
ErrorCode::InternalError->value,
|
||||||
|
['route_name' => $normalizedRouteName],
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resource->auth_mode === 'login_only') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionCodes = DB::table('admin_api_resource_bindings as arb')
|
||||||
|
->join('admin_menu_actions as ma', 'ma.id', '=', 'arb.menu_action_id')
|
||||||
|
->where('arb.api_resource_id', (int) $resource->id)
|
||||||
|
->where('ma.status', 1)
|
||||||
|
->pluck('ma.permission_code')
|
||||||
|
->filter(static fn ($code): bool => is_string($code) && $code !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($permissionCodes === []) {
|
||||||
|
return ApiResponse::error(
|
||||||
|
sprintf('后台 API 资源未绑定权限动作:%s', (string) $resource->code),
|
||||||
|
ErrorCode::InternalError->value,
|
||||||
|
['resource_code' => $resource->code],
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($permissionCodes as $permissionCode) {
|
||||||
|
if ($admin->hasAdminPermission((string) $permissionCode)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse::error(
|
||||||
|
trans('admin.permission_denied', [], $request->lotteryLocale()),
|
||||||
|
ErrorCode::AdminForbidden->value,
|
||||||
|
['required_any_codes' => $permissionCodes, 'resource_code' => $resource->code],
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAdminRouteName(string $routeName): string
|
||||||
|
{
|
||||||
|
return preg_replace('/^(api\.v1\.admin\.)+/', 'api.v1.admin.', $routeName) ?? $routeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ final class TicketOrder extends Model
|
|||||||
'status',
|
'status',
|
||||||
'submit_source',
|
'submit_source',
|
||||||
'client_trace_id',
|
'client_trace_id',
|
||||||
|
'play_config_version_no',
|
||||||
|
'odds_version_no',
|
||||||
|
'risk_cap_version_no',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -32,6 +35,9 @@ final class TicketOrder extends Model
|
|||||||
'total_rebate_amount' => 'integer',
|
'total_rebate_amount' => 'integer',
|
||||||
'total_actual_deduct' => 'integer',
|
'total_actual_deduct' => 'integer',
|
||||||
'total_estimated_payout' => 'integer',
|
'total_estimated_payout' => 'integer',
|
||||||
|
'play_config_version_no' => 'integer',
|
||||||
|
'odds_version_no' => 'integer',
|
||||||
|
'risk_cap_version_no' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ final class PlayCatalogResolver
|
|||||||
* 下注事务内:按固定顺序锁住当前生效的三套配置版本,与后台切版互斥;可选与预览戳比对。
|
* 下注事务内:按固定顺序锁住当前生效的三套配置版本,与后台切版互斥;可选与预览戳比对。
|
||||||
*
|
*
|
||||||
* @param array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}|null $expectedFromPreview
|
* @param array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}|null $expectedFromPreview
|
||||||
|
* @return array{play_config_version_no: int, odds_version_no: int, risk_cap_version_no: int}
|
||||||
*/
|
*/
|
||||||
public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): void
|
public function lockActiveConfigVersionsForPlacement(?array $expectedFromPreview = null): array
|
||||||
{
|
{
|
||||||
$playV = PlayConfigVersion::query()
|
$playV = PlayConfigVersion::query()
|
||||||
->where('status', ConfigVersionStatus::Active->value)
|
->where('status', ConfigVersionStatus::Active->value)
|
||||||
@@ -76,6 +77,12 @@ final class PlayCatalogResolver
|
|||||||
throw new TicketOperationException('config_version_stale', ErrorCode::BetConfigStale->value);
|
throw new TicketOperationException('config_version_stale', ErrorCode::BetConfigStale->value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'play_config_version_no' => (int) $playV->version_no,
|
||||||
|
'odds_version_no' => (int) $oddsV->version_no,
|
||||||
|
'risk_cap_version_no' => (int) $riskV->version_no,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ final class RiskPoolService
|
|||||||
$pool = $this->firstOrMakePool($drawId, $number4d);
|
$pool = $this->firstOrMakePool($drawId, $number4d);
|
||||||
$key = $this->redisPoolKey($drawId, $number4d);
|
$key = $this->redisPoolKey($drawId, $number4d);
|
||||||
|
|
||||||
Redis::eval($this->initLua(), 1, $key, (int) $pool->total_cap_amount, (int) $pool->locked_amount);
|
Redis::eval($this->initLua(), 1, $key, (int) $pool->total_cap_amount, (int) $pool->locked_amount, (int) $pool->version);
|
||||||
|
|
||||||
$result = (int) Redis::eval($this->acquireLua(), 1, $key, $amount);
|
$result = $this->normalizeLuaResult(Redis::eval($this->acquireLua(), 1, $key, $amount, (int) $pool->version));
|
||||||
if ($result !== 1) {
|
if (($result['code'] ?? null) !== 'OK') {
|
||||||
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ final class RiskPoolService
|
|||||||
{
|
{
|
||||||
return <<<'LUA'
|
return <<<'LUA'
|
||||||
if redis.call('EXISTS', KEYS[1]) == 0 then
|
if redis.call('EXISTS', KEYS[1]) == 0 then
|
||||||
redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[1] - ARGV[2])
|
redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[1] - ARGV[2], 'version', ARGV[3])
|
||||||
end
|
end
|
||||||
return 1
|
return 1
|
||||||
LUA;
|
LUA;
|
||||||
@@ -200,13 +200,25 @@ LUA;
|
|||||||
{
|
{
|
||||||
return <<<'LUA'
|
return <<<'LUA'
|
||||||
local amount = tonumber(ARGV[1])
|
local amount = tonumber(ARGV[1])
|
||||||
|
local expectedVersion = tonumber(ARGV[2])
|
||||||
|
if amount == nil or amount <= 0 then
|
||||||
|
return {'INVALID_ARGUMENT', 0, 0, 0}
|
||||||
|
end
|
||||||
|
if redis.call('EXISTS', KEYS[1]) == 0 then
|
||||||
|
return {'POOL_NOT_INITIALIZED', 0, 0, 0}
|
||||||
|
end
|
||||||
|
local version = tonumber(redis.call('HGET', KEYS[1], 'version') or '0')
|
||||||
|
if expectedVersion ~= nil and version ~= expectedVersion then
|
||||||
|
return {'VERSION_CONFLICT', tonumber(redis.call('HGET', KEYS[1], 'remaining') or '0'), tonumber(redis.call('HGET', KEYS[1], 'locked') or '0'), version}
|
||||||
|
end
|
||||||
local remaining = tonumber(redis.call('HGET', KEYS[1], 'remaining') or '0')
|
local remaining = tonumber(redis.call('HGET', KEYS[1], 'remaining') or '0')
|
||||||
if remaining < amount then
|
if remaining < amount then
|
||||||
return 0
|
return {'INSUFFICIENT_CAP', remaining, tonumber(redis.call('HGET', KEYS[1], 'locked') or '0'), version}
|
||||||
end
|
end
|
||||||
redis.call('HINCRBY', KEYS[1], 'locked', amount)
|
local locked = redis.call('HINCRBY', KEYS[1], 'locked', amount)
|
||||||
redis.call('HINCRBY', KEYS[1], 'remaining', -amount)
|
remaining = redis.call('HINCRBY', KEYS[1], 'remaining', -amount)
|
||||||
return 1
|
version = redis.call('HINCRBY', KEYS[1], 'version', 1)
|
||||||
|
return {'OK', remaining, locked, version}
|
||||||
LUA;
|
LUA;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +277,28 @@ LUA;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code:string, remaining:int, locked:int, version:int}
|
||||||
|
*/
|
||||||
|
private function normalizeLuaResult(mixed $result): array
|
||||||
|
{
|
||||||
|
if (! is_array($result)) {
|
||||||
|
return [
|
||||||
|
'code' => (int) $result === 1 ? 'OK' : 'INSUFFICIENT_CAP',
|
||||||
|
'remaining' => 0,
|
||||||
|
'locked' => 0,
|
||||||
|
'version' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => (string) ($result[0] ?? 'INSUFFICIENT_CAP'),
|
||||||
|
'remaining' => (int) ($result[1] ?? 0),
|
||||||
|
'locked' => (int) ($result[2] ?? 0),
|
||||||
|
'version' => (int) ($result[3] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array{number_4d:string, amount:int}> $locks
|
* @param list<array{number_4d:string, amount:int}> $locks
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Draw;
|
|||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Lottery\ErrorCode;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
|
use App\Models\WalletTxn;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
@@ -31,6 +32,22 @@ final class TicketPlacementService
|
|||||||
public function place(Player $player, array $payload): array
|
public function place(Player $player, array $payload): array
|
||||||
{
|
{
|
||||||
$currencyCode = strtoupper((string) $payload['currency_code']);
|
$currencyCode = strtoupper((string) $payload['currency_code']);
|
||||||
|
$clientTraceId = isset($payload['client_trace_id']) && $payload['client_trace_id'] !== ''
|
||||||
|
? (string) $payload['client_trace_id']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($clientTraceId !== null) {
|
||||||
|
$existing = TicketOrder::query()
|
||||||
|
->where('player_id', $player->id)
|
||||||
|
->where('client_trace_id', $clientTraceId)
|
||||||
|
->whereIn('status', ['placed', 'partial_failed'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $this->responseForOrder($existing, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$expectedVersions = $payload['expected_config_versions'] ?? null;
|
$expectedVersions = $payload['expected_config_versions'] ?? null;
|
||||||
if (is_array($expectedVersions)) {
|
if (is_array($expectedVersions)) {
|
||||||
$expectedVersions = [
|
$expectedVersions = [
|
||||||
@@ -59,7 +76,7 @@ final class TicketPlacementService
|
|||||||
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
|
$configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions);
|
||||||
|
|
||||||
$evaluatedLines = [];
|
$evaluatedLines = [];
|
||||||
$totalBet = 0;
|
$totalBet = 0;
|
||||||
@@ -137,6 +154,9 @@ final class TicketPlacementService
|
|||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'submit_source' => 'h5',
|
'submit_source' => 'h5',
|
||||||
'client_trace_id' => $payload['client_trace_id'] ?? null,
|
'client_trace_id' => $payload['client_trace_id'] ?? null,
|
||||||
|
'play_config_version_no' => $configVersions['play_config_version_no'],
|
||||||
|
'odds_version_no' => $configVersions['odds_version_no'],
|
||||||
|
'risk_cap_version_no' => $configVersions['risk_cap_version_no'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$successfulItems = [];
|
$successfulItems = [];
|
||||||
@@ -297,6 +317,7 @@ final class TicketPlacementService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$order->forceFill(['status' => 'refunded'])->save();
|
$order->forceFill(['status' => 'refunded'])->save();
|
||||||
|
$this->ticketWalletService->reverseBetDeduct($order);
|
||||||
});
|
});
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
@@ -304,6 +325,24 @@ final class TicketPlacementService
|
|||||||
|
|
||||||
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
||||||
|
|
||||||
|
return $this->responseForOrder($order, $balanceAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function responseForOrder(TicketOrder $order, ?int $balanceAfter): array
|
||||||
|
{
|
||||||
|
$order = TicketOrder::query()->whereKey($order->id)->firstOrFail();
|
||||||
|
$draw = Draw::query()->whereKey((int) $order->draw_id)->firstOrFail();
|
||||||
|
$successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'success')->count();
|
||||||
|
$failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count();
|
||||||
|
if ($balanceAfter === null) {
|
||||||
|
$walletTxn = WalletTxn::query()
|
||||||
|
->where('biz_type', 'bet_deduct')
|
||||||
|
->where('biz_no', $order->order_no)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
$balanceAfter = $walletTxn === null ? null : (int) $walletTxn->balance_after;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'order_no' => $order->order_no,
|
'order_no' => $order->order_no,
|
||||||
'draw' => [
|
'draw' => [
|
||||||
@@ -315,8 +354,8 @@ final class TicketPlacementService
|
|||||||
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
'total_rebate_amount' => (int) $order->total_rebate_amount,
|
||||||
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
'total_actual_deduct' => (int) $order->total_actual_deduct,
|
||||||
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
'total_estimated_payout' => (int) $order->total_estimated_payout,
|
||||||
'success_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'success')->count(),
|
'success_count' => $successCount,
|
||||||
'failure_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(),
|
'failure_count' => $failureCount,
|
||||||
],
|
],
|
||||||
'balance_after' => $balanceAfter,
|
'balance_after' => $balanceAfter,
|
||||||
'items' => TicketItem::query()
|
'items' => TicketItem::query()
|
||||||
|
|||||||
@@ -69,6 +69,53 @@ final class TicketWalletService
|
|||||||
return $after;
|
return $after;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reverseBetDeduct(TicketOrder $order): void
|
||||||
|
{
|
||||||
|
$deductTxn = WalletTxn::query()
|
||||||
|
->where('biz_type', 'bet_deduct')
|
||||||
|
->where('biz_no', $order->order_no)
|
||||||
|
->where('status', self::TXN_POSTED)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($deductTxn === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$idempotentKey = 'bet-reverse:'.$order->order_no;
|
||||||
|
if (WalletTxn::query()->where('biz_type', 'bet_reverse')->where('idempotent_key', $idempotentKey)->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$wallet = PlayerWallet::query()
|
||||||
|
->whereKey($deductTxn->wallet_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$amount = (int) $deductTxn->amount;
|
||||||
|
$before = (int) $wallet->balance;
|
||||||
|
$after = $before + $amount;
|
||||||
|
$wallet->forceFill([
|
||||||
|
'balance' => $after,
|
||||||
|
'version' => (int) $wallet->version + 1,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
WalletTxn::query()->create([
|
||||||
|
'txn_no' => $this->newTxnNo(),
|
||||||
|
'player_id' => (int) $deductTxn->player_id,
|
||||||
|
'wallet_id' => (int) $deductTxn->wallet_id,
|
||||||
|
'biz_type' => 'bet_reverse',
|
||||||
|
'biz_no' => $order->order_no,
|
||||||
|
'direction' => self::TXN_DIR_IN,
|
||||||
|
'amount' => $amount,
|
||||||
|
'balance_before' => $before,
|
||||||
|
'balance_after' => $after,
|
||||||
|
'status' => self::TXN_POSTED,
|
||||||
|
'external_ref_no' => null,
|
||||||
|
'idempotent_key' => $idempotentKey,
|
||||||
|
'remark' => 'post_deduct_confirmation_failed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。
|
* 结算派彩入账(产品文档:派彩写入钱包流水;幂等键按结算批次 + 玩家)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
167
app/Support/AdminApiResourceCatalog.php
Normal file
167
app/Support/AdminApiResourceCatalog.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
final class AdminApiResourceCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* code: string,
|
||||||
|
* module_code: string,
|
||||||
|
* name: string,
|
||||||
|
* http_method: string,
|
||||||
|
* uri_pattern: string,
|
||||||
|
* route_name: string,
|
||||||
|
* auth_mode: string,
|
||||||
|
* is_audit_required: bool,
|
||||||
|
* permission_codes: list<string>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function resources(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $resource): array => [
|
||||||
|
'code' => $resource['code'],
|
||||||
|
'module_code' => $resource['module_code'],
|
||||||
|
'name' => $resource['name'],
|
||||||
|
'http_method' => $resource['http_method'],
|
||||||
|
'uri_pattern' => $resource['uri_pattern'],
|
||||||
|
'route_name' => $resource['route_name'],
|
||||||
|
'auth_mode' => $resource['auth_mode'],
|
||||||
|
'is_audit_required' => $resource['is_audit_required'],
|
||||||
|
'permission_codes' => self::permissionCodesForLegacySlugs($resource['legacy_permission_slugs'] ?? []),
|
||||||
|
],
|
||||||
|
self::definitions(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $legacySlugs
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function permissionCodesForLegacySlugs(array $legacySlugs): array
|
||||||
|
{
|
||||||
|
/** @var array<string, list<string>> $legacyMap */
|
||||||
|
$legacyMap = config('admin_permissions.legacy_map', []);
|
||||||
|
$codes = [];
|
||||||
|
foreach ($legacySlugs as $legacySlug) {
|
||||||
|
foreach (($legacyMap[$legacySlug] ?? []) as $permissionCode) {
|
||||||
|
if (is_string($permissionCode) && $permissionCode !== '') {
|
||||||
|
$codes[$permissionCode] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* code: string,
|
||||||
|
* module_code: string,
|
||||||
|
* name: string,
|
||||||
|
* http_method: string,
|
||||||
|
* uri_pattern: string,
|
||||||
|
* route_name: string,
|
||||||
|
* auth_mode: string,
|
||||||
|
* is_audit_required: bool,
|
||||||
|
* legacy_permission_slugs?: list<string>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private static function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['code' => 'admin.ping', 'module_code' => 'system', 'name' => '后台连通性探测', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/ping', 'route_name' => 'api.v1.admin.ping', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||||
|
['code' => 'admin.dashboard', 'module_code' => 'dashboard', 'name' => '后台仪表盘', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/dashboard', 'route_name' => 'api.v1.admin.dashboard', 'auth_mode' => 'login_only', 'is_audit_required' => false],
|
||||||
|
['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
|
||||||
|
|
||||||
|
['code' => 'admin.admin-users.index', 'module_code' => 'system', 'name' => '管理员列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.store', 'module_code' => 'system', 'name' => '创建管理员', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/admin-users', 'route_name' => 'api.v1.admin.admin-users.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.show', 'module_code' => 'system', 'name' => '管理员详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.update', 'module_code' => 'system', 'name' => '更新管理员', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.destroy', 'module_code' => 'system', 'name' => '删除管理员', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}', 'route_name' => 'api.v1.admin.admin-users.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.permission-catalog', 'module_code' => 'system', 'name' => '管理员权限目录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/admin-user-permission-catalog', 'route_name' => 'api.v1.admin.admin-users.permission-catalog', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.permissions.sync', 'module_code' => 'system', 'name' => '管理员权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/permissions', 'route_name' => 'api.v1.admin.admin-users.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
['code' => 'admin.admin-users.roles.sync', 'module_code' => 'system', 'name' => '管理员角色同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-users/{admin_user}/roles', 'route_name' => 'api.v1.admin.admin-users.roles.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_user.manage']],
|
||||||
|
|
||||||
|
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']],
|
||||||
|
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.index', 'module_code' => 'config', 'name' => '玩法版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.show', 'module_code' => 'config', 'name' => '玩法版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.store', 'module_code' => 'config', 'name' => '创建玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions', 'route_name' => 'api.v1.admin.config.play-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.items.replace', 'module_code' => 'config', 'name' => '替换玩法版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/items', 'route_name' => 'api.v1.admin.config.play-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.publish', 'module_code' => 'config', 'name' => '发布玩法版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.play-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.play-versions.destroy', 'module_code' => 'config', 'name' => '删除玩法版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/play-versions/{id}', 'route_name' => 'api.v1.admin.config.play-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.odds-versions.index', 'module_code' => 'config', 'name' => '赔率版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
|
||||||
|
['code' => 'admin.config.odds-versions.show', 'module_code' => 'config', 'name' => '赔率版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
|
||||||
|
['code' => 'admin.config.odds-versions.store', 'module_code' => 'config', 'name' => '创建赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions', 'route_name' => 'api.v1.admin.config.odds-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.odds-versions.items.replace', 'module_code' => 'config', 'name' => '替换赔率版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/items', 'route_name' => 'api.v1.admin.config.odds-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.odds-versions.publish', 'module_code' => 'config', 'name' => '发布赔率版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.odds-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.odds-versions.destroy', 'module_code' => 'config', 'name' => '删除赔率版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/odds-versions/{id}', 'route_name' => 'api.v1.admin.config.odds-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.index', 'module_code' => 'config', 'name' => '封顶版本列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.show', 'module_code' => 'config', 'name' => '封顶版本详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.store', 'module_code' => 'config', 'name' => '创建封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions', 'route_name' => 'api.v1.admin.config.risk-cap-versions.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||||
|
['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||||
|
|
||||||
|
['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.risk-pools.show', 'module_code' => 'risk', 'name' => '风控池详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}', 'route_name' => 'api.v1.admin.draws.risk-pools.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
|
||||||
|
['code' => 'admin.draws.result-batches.store', 'module_code' => 'draw', 'name' => '创建开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.result-batches.publish', 'module_code' => 'draw', 'name' => '发布开奖结果批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches/{batch}/publish', 'route_name' => 'api.v1.admin.draws.result-batches.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.reopen', 'module_code' => 'draw', 'name' => '重开开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/reopen', 'route_name' => 'api.v1.admin.draws.reopen', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.generate-plan', 'module_code' => 'draw', 'name' => '生成开奖计划', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/generate-plan', 'route_name' => 'api.v1.admin.draws.generate-plan', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.manual-close', 'module_code' => 'draw', 'name' => '人工封盘', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/manual-close', 'route_name' => 'api.v1.admin.draws.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.risk-pools.manual-close', 'module_code' => 'risk', 'name' => '人工关闭风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/manual-close', 'route_name' => 'api.v1.admin.draws.risk-pools.manual-close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.risk-pools.recover', 'module_code' => 'risk', 'name' => '恢复风控池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools/{number_4d}/recover', 'route_name' => 'api.v1.admin.draws.risk-pools.recover', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.cancel', 'module_code' => 'draw', 'name' => '取消开奖', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/cancel', 'route_name' => 'api.v1.admin.draws.cancel', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.rng', 'module_code' => 'draw', 'name' => '执行开奖 RNG', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/rng', 'route_name' => 'api.v1.admin.draws.rng', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.draw_result.manage']],
|
||||||
|
['code' => 'admin.draws.settlement.run', 'module_code' => 'settlement', 'name' => '执行结算', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/draws/{draw}/settlement/run', 'route_name' => 'api.v1.admin.draws.settlement.run', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review']],
|
||||||
|
|
||||||
|
['code' => 'admin.settlement-batches.index', 'module_code' => 'settlement', 'name' => '结算批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches', 'route_name' => 'api.v1.admin.settlement-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||||
|
['code' => 'admin.settlement-batches.show', 'module_code' => 'settlement', 'name' => '结算批次详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}', 'route_name' => 'api.v1.admin.settlement-batches.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||||
|
['code' => 'admin.settlement-batches.details', 'module_code' => 'settlement', 'name' => '结算批次明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/details', 'route_name' => 'api.v1.admin.settlement-batches.details', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||||
|
['code' => 'admin.settlement-batches.export', 'module_code' => 'settlement', 'name' => '导出结算批次', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/export', 'route_name' => 'api.v1.admin.settlement-batches.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
|
||||||
|
['code' => 'admin.settlement-batches.approve', 'module_code' => 'settlement', 'name' => '审核结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/approve', 'route_name' => 'api.v1.admin.settlement-batches.approve', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']],
|
||||||
|
['code' => 'admin.settlement-batches.reject', 'module_code' => 'settlement', 'name' => '驳回结算批次', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/reject', 'route_name' => 'api.v1.admin.settlement-batches.reject', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.review']],
|
||||||
|
['code' => 'admin.settlement-batches.payout', 'module_code' => 'settlement', 'name' => '执行派彩', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/payout', 'route_name' => 'api.v1.admin.settlement-batches.payout', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']],
|
||||||
|
|
||||||
|
['code' => 'admin.jackpot.pools.index', 'module_code' => 'jackpot', 'name' => '奖池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools', 'route_name' => 'api.v1.admin.jackpot.pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||||
|
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||||
|
['code' => 'admin.jackpot.contributions.index', 'module_code' => 'jackpot', 'name' => '奖池注入记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/contributions', 'route_name' => 'api.v1.admin.jackpot.contributions.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
|
||||||
|
['code' => 'admin.jackpot.pools.update', 'module_code' => 'jackpot', 'name' => '更新奖池', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}', 'route_name' => 'api.v1.admin.jackpot.pools.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
|
||||||
|
['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manage']],
|
||||||
|
|
||||||
|
['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']],
|
||||||
|
|
||||||
|
['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||||
|
['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||||
|
['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||||
|
['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||||
|
['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||||
|
['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||||
|
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
|
||||||
|
['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
|
||||||
|
|
||||||
|
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||||
|
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||||
|
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||||
|
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表任务', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ use App\Http\Middleware\EnsureAdminApi;
|
|||||||
use App\Http\Middleware\EnsurePlayerApi;
|
use App\Http\Middleware\EnsurePlayerApi;
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Auth\AuthenticationException;
|
use Illuminate\Auth\AuthenticationException;
|
||||||
|
use App\Http\Middleware\EnsureAdminApiResourcePermission;
|
||||||
use App\Http\Middleware\EnsureAdminPermission;
|
use App\Http\Middleware\EnsureAdminPermission;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use App\Http\Middleware\NegotiateLotteryLocale;
|
use App\Http\Middleware\NegotiateLotteryLocale;
|
||||||
@@ -48,6 +49,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
// 后台 API 预留:Sanctum / RBAC
|
// 后台 API 预留:Sanctum / RBAC
|
||||||
'lottery.admin' => EnsureAdminApi::class,
|
'lottery.admin' => EnsureAdminApi::class,
|
||||||
'admin.permission' => EnsureAdminPermission::class,
|
'admin.permission' => EnsureAdminPermission::class,
|
||||||
|
'admin.api-resource' => EnsureAdminApiResourcePermission::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -63,6 +63,10 @@
|
|||||||
"@php artisan config:clear --ansi @no_additional_args",
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
],
|
],
|
||||||
|
"test:admin-auth": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@php artisan lottery:admin-auth-audit"
|
||||||
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_orders', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('play_config_version_no')->default(0)->after('client_trace_id');
|
||||||
|
$table->unsignedInteger('odds_version_no')->default(0)->after('play_config_version_no');
|
||||||
|
$table->unsignedInteger('risk_cap_version_no')->default(0)->after('odds_version_no');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticket_orders', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'play_config_version_no',
|
||||||
|
'odds_version_no',
|
||||||
|
'risk_cap_version_no',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use App\Support\AdminApiResourceCatalog;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
|
||||||
|
|
||||||
|
foreach (AdminApiResourceCatalog::resources() as $resource) {
|
||||||
|
$resourceId = DB::table('admin_api_resources')
|
||||||
|
->where('code', $resource['code'])
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'module_code' => $resource['module_code'],
|
||||||
|
'name' => $resource['name'],
|
||||||
|
'http_method' => $resource['http_method'],
|
||||||
|
'uri_pattern' => $resource['uri_pattern'],
|
||||||
|
'route_name' => $resource['route_name'],
|
||||||
|
'auth_mode' => $resource['auth_mode'],
|
||||||
|
'is_audit_required' => $resource['is_audit_required'],
|
||||||
|
'status' => 1,
|
||||||
|
'meta_json' => null,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($resourceId === null) {
|
||||||
|
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
|
||||||
|
'code' => $resource['code'],
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
DB::table('admin_api_resources')
|
||||||
|
->where('id', (int) $resourceId)
|
||||||
|
->update($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_api_resource_bindings')
|
||||||
|
->where('api_resource_id', (int) $resourceId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
foreach ($resource['permission_codes'] as $permissionCode) {
|
||||||
|
$menuActionId = $menuActionIds[$permissionCode] ?? null;
|
||||||
|
if ($menuActionId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_api_resource_bindings')->insert([
|
||||||
|
'api_resource_id' => (int) $resourceId,
|
||||||
|
'menu_action_id' => (int) $menuActionId,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('admin_role_api_resources')->delete();
|
||||||
|
|
||||||
|
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
|
||||||
|
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
|
||||||
|
->select('rma.role_id', 'arb.api_resource_id')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($roleResourceRows as $row) {
|
||||||
|
DB::table('admin_role_api_resources')->insert([
|
||||||
|
'role_id' => (int) $row->role_id,
|
||||||
|
'api_resource_id' => (int) $row->api_resource_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 保持数据升级可逆风险最低:不在 down 中尝试删除资源,避免误删线上已使用授权关系。
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,12 +28,12 @@ use App\Http\Controllers\Api\V1\Admin\AdminSettingController;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 玩法类型只读
|
// 玩法类型只读
|
||||||
Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage'])
|
||||||
->get('play-types', PlayTypeIndexController::class)
|
->get('play-types', PlayTypeIndexController::class)
|
||||||
->name('api.v1.admin.play-types.index');
|
->name('api.v1.admin.play-types.index');
|
||||||
|
|
||||||
// 玩法版本只读
|
// 玩法版本只读
|
||||||
Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage'])
|
||||||
->prefix('config')
|
->prefix('config')
|
||||||
->name('api.v1.admin.config.')
|
->name('api.v1.admin.config.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@@ -45,7 +45,7 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 赔率/回水只读
|
// 赔率/回水只读
|
||||||
Route::middleware('admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate.view')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate.view'])
|
||||||
->prefix('config')
|
->prefix('config')
|
||||||
->name('api.v1.admin.config.')
|
->name('api.v1.admin.config.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@@ -57,7 +57,7 @@ Route::middleware('admin.permission:prd.odds.manage|prd.rebate.manage|prd.rebate
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 封顶只读
|
// 封顶只读
|
||||||
Route::middleware('admin.permission:prd.risk_cap.manage|prd.risk_cap.view')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.risk_cap.manage|prd.risk_cap.view'])
|
||||||
->prefix('config')
|
->prefix('config')
|
||||||
->name('api.v1.admin.config.')
|
->name('api.v1.admin.config.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@@ -69,7 +69,7 @@ Route::middleware('admin.permission:prd.risk_cap.manage|prd.risk_cap.view')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 玩法/赔率/封顶/Jackpot 配置写入
|
// 玩法/赔率/封顶/Jackpot 配置写入
|
||||||
Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.risk_cap.manage|prd.rebate.manage|prd.jackpot.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.play_switch.manage|prd.odds.manage|prd.risk_cap.manage|prd.rebate.manage|prd.jackpot.manage'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::patch('play-types/{play_code}', PlayTypePatchController::class)
|
Route::patch('play-types/{play_code}', PlayTypePatchController::class)
|
||||||
->where('play_code', '[a-z0-9_]+')
|
->where('play_code', '[a-z0-9_]+')
|
||||||
@@ -120,7 +120,7 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 通用 KV 设置(钱包限额等)
|
// 通用 KV 设置(钱包限额等)
|
||||||
Route::middleware('admin.permission:prd.wallet_reconcile.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage'])
|
||||||
->prefix('settings')
|
->prefix('settings')
|
||||||
->name('api.v1.admin.settings.')
|
->name('api.v1.admin.settings.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 连通性探测
|
// 连通性探测
|
||||||
Route::get('ping', AdminPingController::class)->name('api.v1.admin.ping');
|
Route::get('ping', AdminPingController::class)
|
||||||
|
->middleware('admin.api-resource')
|
||||||
|
->name('api.v1.admin.ping');
|
||||||
|
|
||||||
// 首页仪表盘
|
// 首页仪表盘
|
||||||
Route::get('dashboard', AdminDashboardController::class)->name('api.v1.admin.dashboard');
|
Route::get('dashboard', AdminDashboardController::class)
|
||||||
|
->middleware('admin.api-resource')
|
||||||
|
->name('api.v1.admin.dashboard');
|
||||||
|
|
||||||
// 审计日志
|
// 审计日志
|
||||||
Route::middleware('admin.permission:prd.audit.all|prd.audit.self|prd.audit.finance')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.audit.all|prd.audit.self|prd.audit.finance'])
|
||||||
->get('audit-logs', AuditLogIndexController::class)
|
->get('audit-logs', AuditLogIndexController::class)
|
||||||
->name('api.v1.admin.audit-logs.index');
|
->name('api.v1.admin.audit-logs.index');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use App\Http\Controllers\Api\V1\Admin\Settlement\AdminSettlementBatchDetailsCont
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 开奖结果查看 + 风控监控
|
// 开奖结果查看 + 风控监控
|
||||||
Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.draw_result.manage|prd.draw_result.view'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('draws', AdminDrawIndexController::class)
|
Route::get('draws', AdminDrawIndexController::class)
|
||||||
->name('api.v1.admin.draws.index');
|
->name('api.v1.admin.draws.index');
|
||||||
@@ -50,7 +50,7 @@ Route::middleware('admin.permission:prd.draw_result.manage|prd.draw_result.view'
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 开奖结果录入(发布批次)
|
// 开奖结果录入(发布批次)
|
||||||
Route::middleware('admin.permission:prd.draw_result.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.draw_result.manage'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::post('draws/{draw}/result-batches', DrawManualResultBatchStoreController::class)
|
Route::post('draws/{draw}/result-batches', DrawManualResultBatchStoreController::class)
|
||||||
->name('api.v1.admin.draws.result-batches.store');
|
->name('api.v1.admin.draws.result-batches.store');
|
||||||
@@ -75,12 +75,12 @@ Route::middleware('admin.permission:prd.draw_result.manage')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 派彩确认
|
// 派彩确认
|
||||||
Route::middleware('admin.permission:prd.payout.manage|prd.payout.review')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage|prd.payout.review'])
|
||||||
->post('draws/{draw}/settlement/run', DrawSettlementRunController::class)
|
->post('draws/{draw}/settlement/run', DrawSettlementRunController::class)
|
||||||
->name('api.v1.admin.draws.settlement.run');
|
->name('api.v1.admin.draws.settlement.run');
|
||||||
|
|
||||||
// 结算批次查看
|
// 结算批次查看
|
||||||
Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payout.view')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage|prd.payout.review|prd.payout.view'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('settlement-batches', AdminSettlementBatchIndexController::class)
|
Route::get('settlement-batches', AdminSettlementBatchIndexController::class)
|
||||||
->name('api.v1.admin.settlement-batches.index');
|
->name('api.v1.admin.settlement-batches.index');
|
||||||
@@ -92,7 +92,7 @@ Route::middleware('admin.permission:prd.payout.manage|prd.payout.review|prd.payo
|
|||||||
->name('api.v1.admin.settlement-batches.export');
|
->name('api.v1.admin.settlement-batches.export');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('admin.permission:prd.payout.review')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.review'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::post('settlement-batches/{batch}/approve', AdminSettlementBatchApproveController::class)
|
Route::post('settlement-batches/{batch}/approve', AdminSettlementBatchApproveController::class)
|
||||||
->name('api.v1.admin.settlement-batches.approve');
|
->name('api.v1.admin.settlement-batches.approve');
|
||||||
@@ -100,6 +100,6 @@ Route::middleware('admin.permission:prd.payout.review')
|
|||||||
->name('api.v1.admin.settlement-batches.reject');
|
->name('api.v1.admin.settlement-batches.reject');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('admin.permission:prd.payout.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.payout.manage'])
|
||||||
->post('settlement-batches/{batch}/payout', AdminSettlementBatchPayoutController::class)
|
->post('settlement-batches/{batch}/payout', AdminSettlementBatchPayoutController::class)
|
||||||
->name('api.v1.admin.settlement-batches.payout');
|
->name('api.v1.admin.settlement-batches.payout');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use App\Http\Controllers\Api\V1\Admin\Jackpot\AdminJackpotContributionIndexContr
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 奖池查看
|
// 奖池查看
|
||||||
Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.jackpot.manage|prd.jackpot.view'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('jackpot/pools', AdminJackpotPoolIndexController::class)
|
Route::get('jackpot/pools', AdminJackpotPoolIndexController::class)
|
||||||
->name('api.v1.admin.jackpot.pools.index');
|
->name('api.v1.admin.jackpot.pools.index');
|
||||||
@@ -23,7 +23,7 @@ Route::middleware('admin.permission:prd.jackpot.manage|prd.jackpot.view')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 奖池修改(仅管理权限)
|
// 奖池修改(仅管理权限)
|
||||||
Route::middleware('admin.permission:prd.jackpot.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.jackpot.manage'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
|
Route::put('jackpot/pools/{pool}', AdminJackpotPoolUpdateController::class)
|
||||||
->name('api.v1.admin.jackpot.pools.update');
|
->name('api.v1.admin.jackpot.pools.update');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use App\Http\Controllers\Api\V1\Admin\Player\AdminPlayerTicketItemsIndexControll
|
|||||||
/**
|
/**
|
||||||
* 管理员玩家管理路由。
|
* 管理员玩家管理路由。
|
||||||
*/
|
*/
|
||||||
Route::middleware('admin.permission:prd.users.manage|prd.users.view_finance|prd.users.view_cs')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.users.manage|prd.users.view_finance|prd.users.view_cs'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('players', AdminPlayerIndexController::class)
|
Route::get('players', AdminPlayerIndexController::class)
|
||||||
->name('api.v1.admin.players.index');
|
->name('api.v1.admin.players.index');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobStoreController;
|
|||||||
/**
|
/**
|
||||||
* 管理员报表路由。
|
* 管理员报表路由。
|
||||||
*/
|
*/
|
||||||
Route::middleware('admin.permission:prd.report.all|prd.report.risk|prd.report.finance|prd.report.player')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.report.all|prd.report.risk|prd.report.finance|prd.report.player'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('report-jobs', ReportJobIndexController::class)
|
Route::get('report-jobs', ReportJobIndexController::class)
|
||||||
->name('api.v1.admin.report-jobs.index');
|
->name('api.v1.admin.report-jobs.index');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use App\Http\Controllers\Api\V1\Admin\User\AdminUserPermissionSyncController;
|
|||||||
/**
|
/**
|
||||||
* 管理员账号与权限管理路由。
|
* 管理员账号与权限管理路由。
|
||||||
*/
|
*/
|
||||||
Route::middleware('admin.permission:prd.admin_user.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.admin_user.manage'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('admin-users', AdminUserIndexController::class)
|
Route::get('admin-users', AdminUserIndexController::class)
|
||||||
->name('api.v1.admin.admin-users.index');
|
->name('api.v1.admin.admin-users.index');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use App\Http\Controllers\Api\V1\Admin\Wallet\WalletTransactionListController;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// 钱包对账查看
|
// 钱包对账查看
|
||||||
Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage|prd.wallet_reconcile.view|prd.wallet_reconcile.view_cs'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('wallet/transfer-orders', TransferOrderListController::class)
|
Route::get('wallet/transfer-orders', TransferOrderListController::class)
|
||||||
->name('api.v1.admin.wallet.transfer-orders');
|
->name('api.v1.admin.wallet.transfer-orders');
|
||||||
@@ -30,7 +30,7 @@ Route::middleware('admin.permission:prd.wallet_reconcile.manage|prd.wallet_recon
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 对账操作(仅管理权限)
|
// 对账操作(仅管理权限)
|
||||||
Route::middleware('admin.permission:prd.wallet_reconcile.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::post('wallet/transfer-orders/{transfer_no}/reverse', [TransferOrderReconcileController::class, 'reverse'])
|
Route::post('wallet/transfer-orders/{transfer_no}/reverse', [TransferOrderReconcileController::class, 'reverse'])
|
||||||
->name('api.v1.admin.wallet.transfer-orders.reverse');
|
->name('api.v1.admin.wallet.transfer-orders.reverse');
|
||||||
@@ -39,6 +39,6 @@ Route::middleware('admin.permission:prd.wallet_reconcile.manage')
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 对账任务创建(仅管理权限)
|
// 对账任务创建(仅管理权限)
|
||||||
Route::middleware('admin.permission:prd.wallet_reconcile.manage')
|
Route::middleware(['admin.api-resource', 'admin.permission:prd.wallet_reconcile.manage'])
|
||||||
->post('reconcile-jobs', ReconcileJobStoreController::class)
|
->post('reconcile-jobs', ReconcileJobStoreController::class)
|
||||||
->name('api.v1.admin.reconcile-jobs.store');
|
->name('api.v1.admin.reconcile-jobs.store');
|
||||||
|
|||||||
115
tests/Feature/AdminApiResourcePermissionMiddlewareTest.php
Normal file
115
tests/Feature/AdminApiResourcePermissionMiddlewareTest.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminRole;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use App\Support\AdminPermissionBridge;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function mintAdminTokenWithLegacySlugs(string $username, array $permissionSlugs): string
|
||||||
|
{
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => $username,
|
||||||
|
'name' => 'Admin '.$username,
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($permissionSlugs !== []) {
|
||||||
|
$role = AdminRole::query()->create([
|
||||||
|
'slug' => 'role_'.$username,
|
||||||
|
'name' => 'Role '.$username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$codes = [];
|
||||||
|
foreach ($permissionSlugs as $slug) {
|
||||||
|
$codes = array_merge($codes, AdminPermissionBridge::menuActionCodesForLegacy($slug));
|
||||||
|
}
|
||||||
|
$codes = array_values(array_unique($codes));
|
||||||
|
|
||||||
|
$ids = DB::table('admin_menu_actions')
|
||||||
|
->whereIn('permission_code', $codes)
|
||||||
|
->where('status', 1)
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
foreach ($ids as $mid) {
|
||||||
|
DB::table('admin_role_menu_actions')->insert([
|
||||||
|
'role_id' => $role->id,
|
||||||
|
'menu_action_id' => (int) $mid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteId = AdminUser::defaultAdminSiteId();
|
||||||
|
$admin->roles()->sync([
|
||||||
|
(int) $role->id => [
|
||||||
|
'site_id' => $siteId,
|
||||||
|
'granted_at' => now(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('admin api resource middleware allows login only resource for signed in admin', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_ping', []);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/ping')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
|
->assertJsonPath('data.scope', 'admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin api resource middleware denies protected report resource without permission', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_denied', []);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/report-jobs')
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin api resource middleware allows protected report resource with mapped permission', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_reporter', ['prd.report.player']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/report-jobs')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
|
->assertJsonPath('data.meta.total', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin api resource middleware denies wallet reconcile resource without permission', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_wallet_denied', []);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/wallet/transactions')
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/wallet/transactions')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
|
->assertJsonPath('data.total', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin api resource middleware denies jackpot resource without permission', function (): void {
|
||||||
|
$token = mintAdminTokenWithLegacySlugs('resource_jackpot_denied', []);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/jackpot/pools')
|
||||||
|
->assertForbidden()
|
||||||
|
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||||
|
});
|
||||||
48
tests/Feature/AdminAuthorizationAuditCommandTest.php
Normal file
48
tests/Feature/AdminAuthorizationAuditCommandTest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Database\Seeders\AdminRbacAndUserSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('admin authorization audit reports missing api resources for protected routes', function (): void {
|
||||||
|
DB::table('admin_api_resources')
|
||||||
|
->where('code', 'admin.config.play-versions.index')
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->artisan('lottery:admin-auth-audit')
|
||||||
|
->expectsOutputToContain('Admin authorization audit found')
|
||||||
|
->expectsOutputToContain('[route_coverage]')
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin authorization audit passes on the default authorization catalog', function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-audit')
|
||||||
|
->expectsOutputToContain('Admin authorization audit passed.')
|
||||||
|
->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin authorization audit detects role api resource drift', function (): void {
|
||||||
|
$this->seed(AdminRbacAndUserSeeder::class);
|
||||||
|
|
||||||
|
$resourceId = DB::table('admin_api_resources')
|
||||||
|
->where('code', 'admin.audit.index')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$roleId = DB::table('admin_roles')
|
||||||
|
->where('slug', 'finance')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
expect($resourceId)->not->toBeNull();
|
||||||
|
expect($roleId)->not->toBeNull();
|
||||||
|
|
||||||
|
DB::table('admin_role_api_resources')
|
||||||
|
->where('role_id', (int) $roleId)
|
||||||
|
->where('api_resource_id', (int) $resourceId)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->artisan('lottery:admin-auth-audit --skip-route-coverage')
|
||||||
|
->expectsOutputToContain('Missing role-resource grant')
|
||||||
|
->assertExitCode(1);
|
||||||
|
});
|
||||||
23
tests/Feature/RiskPoolLuaScriptTest.php
Normal file
23
tests/Feature/RiskPoolLuaScriptTest.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Ticket\RiskPoolService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('risk pool lua acquire script returns structured status and pool counters', function (): void {
|
||||||
|
$service = app(RiskPoolService::class);
|
||||||
|
$method = new ReflectionMethod($service, 'acquireLua');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$lua = (string) $method->invoke($service);
|
||||||
|
|
||||||
|
expect($lua)->toContain("return {'OK'")
|
||||||
|
->and($lua)->toContain('INVALID_ARGUMENT')
|
||||||
|
->and($lua)->toContain('POOL_NOT_INITIALIZED')
|
||||||
|
->and($lua)->toContain('VERSION_CONFLICT')
|
||||||
|
->and($lua)->toContain('INSUFFICIENT_CAP')
|
||||||
|
->and($lua)->toContain('remaining')
|
||||||
|
->and($lua)->toContain('locked')
|
||||||
|
->and($lua)->toContain('version');
|
||||||
|
});
|
||||||
@@ -9,11 +9,13 @@ use App\Models\TicketItem;
|
|||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Models\OddsVersion;
|
use App\Models\OddsVersion;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
|
use App\Models\JackpotPool;
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\TicketCombination;
|
use App\Models\TicketCombination;
|
||||||
use App\Models\PlayConfigItem;
|
use App\Models\PlayConfigItem;
|
||||||
use App\Models\PlayConfigVersion;
|
use App\Models\PlayConfigVersion;
|
||||||
use App\Lottery\ConfigVersionStatus;
|
use App\Lottery\ConfigVersionStatus;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\PlayTypeSeeder;
|
use Database\Seeders\PlayTypeSeeder;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
@@ -226,6 +228,59 @@ test('ticket place deducts wallet and persists order items combinations and logs
|
|||||||
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
|
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ticket place is idempotent by player draw and client trace id', function (): void {
|
||||||
|
$player = ticketPlayerWithWallet();
|
||||||
|
ticketOpenDraw();
|
||||||
|
|
||||||
|
$payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => 'same-submit-once']);
|
||||||
|
|
||||||
|
$first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
|
->postJson('/api/v1/ticket/place', $payload)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
|
->postJson('/api/v1/ticket/place', $payload)
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
expect($second['order_no'])->toBe($first['order_no'])
|
||||||
|
->and(TicketOrder::query()->count())->toBe(1)
|
||||||
|
->and(TicketItem::query()->count())->toBe(2)
|
||||||
|
->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1);
|
||||||
|
|
||||||
|
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||||
|
expect((int) $wallet->balance)->toBe(200_000 - 12_400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('box family estimated max payout is the sum of every expanded combination payout', function (): void {
|
||||||
|
$player = ticketPlayerWithWallet(500_000);
|
||||||
|
ticketOpenDraw();
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
|
->postJson('/api/v1/ticket/place', [
|
||||||
|
'draw_id' => '20260511-001',
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'client_trace_id' => 'combo-payout-sum',
|
||||||
|
'lines' => [
|
||||||
|
['number' => '1122', 'play_code' => 'ibox', 'amount' => 100],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$item = TicketItem::query()->firstOrFail();
|
||||||
|
$combinationSum = TicketCombination::query()
|
||||||
|
->where('ticket_item_id', $item->id)
|
||||||
|
->sum('estimated_payout');
|
||||||
|
|
||||||
|
expect((int) $response['summary']['total_estimated_payout'])->toBe((int) $combinationSum)
|
||||||
|
->and((int) $item->estimated_max_payout)->toBe((int) $combinationSum)
|
||||||
|
->and((int) $item->risk_locked_amount)->toBe((int) $combinationSum);
|
||||||
|
});
|
||||||
|
|
||||||
test('ticket place rejects closed draw', function (): void {
|
test('ticket place rejects closed draw', function (): void {
|
||||||
$player = ticketPlayerWithWallet();
|
$player = ticketPlayerWithWallet();
|
||||||
$draw = ticketOpenDraw();
|
$draw = ticketOpenDraw();
|
||||||
@@ -285,6 +340,10 @@ test('ticket place succeeds when expected_config_versions matches preview', func
|
|||||||
->assertJsonPath('code', ErrorCode::Success->value);
|
->assertJsonPath('code', ErrorCode::Success->value);
|
||||||
|
|
||||||
expect(TicketOrder::query()->count())->toBe(1);
|
expect(TicketOrder::query()->count())->toBe(1);
|
||||||
|
$order = TicketOrder::query()->firstOrFail();
|
||||||
|
expect((int) $order->play_config_version_no)->toBe((int) $versions['play_config_version_no'])
|
||||||
|
->and((int) $order->odds_version_no)->toBe((int) $versions['odds_version_no'])
|
||||||
|
->and((int) $order->risk_cap_version_no)->toBe((int) $versions['risk_cap_version_no']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ticket place rejects stale expected_config_versions', function (): void {
|
test('ticket place rejects stale expected_config_versions', function (): void {
|
||||||
@@ -769,3 +828,40 @@ test('ticket pending confirmation reconcile confirms order when wallet deduction
|
|||||||
->and($item->fresh()->status)->toBe('success')
|
->and($item->fresh()->status)->toBe('success')
|
||||||
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000);
|
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ticket place reverses wallet and releases risk when post deduction confirmation fails', function (): void {
|
||||||
|
$player = ticketPlayerWithWallet(20_000);
|
||||||
|
$draw = ticketOpenDraw();
|
||||||
|
|
||||||
|
JackpotPool::query()->create([
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'current_amount' => 0,
|
||||||
|
'contribution_rate' => 1,
|
||||||
|
'trigger_threshold' => 0,
|
||||||
|
'payout_rate' => 0,
|
||||||
|
'force_trigger_draw_gap' => 0,
|
||||||
|
'min_bet_amount' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END");
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||||
|
->postJson('/api/v1/ticket/place', [
|
||||||
|
'draw_id' => '20260511-001',
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'client_trace_id' => 'wallet-reverse-on-confirm-fail',
|
||||||
|
'lines' => [
|
||||||
|
['number' => '1234', 'play_code' => 'big', 'amount' => 120],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertStatus(500);
|
||||||
|
|
||||||
|
$wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail();
|
||||||
|
$order = TicketOrder::query()->where('client_trace_id', 'wallet-reverse-on-confirm-fail')->firstOrFail();
|
||||||
|
|
||||||
|
expect((int) $wallet->balance)->toBe(20_000)
|
||||||
|
->and($order->status)->toBe('refunded')
|
||||||
|
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_deduct')->count())->toBe(1)
|
||||||
|
->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1)
|
||||||
|
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user