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:
2026-06-15 17:21:53 +08:00
parent 5b6d4cb74d
commit 606ed6817e
14 changed files with 440 additions and 49 deletions

View File

@@ -42,15 +42,14 @@
## Learned Workspace Facts
- 期号 `close_time` / `draw_time` UTC 存储与比较;后台展示转浏览器本地时区,创建/编辑表单提交前须转回 UTC
- 下注是否开放由 `DrawHallSnapshotBuilder::isBettingOpen()` / `effectiveHallDisplayStatus()` 实时判定,不只看 `draws.status`
- 后台期号列表展示数据库 `status`;详情 API 另提供 `hall_preview_status` 供与大厅预览态对比。
- 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`
- `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。
- 站点管理员(`admin_user_site_roles` + `slug=site_admin`,且**未**绑 `admin_user_agents`)定位单站信用盘运营(代理树/玩家/结算/注单/报表);不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage``canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。
- 结算账单可见范围(绑定代理):**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点``counterparty=本节点`(不含下级玩家的账单、不含更深层代理链)。**账务流水/账期 pipeline** 的玩家维度同样仅直属玩家。站点财务/超管仍见全站。
- 登记收付/确认:绑定代理仅可操作 **收款方**(玩家账单=直属 counterparty代理账单=按 net_amount 方向的 payee。上级不能代登下级玩家收付下级也不能代登向上级的代理账单。
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage``canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点``counterparty=本节点`;登记收付/确认仅可操作 **收款方**
- 收付/调账/坏账后端落库 `payment_records``settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
- 代理仪表盘/账期列表「输赢」用本级占成(`share_profit`),不可看 `platform_pnl` 全站报表
- 开/关账期仅未绑定代理的站点财务(`canManagePeriods = canOperateBills && boundAgent === null`
- **一级代理 profile**:每站唯一根节点(`depth=0`)的占成/授信/回水仅超管可改(`AdminAgentScope::nodeProfileEditableBy`);站点管理员与一级代理账号对根节点 profile API 为 403下级仍由上级代理维护
- 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync常驻 `schedule:work``queue:work redis --queue=broadcasts:countdown,broadcasts,default``reverb:start``CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis先部署 lotterLaravel 再前端
- `AgentProfileService::upsertForNode` PATCH 缺省键会写 0占成/回水等),须按 `array_key_exists` 合并既有 profile
- 代理仪表盘/账期「输赢」用本级 `share_profit`;开/关账期仅未绑定代理站点财务;根节点(`depth=0`profile 仅超管可改
- SSO 钱包盘:首次有效 JWT 调 `GET /api/v1/player/me``(site_code, site_player_id)` 自动建档;`username`/`nickname` 彩票生成 `nlotto******`,不从主站同步。
- 错误 8005Account suspended站点不存在/停用或玩家冻结;**生产**按 `admin_sites` 验站点,`.env` `MAIN_SITE_*` 兜底仅 `local/testing`
- 建站即生成 `sso_jwt_secret`/`wallet_api_key``main-site/` 须手动对齐 `MAIN_SITE_CODE` 与两把密钥(未设时默认 `main-site`),不会从后台自动同步;`wallet_api_url` 填主站钱包根 URL勿填彩票 H5。

View File

@@ -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.'%');
}

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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);

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

View File

@@ -29,7 +29,7 @@ return [
'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.',
'player_unpaid_settlement_blocks_delete' => 'Player still has unpaid settlement bills and cannot be deleted until they are settled or written off.',
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
'platform_roles_fixed' => 'Only the built-in Super Admin and Agent platform roles are supported; creating new roles is disabled.',
'role_slug_reserved' => 'This role code is reserved for a built-in role. Choose another slug.',
'role_super_admin_permissions_fixed' => 'Super Admin always has full permissions. Run lottery:admin-auth-sync after the permission catalog changes.',
'role_super_admin_metadata_fixed' => 'Super Admin is a built-in role; name and status cannot be changed.',
'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.',

View File

@@ -25,7 +25,7 @@ return [
'player_has_tickets_blocks_delete' => 'खेलाडीसँग टिकट रेकर्ड छ, मेटाउन मिल्दैन।',
'player_unpaid_settlement_blocks_delete' => 'खेलाडीसँग अझै बाँकी सेटलमेन्ट बिलहरू छन्। पहिले सेटल वा राइट-अफ गरेपछि मात्र मेटाउन मिल्छ।',
'role_cannot_delete_super_admin' => 'सुपर एडमिन भूमिका मेटाउन मिल्दैन।',
'platform_roles_fixed' => 'प्लेटफर्ममा केवल सुपर एडमिन र एजेन्ट भूमिका छन्; नयाँ भूमिका थप्न मिल्दैन।',
'role_slug_reserved' => 'यो भूमिका कोड बिल्ट-इन भूमिकाका लागि राखिएको छ। अर्को कोड प्रयोग गर्नुहोस्।',
'role_super_admin_permissions_fixed' => 'सुपर एडमिनसँग सबै अनुमति हुन्छ; क्याटलग परिवर्तनपछि lottery:admin-auth-sync चलाउनुहोस्।',
'role_super_admin_metadata_fixed' => 'सुपर एडमिन बिल्ट-इन भूमिका हो; नाम वा स्थिति परिवर्तन गर्न मिल्दैन।',
'role_builtin_cannot_delete' => 'बिल्ट-इन भूमिका मेटाउन मिल्दैन।',

View File

@@ -30,7 +30,7 @@ return [
'player_unpaid_settlement_blocks_delete' => '该玩家仍有未结账单,请先结清或核销后再删除。',
'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。',
'role_cannot_delete_super_admin' => '不能删除超级管理员角色。',
'platform_roles_fixed' => '平台仅保留「超级管理员」与「代理」两个内置角色,不支持新增。',
'role_slug_reserved' => '该角色编码为系统保留,请使用其他标识。',
'role_super_admin_permissions_fixed' => '超级管理员拥有全部权限,请在权限目录变更后执行 lottery:admin-auth-sync。',
'role_super_admin_metadata_fixed' => '超级管理员为内置角色,不支持修改名称或状态。',
'role_builtin_cannot_delete' => '系统内置角色不允许删除。',

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\V1\Admin\Ticket\AdminTicketItemIndexController;
use App\Http\Controllers\Api\V1\Admin\Ticket\AdminTicketItemShowController;
use Illuminate\Support\Facades\Route;
/**
@@ -8,6 +9,8 @@ use Illuminate\Support\Facades\Route;
*/
Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('tickets/{ticket_no}', AdminTicketItemShowController::class)
->name('api.v1.admin.tickets.show');
Route::get('tickets', AdminTicketItemIndexController::class)
->name('api.v1.admin.tickets.index');
});

View File

@@ -254,12 +254,63 @@ test('admin risk pool lock logs include ticket_no when linked', function (): voi
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs')
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs?group_by=entry')
->assertOk()
->assertJsonPath('data.group_by', 'entry')
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.amount', 50);
});
test('admin risk pool lock logs can group by ticket and filter active pools', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-007',
'business_date' => '2026-05-12',
'sequence_no' => 7,
'status' => 'open',
'start_time' => now()->subHour(),
'close_time' => now()->addHour(),
'draw_time' => now()->addHours(2),
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '0043',
'total_cap_amount' => 1_000_000,
'locked_amount' => 0,
'remaining_amount' => 1_000_000,
'sold_out_status' => 0,
'version' => 0,
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1288',
'total_cap_amount' => 1_000,
'locked_amount' => 850,
'remaining_amount' => 150,
'sold_out_status' => 0,
'version' => 1,
]);
$token = mintRiskAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pools?active_only=1')
->assertOk()
->assertJsonPath('data.meta.total', 1)
->assertJsonPath('data.items.0.normalized_number', '1288');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/risk-pool-lock-logs?group_by=entry')
->assertOk()
->assertJsonPath('data.group_by', 'entry');
});
test('admin risk pool show 404 when pool missing', function (): void {
$draw = Draw::query()->create([
'draw_no' => '20260512-003',

View File

@@ -27,7 +27,7 @@ function platformRolesApiToken(string $username): string
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('platform role index only lists fixed super_admin and agent roles', function (): void {
test('platform role index lists built-in roles and custom system roles', function (): void {
AdminRole::query()->create([
'slug' => 'legacy_custom_ops',
'code' => 'legacy_custom_ops',
@@ -49,10 +49,37 @@ test('platform role index only lists fixed super_admin and agent roles', functio
->pluck('slug')
->all();
expect($slugs)->toBe(['super_admin', 'site_admin', 'agent']);
expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops');
});
test('platform roles cannot be created and super_admin permissions are full catalog', function (): void {
test('admin can create custom platform role but not reserved slugs', function (): void {
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_create');
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'super_admin',
'name' => 'Fake Super',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'new_ops',
'name' => 'New Ops',
'description' => 'Custom ops role',
'permission_slugs' => ['prd.dashboard.view'],
])
->assertCreated()
->assertJsonPath('data.slug', 'new_ops')
->assertJsonPath('data.name', 'New Ops')
->assertJsonPath('data.is_system', false);
expect(AdminRole::query()->where('slug', 'new_ops')->exists())->toBeTrue();
});
test('super_admin built-in role remains protected', function (): void {
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_guard');
@@ -64,13 +91,6 @@ test('platform roles cannot be created and super_admin permissions are full cata
->toBe($menuActionCount);
expect($super->legacyPermissionSlugs())->not->toBeEmpty();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'new_ops',
'name' => 'New Ops',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [
'permission_slugs' => ['prd.dashboard.view'],