feat: enhance risk pool management and role validation
- Updated AGENTS.md to streamline information on risk pool operations and agent account restrictions. - Introduced filtering options in AdminRiskPoolIndexController for active risk pools. - Refactored AdminRiskPoolLockLogIndexController to support grouping by ticket or entry. - Enhanced AdminRoleStoreController and AdminRoleUpdateController to prevent the use of reserved slugs for new roles. - Improved error messaging for role creation and updates to clarify reserved role restrictions. - Added new API routes for ticket item retrieval to improve admin functionalities.
This commit is contained in:
@@ -23,6 +23,7 @@ final class AdminRiskPoolIndexController extends Controller
|
||||
$p = AdminApiList::readPaging($request);
|
||||
$soldOutOnly = $request->boolean('sold_out_only');
|
||||
$highRiskOnly = $request->boolean('high_risk_only');
|
||||
$activeOnly = $request->boolean('active_only');
|
||||
$number = trim((string) $request->query('normalized_number', ''));
|
||||
$sort = trim((string) $request->query('sort', 'usage_desc'));
|
||||
|
||||
@@ -34,6 +35,13 @@ final class AdminRiskPoolIndexController extends Controller
|
||||
if ($highRiskOnly) {
|
||||
$q->whereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
|
||||
}
|
||||
if ($activeOnly) {
|
||||
$q->where(function ($inner): void {
|
||||
$inner->where('locked_amount', '>', 0)
|
||||
->orWhere('sold_out_status', 1)
|
||||
->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
|
||||
});
|
||||
}
|
||||
if ($number !== '') {
|
||||
$q->where('normalized_number', 'like', '%'.$number.'%');
|
||||
}
|
||||
|
||||
@@ -10,48 +10,51 @@ use App\Models\RiskPoolLockLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use App\Services\Admin\AdminRiskPoolLockLogQueryService;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/draws/{draw}/risk-pool-lock-logs — 风险池占用/释放流水(审计与监控)。
|
||||
*
|
||||
* Query:`group_by=ticket`(默认,按注单聚合)| `entry`(按号码明细);
|
||||
* `ticket_item_id` 可筛单注;`action_type`、`normalized_number` 同前。
|
||||
*/
|
||||
final class AdminRiskPoolLockLogIndexController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdminRiskPoolLockLogQueryService $queryService,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, Draw $draw): JsonResponse
|
||||
{
|
||||
$p = AdminApiList::readPaging($request);
|
||||
$action = trim((string) $request->query('action_type', ''));
|
||||
$number = trim((string) $request->query('normalized_number', ''));
|
||||
|
||||
$q = RiskPoolLockLog::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->with(['ticketItem:id,ticket_no,play_code,player_id'])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($action !== '' && in_array($action, ['lock', 'release'], true)) {
|
||||
$q->where('action_type', $action);
|
||||
}
|
||||
|
||||
if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) {
|
||||
$q->where('normalized_number', $number);
|
||||
$groupBy = trim((string) $request->query('group_by', 'ticket'));
|
||||
if (! in_array($groupBy, ['ticket', 'entry'], true)) {
|
||||
$groupBy = 'ticket';
|
||||
}
|
||||
|
||||
/** @var LengthAwarePaginator $paginator */
|
||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||
$paginator = $groupBy === 'entry'
|
||||
? $this->queryService->paginateEntries($draw, $request, $p['page'], $p['perPage'])
|
||||
: $this->queryService->paginateByTicket($draw, $request, $p['page'], $p['perPage']);
|
||||
|
||||
$currencyCode = (string) (TicketOrder::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->value('currency_code') ?? '');
|
||||
|
||||
return AdminApiList::jsonWith($paginator, fn (RiskPoolLockLog $log) => $this->row($log), [
|
||||
$rowMapper = $groupBy === 'entry'
|
||||
? fn (RiskPoolLockLog $log): array => $this->entryRow($log)
|
||||
: fn (object $row): array => $this->ticketRow($row);
|
||||
|
||||
return AdminApiList::jsonWith($paginator, $rowMapper, [
|
||||
'draw_id' => (int) $draw->id,
|
||||
'draw_no' => $draw->draw_no,
|
||||
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
|
||||
'group_by' => $groupBy,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function row(RiskPoolLockLog $log): array
|
||||
private function entryRow(RiskPoolLockLog $log): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $log->id,
|
||||
@@ -66,4 +69,25 @@ final class AdminRiskPoolLockLogIndexController extends Controller
|
||||
'created_at' => $log->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function ticketRow(object $row): array
|
||||
{
|
||||
$lastAt = $row->last_at ?? null;
|
||||
|
||||
return [
|
||||
'ticket_item_id' => (int) $row->ticket_item_id,
|
||||
'ticket_no' => (string) $row->ticket_no,
|
||||
'play_code' => (string) $row->play_code,
|
||||
'original_number' => (string) $row->original_number,
|
||||
'combination_count' => (int) $row->combination_count,
|
||||
'player_id' => (int) $row->player_id,
|
||||
'number_count' => (int) $row->number_count,
|
||||
'lock_entry_count' => (int) $row->lock_entry_count,
|
||||
'release_entry_count' => (int) $row->release_entry_count,
|
||||
'total_lock_amount' => (int) $row->total_lock_amount,
|
||||
'total_release_amount' => (int) $row->total_release_amount,
|
||||
'last_at' => is_string($lastAt) ? $lastAt : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\Ticket;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Models\TicketItem;
|
||||
use App\Support\AdminScopePolicy;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Support\CurrencyFormatter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/tickets/{ticket_no} — 注单详情(含展开组合)。
|
||||
*/
|
||||
final class AdminTicketItemShowController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $ticket_no): JsonResponse
|
||||
{
|
||||
$admin = $request->lotteryAdmin();
|
||||
abort_if($admin === null, 401);
|
||||
|
||||
$ticketNo = trim($ticket_no);
|
||||
$scope = AdminScopePolicy::resolveContext($request, $admin);
|
||||
|
||||
$query = TicketItem::query()
|
||||
->where('ticket_no', $ticketNo)
|
||||
->with([
|
||||
'combinations',
|
||||
'draw:id,draw_no,business_date',
|
||||
'order:id,order_no,currency_code,created_at,status',
|
||||
'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode',
|
||||
'player.agentNode:id,code,name',
|
||||
]);
|
||||
|
||||
AdminScopePolicy::applyViaPlayerRelationWithContext($query, $scope, 'player');
|
||||
|
||||
$item = $query->first();
|
||||
if ($item === null) {
|
||||
return ApiResponse::error(
|
||||
trans('api.not_found', [], $request->lotteryLocale()),
|
||||
ErrorCode::NotFound->value,
|
||||
null,
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
$totalBet = (int) $item->total_bet_amount;
|
||||
$actualDeduct = (int) $item->actual_deduct_amount;
|
||||
|
||||
return ApiResponse::success([
|
||||
'id' => (int) $item->id,
|
||||
'ticket_no' => $item->ticket_no,
|
||||
'order_no' => $item->order?->order_no,
|
||||
'order_status' => $item->order?->status,
|
||||
'draw_id' => (int) $item->draw_id,
|
||||
'draw_no' => $item->draw?->draw_no,
|
||||
'currency_code' => $item->order?->currency_code,
|
||||
'player_id' => $item->player_id,
|
||||
'site_code' => $item->player?->site_code,
|
||||
'site_player_id' => $item->player?->site_player_id,
|
||||
'username' => $item->player?->username,
|
||||
'nickname' => $item->player?->nickname,
|
||||
'funding_mode' => $item->player?->funding_mode,
|
||||
'agent_node_id' => $item->player?->agent_node_id,
|
||||
'agent_code' => $item->player?->agentNode?->code,
|
||||
'agent_name' => $item->player?->agentNode?->name,
|
||||
'play_code' => $item->play_code,
|
||||
'dimension' => $item->dimension,
|
||||
'digit_slot' => $item->digit_slot,
|
||||
'original_number' => $item->original_number,
|
||||
'normalized_number' => $item->normalized_number,
|
||||
'combination_count' => (int) $item->combination_count,
|
||||
'unit_bet_amount' => (int) $item->unit_bet_amount,
|
||||
'total_bet_amount_minor' => $totalBet,
|
||||
'total_bet_amount_formatted' => CurrencyFormatter::fromMinor($totalBet),
|
||||
'actual_deduct_amount_minor' => $actualDeduct,
|
||||
'actual_deduct_amount_formatted' => CurrencyFormatter::fromMinor($actualDeduct),
|
||||
'risk_locked_amount' => (int) $item->risk_locked_amount,
|
||||
'status' => $item->status,
|
||||
'fail_reason_code' => $item->fail_reason_code,
|
||||
'fail_reason_text' => $item->fail_reason_text,
|
||||
'win_amount_minor' => (int) $item->win_amount,
|
||||
'win_amount_formatted' => CurrencyFormatter::fromMinor((int) $item->win_amount),
|
||||
'placed_at' => $item->order?->created_at?->toIso8601String(),
|
||||
'updated_at' => $item->updated_at?->toIso8601String(),
|
||||
'combinations' => $item->combinations
|
||||
->sortBy('combination_no')
|
||||
->values()
|
||||
->map(fn ($combo): array => [
|
||||
'combination_no' => (int) $combo->combination_no,
|
||||
'number_4d' => (string) $combo->number_4d,
|
||||
'bet_amount' => (int) $combo->bet_amount,
|
||||
'estimated_payout' => (int) $combo->estimated_payout,
|
||||
])
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,12 @@ use App\Support\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
|
||||
final class AdminRoleIndexController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$roles = AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->whereIn('slug', PlatformSystemRoles::fixedSlugs())
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
@@ -2,22 +2,71 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin\User;
|
||||
|
||||
use App\Models\AdminRole;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\ApiMessage;
|
||||
use App\Support\ApiResponse;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AdminRoleApiPresenter;
|
||||
use App\Support\PlatformSystemRoles;
|
||||
use App\Http\Requests\Admin\AdminRoleStoreRequest;
|
||||
|
||||
final class AdminRoleStoreController extends Controller
|
||||
{
|
||||
public function __invoke(AdminRoleStoreRequest $request): JsonResponse
|
||||
{
|
||||
return ApiMessage::errorResponse(
|
||||
$slug = strtolower(trim((string) $request->validated('slug')));
|
||||
if (PlatformSystemRoles::isFixedSlug($slug)) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.role_slug_reserved',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
['slug' => $slug],
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
$role = DB::transaction(function () use ($request, $slug): AdminRole {
|
||||
$maxSort = (int) AdminRole::query()
|
||||
->where('scope_type', AdminRole::SCOPE_SYSTEM)
|
||||
->max('sort_order');
|
||||
|
||||
/** @var AdminRole $role */
|
||||
$role = AdminRole::query()->create([
|
||||
'slug' => $slug,
|
||||
'code' => $slug,
|
||||
'name' => $request->validated('name'),
|
||||
'description' => $request->validated('description'),
|
||||
'status' => (int) ($request->validated('status') ?? 1),
|
||||
'is_system' => false,
|
||||
'sort_order' => max(100, $maxSort + 10),
|
||||
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||
'owner_agent_id' => null,
|
||||
'delegated_from_role_id' => null,
|
||||
]);
|
||||
|
||||
$permissionSlugs = $request->validated('permission_slugs', []);
|
||||
if (is_array($permissionSlugs) && $permissionSlugs !== []) {
|
||||
$role->syncLegacyPermissionSlugs(array_values($permissionSlugs));
|
||||
}
|
||||
|
||||
return $role->refresh();
|
||||
});
|
||||
|
||||
AuditLogger::recordForAdmin(
|
||||
$request->lotteryAdmin(),
|
||||
$request,
|
||||
'admin.platform_roles_fixed',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
'system',
|
||||
'admin_role.create',
|
||||
'admin_role',
|
||||
(string) $role->id,
|
||||
null,
|
||||
422,
|
||||
AdminRoleApiPresenter::item($role),
|
||||
);
|
||||
|
||||
return ApiResponse::success(AdminRoleApiPresenter::item($role))->setStatusCode(201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,21 @@ final class AdminRoleUpdateController extends Controller
|
||||
}
|
||||
}
|
||||
if (isset($payload['slug'])) {
|
||||
$payload['code'] = $payload['slug'];
|
||||
$nextSlug = strtolower(trim((string) $payload['slug']));
|
||||
if (
|
||||
PlatformSystemRoles::isFixedSlug($nextSlug)
|
||||
&& $nextSlug !== (string) $admin_role->slug
|
||||
) {
|
||||
return ApiMessage::errorResponse(
|
||||
$request,
|
||||
'admin.role_slug_reserved',
|
||||
ErrorCode::ValidationFailed->value,
|
||||
['slug' => $nextSlug],
|
||||
422,
|
||||
);
|
||||
}
|
||||
$payload['slug'] = $nextSlug;
|
||||
$payload['code'] = $nextSlug;
|
||||
}
|
||||
|
||||
$admin_role->fill($payload);
|
||||
|
||||
126
app/Services/Admin/AdminRiskPoolLockLogQueryService.php
Normal file
126
app/Services/Admin/AdminRiskPoolLockLogQueryService.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\RiskPoolLockLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 风险占用流水查询:按号码明细或按注单聚合。
|
||||
*/
|
||||
final class AdminRiskPoolLockLogQueryService
|
||||
{
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, object>
|
||||
*/
|
||||
public function paginateEntries(Draw $draw, Request $request, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$action = trim((string) $request->query('action_type', ''));
|
||||
$number = trim((string) $request->query('normalized_number', ''));
|
||||
$ticketItemId = (int) $request->integer('ticket_item_id', 0);
|
||||
|
||||
$q = RiskPoolLockLog::query()
|
||||
->where('draw_id', $draw->id)
|
||||
->with(['ticketItem:id,ticket_no,play_code,player_id'])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($action !== '' && in_array($action, ['lock', 'release'], true)) {
|
||||
$q->where('action_type', $action);
|
||||
}
|
||||
|
||||
if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) {
|
||||
$q->where('normalized_number', $number);
|
||||
}
|
||||
|
||||
if ($ticketItemId > 0) {
|
||||
$q->where('ticket_item_id', $ticketItemId);
|
||||
}
|
||||
|
||||
return $q->paginate($perPage, ['*'], 'page', $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, object>
|
||||
*/
|
||||
public function paginateByTicket(Draw $draw, Request $request, int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$action = trim((string) $request->query('action_type', ''));
|
||||
$number = trim((string) $request->query('normalized_number', ''));
|
||||
$ticketItemId = (int) $request->integer('ticket_item_id', 0);
|
||||
|
||||
$bindings = ['draw_id' => $draw->id];
|
||||
$filters = 'l.draw_id = :draw_id AND l.ticket_item_id IS NOT NULL';
|
||||
|
||||
if ($action !== '' && in_array($action, ['lock', 'release'], true)) {
|
||||
$filters .= ' AND l.action_type = :action_type';
|
||||
$bindings['action_type'] = $action;
|
||||
}
|
||||
|
||||
if ($number !== '' && preg_match('/^[0-9]{4}$/', $number) === 1) {
|
||||
$filters .= ' AND l.normalized_number = :normalized_number';
|
||||
$bindings['normalized_number'] = $number;
|
||||
}
|
||||
|
||||
if ($ticketItemId > 0) {
|
||||
$filters .= ' AND l.ticket_item_id = :ticket_item_id';
|
||||
$bindings['ticket_item_id'] = $ticketItemId;
|
||||
}
|
||||
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$lockCountExpr = $driver === 'sqlite'
|
||||
? "SUM(CASE WHEN l.action_type = 'lock' THEN 1 ELSE 0 END)"
|
||||
: "COUNT(*) FILTER (WHERE l.action_type = 'lock')";
|
||||
$releaseCountExpr = $driver === 'sqlite'
|
||||
? "SUM(CASE WHEN l.action_type = 'release' THEN 1 ELSE 0 END)"
|
||||
: "COUNT(*) FILTER (WHERE l.action_type = 'release')";
|
||||
$lockAmountExpr = $driver === 'sqlite'
|
||||
? "COALESCE(SUM(CASE WHEN l.action_type = 'lock' THEN l.amount ELSE 0 END), 0)"
|
||||
: "COALESCE(SUM(l.amount) FILTER (WHERE l.action_type = 'lock'), 0)";
|
||||
$releaseAmountExpr = $driver === 'sqlite'
|
||||
? "COALESCE(SUM(CASE WHEN l.action_type = 'release' THEN l.amount ELSE 0 END), 0)"
|
||||
: "COALESCE(SUM(l.amount) FILTER (WHERE l.action_type = 'release'), 0)";
|
||||
|
||||
$aggregateSql = <<<SQL
|
||||
SELECT
|
||||
l.ticket_item_id AS ticket_item_id,
|
||||
ti.ticket_no AS ticket_no,
|
||||
ti.play_code AS play_code,
|
||||
ti.original_number AS original_number,
|
||||
ti.combination_count AS combination_count,
|
||||
ti.player_id AS player_id,
|
||||
{$lockCountExpr} AS lock_entry_count,
|
||||
{$releaseCountExpr} AS release_entry_count,
|
||||
{$lockAmountExpr} AS total_lock_amount,
|
||||
{$releaseAmountExpr} AS total_release_amount,
|
||||
COUNT(DISTINCT l.normalized_number) AS number_count,
|
||||
MAX(l.created_at) AS last_at
|
||||
FROM risk_pool_lock_logs l
|
||||
INNER JOIN ticket_items ti ON ti.id = l.ticket_item_id
|
||||
WHERE {$filters}
|
||||
GROUP BY l.ticket_item_id, ti.ticket_no, ti.play_code, ti.original_number, ti.combination_count, ti.player_id
|
||||
SQL;
|
||||
|
||||
$countSql = "SELECT COUNT(*) AS aggregate_count FROM ({$aggregateSql}) AS grouped";
|
||||
$total = (int) (DB::selectOne($countSql, $bindings)->aggregate_count ?? 0);
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min(max($page, 1), $lastPage);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$items = DB::select(
|
||||
"{$aggregateSql} ORDER BY last_at DESC, ticket_item_id DESC LIMIT {$perPage} OFFSET {$offset}",
|
||||
$bindings,
|
||||
);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
['path' => $request->url(), 'query' => $request->query()],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user