diff --git a/app/Console/Commands/AuditAgentLineDataCommand.php b/app/Console/Commands/AuditAgentLineDataCommand.php index 38bb6e9..2f26c2c 100644 --- a/app/Console/Commands/AuditAgentLineDataCommand.php +++ b/app/Console/Commands/AuditAgentLineDataCommand.php @@ -78,7 +78,7 @@ final class AuditAgentLineDataCommand extends Command ->select('admin_site_id', DB::raw('count(*) as cnt')) ->where('depth', '>', 0) ->groupBy('admin_site_id') - ->having('cnt', '>', 0) + ->havingRaw('count(*) > 0') ->get(); foreach ($sitesWithManyBusinessAgents as $row) { diff --git a/app/Console/Commands/AuditFinancialChainCommand.php b/app/Console/Commands/AuditFinancialChainCommand.php new file mode 100644 index 0000000..42e7183 --- /dev/null +++ b/app/Console/Commands/AuditFinancialChainCommand.php @@ -0,0 +1,178 @@ +walletIssues(), + $this->transferIssues(), + $this->creditIssues(), + $this->settlementIssues(), + $this->paymentIssues(), + ); + + if ($this->option('json')) { + $this->line(json_encode(['issues' => $issues], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + + return $issues === [] ? self::SUCCESS : self::FAILURE; + } + + if ($issues === []) { + $this->info('Financial chain audit passed.'); + + return self::SUCCESS; + } + + $this->error(sprintf('Financial chain 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 + */ + private function walletIssues(): array + { + return $this->countIssues([ + 'wallet_txn_math_bad' => [ + 'message' => '已入账钱包流水的前后余额不匹配', + 'sql' => "select count(*) as cnt from wallet_txns where status = 'posted' and ((direction = 1 and balance_after <> balance_before + amount) or (direction = 2 and balance_after <> balance_before - amount) or direction not in (1,2))", + ], + 'wallet_negative' => [ + 'message' => '玩家钱包余额或冻结余额为负数', + 'sql' => 'select count(*) as cnt from player_wallets where balance < 0 or frozen_balance < 0', + ], + 'wallet_latest_mismatch' => [ + 'message' => '玩家钱包当前余额与最新已入账流水余额不一致', + 'sql' => "select count(*) as cnt from player_wallets w join wallet_txns t on t.wallet_id = w.id where t.status = 'posted' and t.id = (select max(t2.id) from wallet_txns t2 where t2.wallet_id = w.id and t2.status = 'posted') and w.balance <> t.balance_after", + ], + ]); + } + + /** + * @return list + */ + private function transferIssues(): array + { + return $this->countIssues([ + 'success_transfer_missing_txn' => [ + 'message' => '成功转账单缺少对应钱包流水', + 'sql' => "select count(*) as cnt from transfer_orders o left join wallet_txns t on t.biz_no = o.transfer_no and t.biz_type = case when o.direction = 'out' then 'transfer_out' else 'transfer_in' end and t.status = 'posted' where o.status = 'success' and t.id is null", + ], + 'transfer_txn_missing_order' => [ + 'message' => '转账类钱包流水找不到对应转账单', + 'sql' => "select count(*) as cnt from wallet_txns t left join transfer_orders o on o.transfer_no = t.biz_no where t.status = 'posted' and t.biz_type in ('transfer_in','transfer_out','transfer_out_refund','reversal') and o.id is null", + ], + 'transfer_amount_mismatch' => [ + 'message' => '成功转账单金额与对应钱包流水金额不一致', + 'sql' => "select count(*) as cnt from transfer_orders o join wallet_txns t on t.biz_no = o.transfer_no and t.status = 'posted' and t.biz_type = case when o.direction = 'out' then 'transfer_out' else 'transfer_in' end where o.status = 'success' and o.amount <> t.amount", + ], + ]); + } + + /** + * @return list + */ + private function creditIssues(): array + { + return $this->countIssues([ + 'credit_account_negative' => [ + 'message' => '玩家授信账户额度、已用或冻结为负数', + 'sql' => 'select count(*) as cnt from player_credit_accounts where credit_limit < 0 or used_credit < 0 or frozen_credit < 0', + ], + 'credit_account_over_limit' => [ + 'message' => '玩家已用授信加冻结授信超过授信额度', + 'sql' => 'select count(*) as cnt from player_credit_accounts where used_credit + frozen_credit > credit_limit', + ], + 'credit_players_without_account' => [ + 'message' => '信用盘玩家缺少授信账户', + 'sql' => "select count(*) as cnt from players p left join player_credit_accounts a on a.player_id = p.id where p.funding_mode = 'credit' and a.player_id is null", + ], + 'orphan_player_credit_ledger' => [ + 'message' => '玩家信用流水引用了不存在的玩家', + 'sql' => "select count(*) as cnt from credit_ledger cl left join players p on p.id = cl.owner_id where cl.owner_type = 'player' and p.id is null", + ], + 'orphan_credit_ticket_item_ref' => [ + 'message' => '信用流水引用了不存在的注单明细', + 'sql' => "select count(*) as cnt from credit_ledger cl where cl.ref_type = 'ticket_item' and not exists (select 1 from ticket_items ti where ti.id = cl.ref_id)", + ], + 'orphan_credit_bill_ref' => [ + 'message' => '信用流水引用了不存在的结算账单', + 'sql' => "select count(*) as cnt from credit_ledger cl where cl.ref_type = 'settlement_bill' and not exists (select 1 from settlement_bills sb where sb.id = cl.ref_id)", + ], + ]); + } + + /** + * @return list + */ + private function settlementIssues(): array + { + return $this->countIssues([ + 'settlement_bill_math_bad' => [ + 'message' => '结算账单金额闭环异常(已付 + 未付 + 坏账核销不等于应结绝对值)', + 'sql' => "select count(*) as cnt from settlement_bills where abs(net_amount) <> paid_amount + unpaid_amount + case when bill_type <> 'bad_debt' then coalesce(cast(meta_json ->> 'written_off_amount' as bigint), 0) else 0 end", + ], + 'settlement_bill_negative_paid_unpaid' => [ + 'message' => '结算账单已付或未付金额为负数', + 'sql' => 'select count(*) as cnt from settlement_bills where paid_amount < 0 or unpaid_amount < 0', + ], + ]); + } + + /** + * @return list + */ + private function paymentIssues(): array + { + return $this->countIssues([ + 'payment_amount_nonpositive' => [ + 'message' => '收付款记录金额小于等于 0', + 'sql' => 'select count(*) as cnt from payment_records where amount <= 0', + ], + 'confirmed_payment_missing_time' => [ + 'message' => '已确认收付款记录缺少确认时间', + 'sql' => "select count(*) as cnt from payment_records where status = 'confirmed' and confirmed_at is null", + ], + 'bill_paid_mismatch_confirmed_payments' => [ + 'message' => '账单已付金额与已确认收付款汇总不一致', + 'sql' => "with p as (select settlement_bill_id, coalesce(sum(amount),0) confirmed_amount from payment_records where status = 'confirmed' group by settlement_bill_id) select count(*) as cnt from settlement_bills b left join p on p.settlement_bill_id = b.id where b.paid_amount <> coalesce(p.confirmed_amount,0)", + ], + ]); + } + + /** + * @param array $checks + * @return list + */ + private function countIssues(array $checks): array + { + $issues = []; + + foreach ($checks as $type => $check) { + $count = (int) (DB::selectOne($check['sql'])->cnt ?? 0); + if ($count > 0) { + $issues[] = [ + 'type' => $type, + 'message' => $check['message'], + 'count' => $count, + ]; + } + } + + return $issues; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php index 3fc6799..f392b78 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeAdminUserStoreController.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Services\Agent\AgentAdminUserService; use App\Lottery\ErrorCode; use App\Support\AdminAgentNodeAccess; +use App\Support\AdminAgentScope; use App\Support\AdminUserApiPresenter; use App\Support\ApiMessage; use App\Http\Requests\Admin\AgentAdminUserStoreRequest; @@ -39,6 +40,17 @@ final class AgentNodeAdminUserStoreController extends Controller ); } + if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node) + ?? ApiMessage::errorResponse( + $request, + 'admin.agent_user_manage_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + $user = $service->createUnderAgent($agent_node, $request->validated()); AuditLogger::recordForAdmin( diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php index 23de8db..1b05a9f 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeDestroyController.php @@ -47,6 +47,14 @@ final class AgentNodeDestroyController extends Controller return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); } + if (DB::table('admin_user_agents')->where('agent_node_id', $agent_node->id)->exists()) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_admin_users_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + } + + if ($service->hasBlockingCustomRoles($agent_node)) { + return ApiMessage::errorResponse($request, 'admin.agent_node_has_roles_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); + } + $before = AgentNodePresenter::item($agent_node); $service->destroy($agent_node); diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php index e2de0e6..23de3e7 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Services\Agent\AgentRoleService; use App\Lottery\ErrorCode; use App\Support\AdminAgentNodeAccess; +use App\Support\AdminAgentScope; use App\Support\AdminRoleApiPresenter; use App\Support\ApiMessage; use App\Http\Requests\Admin\AgentRoleStoreRequest; @@ -39,6 +40,17 @@ final class AgentNodeRoleStoreController extends Controller ); } + if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) { + return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node) + ?? ApiMessage::errorResponse( + $request, + 'admin.agent_role_manage_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + $role = $service->createForAgent($admin, $agent_node, $request->validated()); AuditLogger::recordForAdmin( diff --git a/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php index 96e9e71..0726587 100644 --- a/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminSettingBatchUpdateRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin; use App\Models\AdminUser; use App\Http\Requests\ApiFormRequest; +use App\Support\AdminSettingPolicy; final class AdminSettingBatchUpdateRequest extends ApiFormRequest { @@ -22,8 +23,8 @@ final class AdminSettingBatchUpdateRequest extends ApiFormRequest foreach ($items as $item) { $key = is_array($item) ? (string) ($item['key'] ?? '') : ''; - if (str_starts_with($key, 'settlement.')) { - return $admin->hasAdminPermission('prd.payout.manage'); + if (! AdminSettingPolicy::canUpdate($admin, $key)) { + return false; } } @@ -38,4 +39,15 @@ final class AdminSettingBatchUpdateRequest extends ApiFormRequest 'items.*.value' => ['present'], ]; } + + public function after(): array + { + return [ + function (): void { + /** @var list $items */ + $items = $this->validated('items', []); + AdminSettingPolicy::validateItems($items); + }, + ]; + } } diff --git a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php index 2cb2ac6..6fb1c38 100644 --- a/app/Http/Requests/Admin/AdminSettingUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminSettingUpdateRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin; use App\Models\AdminUser; use App\Http\Requests\ApiFormRequest; +use App\Support\AdminSettingPolicy; final class AdminSettingUpdateRequest extends ApiFormRequest { @@ -15,11 +16,7 @@ final class AdminSettingUpdateRequest extends ApiFormRequest } $key = (string) $this->route('key', ''); - if (str_starts_with($key, 'settlement.')) { - return $admin->hasAdminPermission('prd.payout.manage'); - } - - return true; + return AdminSettingPolicy::canUpdate($admin, $key); } public function rules(): array @@ -28,4 +25,17 @@ final class AdminSettingUpdateRequest extends ApiFormRequest 'value' => ['present'], ]; } + + public function after(): array + { + return [ + function (): void { + $key = (string) $this->route('key', ''); + AdminSettingPolicy::validateItems([[ + 'key' => $key, + 'value' => $this->validated('value'), + ]]); + }, + ]; + } } diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index a63a2af..d65f8a4 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -443,7 +443,7 @@ final class AdminUser extends Authenticatable return false; } - return count(array_intersect($needed, $effective)) > 0; + return count(array_diff($needed, $effective)) === 0; } /** diff --git a/app/Services/Admin/AdminDashboardSnapshotBuilder.php b/app/Services/Admin/AdminDashboardSnapshotBuilder.php index 2bfe220..aaab989 100644 --- a/app/Services/Admin/AdminDashboardSnapshotBuilder.php +++ b/app/Services/Admin/AdminDashboardSnapshotBuilder.php @@ -133,7 +133,6 @@ final class AdminDashboardSnapshotBuilder { return $admin->hasPermissionCode('service.reconcile.manage') || $admin->hasPermissionCode('service.reconcile.view') - || $admin->hasPermissionCode('service.wallet.view') || $admin->hasPermissionCode('service.wallet.manage'); } diff --git a/app/Services/Admin/AgentDashboardOverviewBuilder.php b/app/Services/Admin/AgentDashboardOverviewBuilder.php index c0ee8ed..cdc0984 100644 --- a/app/Services/Admin/AgentDashboardOverviewBuilder.php +++ b/app/Services/Admin/AgentDashboardOverviewBuilder.php @@ -6,13 +6,20 @@ use App\Models\AdminUser; use App\Models\AgentNode; use App\Models\AgentProfile; use App\Models\Player; +use App\Support\AdminScopeContext; +use App\Support\AdminScopeContextResolver; use App\Support\AdminAgentSettlementScope; +use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; /** 代理账号仪表盘:授信、团队规模、待结账单摘要。 */ final class AgentDashboardOverviewBuilder { + public function __construct( + private readonly AdminReportQueryService $reportQuery, + ) {} + /** * @return array|null */ @@ -46,6 +53,16 @@ final class AgentDashboardOverviewBuilder } $pendingBillStats = $this->pendingBillStats($admin, $subtreeIds); + $scope = AdminScopeContextResolver::fromValues($admin, requestedAgentNodeId: (int) $node->id); + $today = now()->toDateString(); + $sevenDayFrom = now()->subDays(6)->toDateString(); + $todayTotals = $this->reportQuery->periodFinanceTotals($today, $today, $scope); + $sevenDayTotals = $this->reportQuery->periodFinanceTotals($sevenDayFrom, $today, $scope); + $currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scope) + ?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scope); + $teamPlayerStats = $this->teamPlayerStats($subtreeIds); + $todayActivityStats = $this->todayActivityStats($subtreeIds, $today); + $topAgentToday = $this->topAgentToday($scope, $today, (int) $node->id); return [ 'agent_node_id' => (int) $node->id, @@ -61,13 +78,25 @@ final class AgentDashboardOverviewBuilder ), 'total_share_rate' => (float) ($profile?->total_share_rate ?? 0), 'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'), - 'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, - 'can_create_player' => $profile === null || $profile->can_create_player, + 'can_create_child_agent' => (bool) ($profile?->can_create_child_agent ?? false), + 'can_create_player' => (bool) ($profile?->can_create_player ?? false), 'direct_child_count' => $directChildCount, 'subtree_agent_count' => count($subtreeIds), 'direct_player_count' => $directPlayerCount, + 'team_player_count' => $teamPlayerStats['count'], + 'active_player_count_today' => $todayActivityStats['player_count'], + 'bet_order_count_today' => $todayActivityStats['order_count'], + 'today_bet_minor' => $todayTotals['total_bet_minor'], + 'today_payout_minor' => $todayTotals['total_payout_minor'], + 'today_profit_minor' => $todayTotals['approx_house_gross_minor'], + 'seven_day_bet_minor' => $sevenDayTotals['total_bet_minor'], + 'seven_day_payout_minor' => $sevenDayTotals['total_payout_minor'], + 'seven_day_profit_minor' => $sevenDayTotals['approx_house_gross_minor'], + 'currency_code' => $currencyCode, 'pending_bill_count' => $pendingBillStats['count'], 'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'], + 'latest_bet_at' => $todayActivityStats['latest_bet_at'], + 'top_agent_today' => $topAgentToday, ]; } @@ -94,4 +123,64 @@ final class AgentDashboardOverviewBuilder 'unpaid_minor' => (int) $query->sum('unpaid_amount'), ]; } + + /** + * @param list $subtreeIds + * @return array{count: int} + */ + private function teamPlayerStats(array $subtreeIds): array + { + if ($subtreeIds === [] || ! Schema::hasColumn('players', 'agent_node_id')) { + return ['count' => 0]; + } + + return [ + 'count' => (int) Player::query() + ->whereIn('agent_node_id', $subtreeIds) + ->count(), + ]; + } + + /** + * @param list $subtreeIds + * @return array{player_count: int, order_count: int, latest_bet_at: ?string} + */ + private function todayActivityStats(array $subtreeIds, string $today): array + { + if ($subtreeIds === []) { + return [ + 'player_count' => 0, + 'order_count' => 0, + 'latest_bet_at' => null, + ]; + } + + $base = DB::table('ticket_orders as o') + ->join('players as p', 'p.id', '=', 'o.player_id') + ->whereIn('p.agent_node_id', $subtreeIds) + ->whereDate('o.created_at', $today); + + $latestBetAt = (clone $base)->max('o.created_at'); + + return [ + 'player_count' => (int) (clone $base)->distinct('o.player_id')->count('o.player_id'), + 'order_count' => (int) (clone $base)->count(), + 'latest_bet_at' => $latestBetAt !== null ? Carbon::parse((string) $latestBetAt)->toIso8601String() : null, + ]; + } + + /** + * @return array|null + */ + private function topAgentToday(AdminScopeContext $scope, string $today, int $currentAgentId): ?array + { + $rows = $this->reportQuery->agentRankingRows($today, $today, null, 5, $scope); + foreach ($rows as $row) { + if ((int) ($row['agent_node_id'] ?? 0) === $currentAgentId) { + return $row; + } + } + + return $rows[0] ?? null; + } } diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index 84e05d6..0416b9d 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -274,14 +274,14 @@ final class AgentProfileService { $profile = $this->profileForNode($agentNodeId); - return $profile === null || $profile->can_create_child_agent; + return (bool) ($profile?->can_create_child_agent ?? false); } public function nodeMayCreatePlayer(int $agentNodeId): bool { $profile = $this->profileForNode($agentNodeId); - return $profile === null || $profile->can_create_player; + return (bool) ($profile?->can_create_player ?? false); } private function assertAgentProfileExists(AgentNode $agent): void diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index e32d2bd..4d3aa3a 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -520,6 +520,15 @@ final class SettlementCenterLedgerService array_values($creditById), ), static fn (int $id): bool => $id > 0)), ); + $creditBillRefs = $this->settlementBillsByIds( + $admin, + array_values(array_filter(array_map( + static fn (object $row): int => (string) ($row->ref_type ?? '') === 'settlement_bill' + ? (int) ($row->ref_id ?? 0) + : 0, + array_values($creditById), + ), static fn (int $id): bool => $id > 0)), + ); $paymentById = []; if ($paymentIds !== []) { @@ -556,7 +565,10 @@ final class SettlementCenterLedgerService if ($kind === 'credit' && isset($creditById[$id])) { $row = $creditById[$id]; $pid = (int) $row->player_id; - $items[] = $this->formatCreditEntry($row, $playerBills[$pid] ?? null, $ticketRefs); + $bill = (string) ($row->ref_type ?? '') === 'settlement_bill' + ? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null) + : ($playerBills[$pid] ?? null); + $items[] = $this->formatCreditEntry($row, $bill, $ticketRefs); } elseif ($kind === 'payment' && isset($paymentById[$id])) { $items[] = $this->formatPaymentEntry($paymentById[$id]); } elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) { @@ -569,6 +581,40 @@ final class SettlementCenterLedgerService return $items; } + /** + * @param list $ids + * @return array + */ + private function settlementBillsByIds(AdminUser $admin, array $ids): array + { + $ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0))); + if ($ids === []) { + return []; + } + + $query = DB::table('settlement_bills as sb') + ->whereIn('sb.id', $ids) + ->select([ + 'sb.id', + 'sb.owner_id as player_id', + 'sb.status', + 'sb.bill_type', + 'sb.net_amount', + 'sb.unpaid_amount', + 'sb.paid_amount', + 'sb.settlement_period_id', + ]); + + AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb'); + + $map = []; + foreach ($query->get() as $bill) { + $map[(int) $bill->id] = $bill; + } + + return $map; + } + /** * @return array{0: Carbon|null, 1: Carbon|null} */ @@ -1273,6 +1319,8 @@ final class SettlementCenterLedgerService private function formatPaymentEntry(object $row): array { $amount = (int) $row->amount; + $method = trim((string) ($row->method ?? '')); + $methodLabel = $method !== '' && ! ctype_digit($method) ? ' · '.$method : ''; return array_merge( $this->baseRow( @@ -1289,7 +1337,7 @@ final class SettlementCenterLedgerService billStatus: (string) ($row->bill_status ?? ''), billType: (string) ($row->bill_type ?? ''), billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, - refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''), + refLabel: 'bill#'.$row->settlement_bill_id.$methodLabel, ), $this->partyFieldsFromRow($row), ); diff --git a/app/Support/AdminAuthProfile.php b/app/Support/AdminAuthProfile.php index 97e3043..8026b1b 100644 --- a/app/Support/AdminAuthProfile.php +++ b/app/Support/AdminAuthProfile.php @@ -104,8 +104,8 @@ final class AdminAuthProfile 'code' => (string) $node->code, 'name' => (string) $node->name, 'depth' => (int) $node->depth, - 'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, - 'can_create_player' => $profile === null || $profile->can_create_player, + 'can_create_child_agent' => (bool) ($profile?->can_create_child_agent ?? false), + 'can_create_player' => (bool) ($profile?->can_create_player ?? false), ]; } } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 2976969..876feae 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -152,7 +152,7 @@ final class AdminAuthorizationRegistry ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'nav_group' => 'operations', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'nav_group' => 'operations', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], - ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance'], 'agent_hidden' => true], + ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'nav_group' => 'finance', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage'], 'agent_hidden' => true], ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'nav_group' => 'finance', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'agent_hidden' => true], ['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'nav_group' => 'finance', 'requiredAny' => ['prd.report.view']], ['segment' => 'rules_plays', 'label' => 'Play rules', 'href' => '/admin/rules/plays', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view']], @@ -164,7 +164,7 @@ final class AdminAuthorizationRegistry ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']], - ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']], + ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']], ]; } @@ -477,8 +477,8 @@ final class AdminAuthorizationRegistry ['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.risk_cap.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.risk_cap.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', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], - ['code' => 'admin.settings.batch-update', 'module_code' => 'settings', 'name' => '系统设置批量更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/batch', 'route_name' => 'api.v1.admin.settings.batch-update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.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', 'prd.rebate.manage', 'prd.payout.manage']], + ['code' => 'admin.settings.batch-update', 'module_code' => 'settings', 'name' => '系统设置批量更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/batch', 'route_name' => 'api.v1.admin.settings.batch-update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust', 'config.odds.manage', 'settlement.batch.manage', 'draw.review.review', 'draw.review.publish']], + ['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, 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust', 'config.odds.manage', 'settlement.batch.manage', 'draw.review.review', 'draw.review.publish']], ['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.store', 'module_code' => 'settings', 'name' => '创建币种', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.update', 'module_code' => 'settings', 'name' => '更新币种', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], @@ -489,7 +489,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.integration-sites.show', 'module_code' => 'integration', 'name' => '接入站点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], ['code' => 'admin.integration-sites.update', 'module_code' => 'integration', 'name' => '更新接入站点', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}', 'route_name' => 'api.v1.admin.integration-sites.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.rotate-secrets', 'module_code' => 'integration', 'name' => '重置接入密钥', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/rotate-secrets', 'route_name' => 'api.v1.admin.integration-sites.rotate-secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], - ['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], + ['code' => 'admin.integration-sites.connectivity-test', 'module_code' => 'integration', 'name' => '接入站点联通检测', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/connectivity-test', 'route_name' => 'api.v1.admin.integration-sites.connectivity-test', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.manage']], ['code' => 'admin.integration-sites.export', 'module_code' => 'integration', 'name' => '导出接入参数表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/export', 'route_name' => 'api.v1.admin.integration-sites.export', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']], ['code' => 'admin.integration-sites.secrets', 'module_code' => 'integration', 'name' => '查看接入密钥明文', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites/{admin_site}/secrets', 'route_name' => 'api.v1.admin.integration-sites.secrets', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['integration.site.manage']], @@ -514,16 +514,16 @@ final class AdminAuthorizationRegistry ['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', 'prd.risk.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.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, 'permission_codes' => ['settlement.batch.manage', 'settlement.batch.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.settlement-batches.adjustments.store', 'module_code' => 'settlement', 'name' => '结算补差调账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/adjustments', 'route_name' => 'api.v1.admin.settlement-batches.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.payout.manage']], + ['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, 'permission_codes' => ['settlement.batch.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, 'permission_codes' => ['settlement.batch.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, 'permission_codes' => ['settlement.batch.manage']], + ['code' => 'admin.settlement-batches.adjustments.store', 'module_code' => 'settlement', 'name' => '结算补差调账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-batches/{batch}/adjustments', 'route_name' => 'api.v1.admin.settlement-batches.adjustments.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.batch.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']], @@ -540,11 +540,11 @@ final class AdminAuthorizationRegistry ['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']], ['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, 'permission_codes' => ['service.players.freeze']], ['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, 'permission_codes' => ['service.players.freeze']], - ['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, 'permission_codes' => ['service.players.manage', 'service.wallet.view']], + ['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, 'permission_codes' => ['service.wallet.view']], ['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, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], ['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']], - ['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', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']], + ['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', 'prd.wallet_adjust.manage']], ['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_adjust.manage', '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_adjust.manage', 'prd.wallet_reconcile.manage']], diff --git a/app/Support/AdminSettingPolicy.php b/app/Support/AdminSettingPolicy.php new file mode 100644 index 0000000..32a9380 --- /dev/null +++ b/app/Support/AdminSettingPolicy.php @@ -0,0 +1,83 @@ +hasAdminPermission('prd.payout.manage'); + } + + if (str_starts_with($key, 'draw.')) { + return $admin->hasAdminPermission('prd.draw_result.manage'); + } + + if (str_starts_with($key, 'frontend.')) { + return $admin->hasAdminPermission('prd.odds.manage') + || $admin->hasAdminPermission('prd.rebate.manage'); + } + + if (str_starts_with($key, 'wallet.')) { + return $admin->hasAdminPermission('prd.wallet_reconcile.manage') + || $admin->hasAdminPermission('prd.wallet_adjust.manage'); + } + + return true; + } + + /** + * @param list $items + */ + public static function validateItems(array $items): void + { + $values = []; + foreach ($items as $index => $item) { + $key = (string) $item['key']; + if (! in_array($key, self::WALLET_LIMIT_KEYS, true)) { + continue; + } + + $value = $item['value']; + if (! is_int($value) || $value < 1) { + throw ValidationException::withMessages([ + "items.$index.value" => ['钱包转账限额必须是大于等于 1 的整数最小货币单位。'], + ]); + } + + $values[$key] = $value; + } + + self::validateWalletRange($values, 'wallet.transfer_in_min_minor', 'wallet.transfer_in_max_minor'); + self::validateWalletRange($values, 'wallet.transfer_out_min_minor', 'wallet.transfer_out_max_minor'); + } + + /** + * @param array $values + */ + private static function validateWalletRange(array $values, string $minKey, string $maxKey): void + { + $min = $values[$minKey] ?? max(1, (int) LotterySettings::get($minKey, 1)); + $max = $values[$maxKey] ?? max(1, (int) LotterySettings::get($maxKey, 1)); + + if ($max >= $min) { + return; + } + + throw ValidationException::withMessages([ + 'items' => ['钱包转账最大金额不能小于最小金额。'], + ]); + } +} diff --git a/app/Support/AgentDefaultRolePermissions.php b/app/Support/AgentDefaultRolePermissions.php index 3bfed2a..6bfd706 100644 --- a/app/Support/AgentDefaultRolePermissions.php +++ b/app/Support/AgentDefaultRolePermissions.php @@ -32,7 +32,6 @@ final class AgentDefaultRolePermissions private const PLAYER_MANAGE_SLUGS = [ 'prd.users.manage', - 'prd.users.view_finance', 'prd.users.view_cs', ]; @@ -43,7 +42,6 @@ final class AgentDefaultRolePermissions 'prd.agent.role.manage', 'prd.agent.user.manage', 'prd.users.manage', - 'prd.users.view_finance', 'prd.users.view_cs', 'prd.settlement.agent.manage', ]; @@ -106,10 +104,7 @@ final class AgentDefaultRolePermissions */ public static function defaultOwnerSlugsWithoutProfile(): array { - return array_values(array_unique(array_merge( - self::BASE_SLUGS, - self::PLAYER_MANAGE_SLUGS, - ))); + return self::BASE_SLUGS; } /** diff --git a/app/Support/AgentProfileCapabilityFilter.php b/app/Support/AgentProfileCapabilityFilter.php index 134b6a9..f1cc1d8 100644 --- a/app/Support/AgentProfileCapabilityFilter.php +++ b/app/Support/AgentProfileCapabilityFilter.php @@ -32,7 +32,6 @@ final class AgentProfileCapabilityFilter /** @var list */ private const PLAYER_LEGACY_SLUGS = [ 'prd.users.manage', - 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage', ]; @@ -45,10 +44,6 @@ final class AgentProfileCapabilityFilter */ public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array { - if ($profile === null) { - return $permissionCodes; - } - $set = []; foreach ($permissionCodes as $code) { if (is_string($code) && $code !== '') { @@ -56,7 +51,7 @@ final class AgentProfileCapabilityFilter } } - if (! $profile->can_create_child_agent) { + if (! ($profile?->can_create_child_agent ?? false)) { foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) { unset($set[$code]); } @@ -66,7 +61,7 @@ final class AgentProfileCapabilityFilter } } - if (! $profile->can_create_player) { + if (! ($profile?->can_create_player ?? false)) { foreach (self::PLAYER_PERMISSION_CODES as $code) { unset($set[$code]); } @@ -97,15 +92,11 @@ final class AgentProfileCapabilityFilter */ public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array { - if ($profile === null) { - return $legacySlugs; - } - $deny = []; - if (! $profile->can_create_child_agent) { + if (! ($profile?->can_create_child_agent ?? false)) { $deny = array_merge($deny, self::CHILD_AGENT_LEGACY_SLUGS); } - if (! $profile->can_create_player) { + if (! ($profile?->can_create_player ?? false)) { $deny = array_merge($deny, self::PLAYER_LEGACY_SLUGS); } diff --git a/tests/Feature/AdminAgentDelegationApiTest.php b/tests/Feature/AdminAgentDelegationApiTest.php index 2ccb0a7..25bebba 100644 --- a/tests/Feature/AdminAgentDelegationApiTest.php +++ b/tests/Feature/AdminAgentDelegationApiTest.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + ensureAdminActionCatalogSeeded(); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); diff --git a/tests/Feature/AdminAgentLineApiTest.php b/tests/Feature/AdminAgentLineApiTest.php index f91ed84..8269092 100644 --- a/tests/Feature/AdminAgentLineApiTest.php +++ b/tests/Feature/AdminAgentLineApiTest.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + ensureAdminActionCatalogSeeded(); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); diff --git a/tests/Feature/AdminAgentNodeApiTest.php b/tests/Feature/AdminAgentNodeApiTest.php index ce51a90..372ba17 100644 --- a/tests/Feature/AdminAgentNodeApiTest.php +++ b/tests/Feature/AdminAgentNodeApiTest.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + ensureAdminActionCatalogSeeded(); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); @@ -209,6 +210,58 @@ test('agent operator can create child under own node but not under sibling', fun ->assertForbidden(); }); +test('agent operator cannot create role or admin user under descendant node', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = agentRootNodeId($siteId); + $service = app(\App\Services\Agent\AgentNodeService::class); + $super = AdminUser::query()->create([ + 'username' => 'bootstrap4', + 'name' => 'Bootstrap', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $nodeA = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'branch-a3', + 'name' => 'Branch A3', + ])); + $child = $service->createChild($super, agentChildPayload([ + 'parent_id' => $nodeA->id, + 'code' => 'branch-a3-child', + 'name' => 'Branch A3 Child', + ])); + + $operator = AdminUser::query()->create([ + 'username' => 'agent_a3_ops', + 'name' => 'A3 Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantAgentOperatorRole($operator, $nodeA); + $token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$child->id.'/roles', [ + 'slug' => 'child_role', + 'name' => 'Child Role', + 'permission_slugs' => ['prd.agent.view'], + ]) + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/agent-nodes/'.$child->id.'/admin-users', [ + 'username' => 'child_admin', + 'nickname' => 'Child Admin', + 'password' => 'Secret123!', + 'role_ids' => [], + ]) + ->assertForbidden(); +}); + test('auth me returns agent context for bound operator', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $rootId = agentRootNodeId($siteId); diff --git a/tests/Feature/AdminAgentProfileApiTest.php b/tests/Feature/AdminAgentProfileApiTest.php index d49a557..cc2ac7e 100644 --- a/tests/Feature/AdminAgentProfileApiTest.php +++ b/tests/Feature/AdminAgentProfileApiTest.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function (): void { + ensureAdminActionCatalogSeeded(); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); diff --git a/tests/Feature/AdminCreditLedgerFilterTest.php b/tests/Feature/AdminCreditLedgerFilterTest.php index 06aace6..900b308 100644 --- a/tests/Feature/AdminCreditLedgerFilterTest.php +++ b/tests/Feature/AdminCreditLedgerFilterTest.php @@ -152,6 +152,89 @@ test('credit ledger index includes payment on agent bill', function (): void { ->assertJsonPath('data.items.0.bill_type', 'agent'); }); +test('credit ledger settlement bill reference keeps the referenced bill id', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:bill-ref-ledger', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'bill_ref_ledger_user', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $referencedBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => -100, + 'unpaid_amount' => 0, + 'paid_amount' => 100, + 'status' => 'settled', + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + + $newerBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => -200, + 'unpaid_amount' => 0, + 'paid_amount' => 200, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 100, + 'reason' => 'settlement_payout', + 'ref_type' => 'settlement_bill', + 'ref_id' => $referencedBillId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'ledger_bill_ref_super', + 'name' => 'Ledger Bill Ref', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&reason=settlement_payout') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'credit') + ->assertJsonPath('data.items.0.settlement_bill_id', $referencedBillId) + ->assertJsonPath('data.items.0.ref_id', $referencedBillId); +}); + test('credit ledger entry_kind share returns share ledger rows', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $siteId = (int) $site->id; diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php index 7300e75..002d046 100644 --- a/tests/Feature/AdminSettingBatchUpdateTest.php +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -157,3 +157,56 @@ test('non payout manager cannot update single settlement setting', function (): expect(LotterySetting::query()->where('setting_key', 'settlement.auto_approve_on_tick')->value('value_json'))->toBeTrue(); }); + +test('wallet limits must be positive integer minor units', function (): void { + LotterySettings::put('wallet.transfer_in_min_minor', 100, 'wallet'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'wallet.transfer_in_min_minor', 'value' => 0], + ], + ]) + ->assertUnprocessable(); + + expect(LotterySetting::query()->where('setting_key', 'wallet.transfer_in_min_minor')->value('value_json'))->toBe(100); +}); + +test('wallet max limit cannot be less than current min limit', function (): void { + LotterySettings::put('wallet.transfer_in_min_minor', 100, 'wallet'); + LotterySettings::put('wallet.transfer_in_max_minor', 10_000, 'wallet'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/wallet.transfer_in_max_minor', [ + 'value' => 50, + ]) + ->assertUnprocessable(); + + expect(LotterySetting::query()->where('setting_key', 'wallet.transfer_in_max_minor')->value('value_json'))->toBe(10_000); +}); + +test('settings updates require permission for their setting group', function (): void { + LotterySettings::put('draw.interval_minutes', 5, 'draw'); + LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); + + $token = settingsReadOnlyToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/draw.interval_minutes', [ + 'value' => 10, + ]) + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/frontend.play_rules_html_zh', [ + 'value' => '
new
', + ]) + ->assertOk(); + + expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(5) + ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
new
'); +}); diff --git a/tests/Feature/FinancialChainAuditCommandTest.php b/tests/Feature/FinancialChainAuditCommandTest.php new file mode 100644 index 0000000..45abb5e --- /dev/null +++ b/tests/Feature/FinancialChainAuditCommandTest.php @@ -0,0 +1,185 @@ +id(); + $table->string('site_code'); + $table->string('site_player_id'); + $table->string('username')->nullable(); + $table->string('nickname')->nullable(); + $table->string('default_currency')->default('NPR'); + $table->string('funding_mode')->default('wallet'); + $table->smallInteger('status')->default(0); + $table->timestamps(); + }); + + Schema::create('player_wallets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('player_id'); + $table->string('wallet_type'); + $table->string('currency_code'); + $table->bigInteger('balance')->default(0); + $table->bigInteger('frozen_balance')->default(0); + $table->smallInteger('status')->default(0); + $table->integer('version')->default(0); + $table->timestamps(); + }); + + Schema::create('wallet_txns', function (Blueprint $table): void { + $table->id(); + $table->string('txn_no'); + $table->foreignId('player_id'); + $table->foreignId('wallet_id'); + $table->string('biz_type'); + $table->string('biz_no')->nullable(); + $table->smallInteger('direction'); + $table->bigInteger('amount'); + $table->bigInteger('balance_before'); + $table->bigInteger('balance_after'); + $table->string('status'); + $table->string('external_ref_no')->nullable(); + $table->string('idempotent_key')->nullable(); + $table->string('remark')->nullable(); + $table->timestamps(); + }); + + Schema::create('transfer_orders', function (Blueprint $table): void { + $table->id(); + $table->string('transfer_no'); + $table->foreignId('player_id'); + $table->string('direction'); + $table->string('currency_code'); + $table->bigInteger('amount'); + $table->string('idempotent_key'); + $table->string('status'); + $table->timestamps(); + }); + + Schema::create('player_credit_accounts', function (Blueprint $table): void { + $table->foreignId('player_id')->primary(); + $table->bigInteger('credit_limit')->default(0); + $table->bigInteger('used_credit')->default(0); + $table->bigInteger('frozen_credit')->default(0); + $table->timestamps(); + }); + + Schema::create('credit_ledger', function (Blueprint $table): void { + $table->id(); + $table->string('owner_type'); + $table->unsignedBigInteger('owner_id'); + $table->bigInteger('amount'); + $table->string('reason'); + $table->string('ref_type')->nullable(); + $table->unsignedBigInteger('ref_id')->nullable(); + $table->timestamps(); + }); + + Schema::create('ticket_items', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('settlement_bills', function (Blueprint $table): void { + $table->id(); + $table->string('bill_type')->default('player'); + $table->bigInteger('net_amount')->default(0); + $table->bigInteger('paid_amount')->default(0); + $table->bigInteger('unpaid_amount')->default(0); + $table->json('meta_json')->nullable(); + }); + + Schema::create('payment_records', function (Blueprint $table): void { + $table->id(); + $table->foreignId('settlement_bill_id'); + $table->bigInteger('amount'); + $table->string('status'); + $table->timestamp('confirmed_at')->nullable(); + }); +}); + +test('financial chain audit passes for consistent wallet ledger', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'financial-audit-ok', + 'username' => 'financial_audit_ok', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 1, + ]); + + WalletTxn::query()->create([ + 'txn_no' => 'WX_financial_audit_ok', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'manual_seed', + 'biz_no' => 'manual_seed_ok', + 'direction' => 1, + 'amount' => 1000, + 'balance_before' => 0, + 'balance_after' => 1000, + 'status' => 'posted', + 'idempotent_key' => 'financial-audit-ok', + ]); + + $this->artisan('lottery:audit-financial-chain') + ->expectsOutputToContain('Financial chain audit passed.') + ->assertExitCode(0); +}); + +test('financial chain audit reports wallet balance drift', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'financial-audit-bad', + 'username' => 'financial_audit_bad', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 900, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 1, + ]); + + WalletTxn::query()->create([ + 'txn_no' => 'WX_financial_audit_bad', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'manual_seed', + 'biz_no' => 'manual_seed_bad', + 'direction' => 1, + 'amount' => 1000, + 'balance_before' => 0, + 'balance_after' => 1000, + 'status' => 'posted', + 'idempotent_key' => 'financial-audit-bad', + ]); + + $this->artisan('lottery:audit-financial-chain') + ->expectsOutputToContain('Financial chain audit found') + ->expectsOutputToContain('[wallet_latest_mismatch]') + ->assertExitCode(1); +}); diff --git a/tests/Pest.php b/tests/Pest.php index aa28941..d85bdd8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,7 +2,9 @@ use Tests\TestCase; use App\Models\AdminUser; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Foundation\Testing\RefreshDatabase; /* @@ -104,3 +106,32 @@ function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void ], ); } + +function ensureAdminActionCatalogSeeded(): void +{ + if (! Schema::hasTable('admin_action_catalog')) { + Schema::create('admin_action_catalog', static function ($table): void { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->unsignedTinyInteger('status')->default(1); + $table->timestamps(); + }); + } + + $now = Carbon::now(); + foreach ([ + ['code' => 'view', 'name' => '查看'], + ['code' => 'manage', 'name' => '管理'], + ] as $row) { + DB::table('admin_action_catalog')->updateOrInsert( + ['code' => $row['code']], + [ + 'name' => $row['name'], + 'status' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } +}