feat: 增强后台设置校验、代理权限控制与财务审计能力

This commit is contained in:
2026-06-09 13:44:08 +08:00
parent 8d5d7f5b17
commit 41b964a606
25 changed files with 894 additions and 49 deletions

View File

@@ -78,7 +78,7 @@ final class AuditAgentLineDataCommand extends Command
->select('admin_site_id', DB::raw('count(*) as cnt')) ->select('admin_site_id', DB::raw('count(*) as cnt'))
->where('depth', '>', 0) ->where('depth', '>', 0)
->groupBy('admin_site_id') ->groupBy('admin_site_id')
->having('cnt', '>', 0) ->havingRaw('count(*) > 0')
->get(); ->get();
foreach ($sitesWithManyBusinessAgents as $row) { foreach ($sitesWithManyBusinessAgents as $row) {

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AuditFinancialChainCommand extends Command
{
protected $signature = 'lottery:audit-financial-chain {--json : Output JSON only}';
protected $description = '只读审计钱包、转账、授信、结算与收付款资金链闭环';
public function handle(): int
{
$issues = array_merge(
$this->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<array{type: string, message: string, count: int}>
*/
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<array{type: string, message: string, count: int}>
*/
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<array{type: string, message: string, count: int}>
*/
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<array{type: string, message: string, count: int}>
*/
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<array{type: string, message: string, count: int}>
*/
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<string, array{message: string, sql: string}> $checks
* @return list<array{type: string, message: string, count: int}>
*/
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;
}
}

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Controller;
use App\Services\Agent\AgentAdminUserService; use App\Services\Agent\AgentAdminUserService;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\AdminAgentNodeAccess; use App\Support\AdminAgentNodeAccess;
use App\Support\AdminAgentScope;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Http\Requests\Admin\AgentAdminUserStoreRequest; 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()); $user = $service->createUnderAgent($agent_node, $request->validated());
AuditLogger::recordForAdmin( AuditLogger::recordForAdmin(

View File

@@ -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); 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); $before = AgentNodePresenter::item($agent_node);
$service->destroy($agent_node); $service->destroy($agent_node);

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Controller;
use App\Services\Agent\AgentRoleService; use App\Services\Agent\AgentRoleService;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\AdminAgentNodeAccess; use App\Support\AdminAgentNodeAccess;
use App\Support\AdminAgentScope;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Http\Requests\Admin\AgentRoleStoreRequest; 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()); $role = $service->createForAgent($admin, $agent_node, $request->validated());
AuditLogger::recordForAdmin( AuditLogger::recordForAdmin(

View File

@@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Http\Requests\ApiFormRequest; use App\Http\Requests\ApiFormRequest;
use App\Support\AdminSettingPolicy;
final class AdminSettingBatchUpdateRequest extends ApiFormRequest final class AdminSettingBatchUpdateRequest extends ApiFormRequest
{ {
@@ -22,8 +23,8 @@ final class AdminSettingBatchUpdateRequest extends ApiFormRequest
foreach ($items as $item) { foreach ($items as $item) {
$key = is_array($item) ? (string) ($item['key'] ?? '') : ''; $key = is_array($item) ? (string) ($item['key'] ?? '') : '';
if (str_starts_with($key, 'settlement.')) { if (! AdminSettingPolicy::canUpdate($admin, $key)) {
return $admin->hasAdminPermission('prd.payout.manage'); return false;
} }
} }
@@ -38,4 +39,15 @@ final class AdminSettingBatchUpdateRequest extends ApiFormRequest
'items.*.value' => ['present'], 'items.*.value' => ['present'],
]; ];
} }
public function after(): array
{
return [
function (): void {
/** @var list<array{key: string, value: mixed}> $items */
$items = $this->validated('items', []);
AdminSettingPolicy::validateItems($items);
},
];
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Http\Requests\ApiFormRequest; use App\Http\Requests\ApiFormRequest;
use App\Support\AdminSettingPolicy;
final class AdminSettingUpdateRequest extends ApiFormRequest final class AdminSettingUpdateRequest extends ApiFormRequest
{ {
@@ -15,11 +16,7 @@ final class AdminSettingUpdateRequest extends ApiFormRequest
} }
$key = (string) $this->route('key', ''); $key = (string) $this->route('key', '');
if (str_starts_with($key, 'settlement.')) { return AdminSettingPolicy::canUpdate($admin, $key);
return $admin->hasAdminPermission('prd.payout.manage');
}
return true;
} }
public function rules(): array public function rules(): array
@@ -28,4 +25,17 @@ final class AdminSettingUpdateRequest extends ApiFormRequest
'value' => ['present'], 'value' => ['present'],
]; ];
} }
public function after(): array
{
return [
function (): void {
$key = (string) $this->route('key', '');
AdminSettingPolicy::validateItems([[
'key' => $key,
'value' => $this->validated('value'),
]]);
},
];
}
} }

View File

@@ -443,7 +443,7 @@ final class AdminUser extends Authenticatable
return false; return false;
} }
return count(array_intersect($needed, $effective)) > 0; return count(array_diff($needed, $effective)) === 0;
} }
/** /**

View File

@@ -133,7 +133,6 @@ final class AdminDashboardSnapshotBuilder
{ {
return $admin->hasPermissionCode('service.reconcile.manage') return $admin->hasPermissionCode('service.reconcile.manage')
|| $admin->hasPermissionCode('service.reconcile.view') || $admin->hasPermissionCode('service.reconcile.view')
|| $admin->hasPermissionCode('service.wallet.view')
|| $admin->hasPermissionCode('service.wallet.manage'); || $admin->hasPermissionCode('service.wallet.manage');
} }

View File

@@ -6,13 +6,20 @@ use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Models\Player; use App\Models\Player;
use App\Support\AdminScopeContext;
use App\Support\AdminScopeContextResolver;
use App\Support\AdminAgentSettlementScope; use App\Support\AdminAgentSettlementScope;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
/** 代理账号仪表盘:授信、团队规模、待结账单摘要。 */ /** 代理账号仪表盘:授信、团队规模、待结账单摘要。 */
final class AgentDashboardOverviewBuilder final class AgentDashboardOverviewBuilder
{ {
public function __construct(
private readonly AdminReportQueryService $reportQuery,
) {}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */
@@ -46,6 +53,16 @@ final class AgentDashboardOverviewBuilder
} }
$pendingBillStats = $this->pendingBillStats($admin, $subtreeIds); $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 [ return [
'agent_node_id' => (int) $node->id, 'agent_node_id' => (int) $node->id,
@@ -61,13 +78,25 @@ final class AgentDashboardOverviewBuilder
), ),
'total_share_rate' => (float) ($profile?->total_share_rate ?? 0), 'total_share_rate' => (float) ($profile?->total_share_rate ?? 0),
'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'), 'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'),
'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, 'can_create_child_agent' => (bool) ($profile?->can_create_child_agent ?? false),
'can_create_player' => $profile === null || $profile->can_create_player, 'can_create_player' => (bool) ($profile?->can_create_player ?? false),
'direct_child_count' => $directChildCount, 'direct_child_count' => $directChildCount,
'subtree_agent_count' => count($subtreeIds), 'subtree_agent_count' => count($subtreeIds),
'direct_player_count' => $directPlayerCount, '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_bill_count' => $pendingBillStats['count'],
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'], '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'), 'unpaid_minor' => (int) $query->sum('unpaid_amount'),
]; ];
} }
/**
* @param list<int> $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<int> $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<string, mixed>|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;
}
} }

View File

@@ -274,14 +274,14 @@ final class AgentProfileService
{ {
$profile = $this->profileForNode($agentNodeId); $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 public function nodeMayCreatePlayer(int $agentNodeId): bool
{ {
$profile = $this->profileForNode($agentNodeId); $profile = $this->profileForNode($agentNodeId);
return $profile === null || $profile->can_create_player; return (bool) ($profile?->can_create_player ?? false);
} }
private function assertAgentProfileExists(AgentNode $agent): void private function assertAgentProfileExists(AgentNode $agent): void

View File

@@ -520,6 +520,15 @@ final class SettlementCenterLedgerService
array_values($creditById), array_values($creditById),
), static fn (int $id): bool => $id > 0)), ), 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 = []; $paymentById = [];
if ($paymentIds !== []) { if ($paymentIds !== []) {
@@ -556,7 +565,10 @@ final class SettlementCenterLedgerService
if ($kind === 'credit' && isset($creditById[$id])) { if ($kind === 'credit' && isset($creditById[$id])) {
$row = $creditById[$id]; $row = $creditById[$id];
$pid = (int) $row->player_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])) { } elseif ($kind === 'payment' && isset($paymentById[$id])) {
$items[] = $this->formatPaymentEntry($paymentById[$id]); $items[] = $this->formatPaymentEntry($paymentById[$id]);
} elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) { } elseif ($kind === 'adjustment' && isset($adjustmentById[$id])) {
@@ -569,6 +581,40 @@ final class SettlementCenterLedgerService
return $items; return $items;
} }
/**
* @param list<int> $ids
* @return array<int, object>
*/
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} * @return array{0: Carbon|null, 1: Carbon|null}
*/ */
@@ -1273,6 +1319,8 @@ final class SettlementCenterLedgerService
private function formatPaymentEntry(object $row): array private function formatPaymentEntry(object $row): array
{ {
$amount = (int) $row->amount; $amount = (int) $row->amount;
$method = trim((string) ($row->method ?? ''));
$methodLabel = $method !== '' && ! ctype_digit($method) ? ' · '.$method : '';
return array_merge( return array_merge(
$this->baseRow( $this->baseRow(
@@ -1289,7 +1337,7 @@ final class SettlementCenterLedgerService
billStatus: (string) ($row->bill_status ?? ''), billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''), billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null, 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), $this->partyFieldsFromRow($row),
); );

View File

@@ -104,8 +104,8 @@ final class AdminAuthProfile
'code' => (string) $node->code, 'code' => (string) $node->code,
'name' => (string) $node->name, 'name' => (string) $node->name,
'depth' => (int) $node->depth, 'depth' => (int) $node->depth,
'can_create_child_agent' => $profile === null || $profile->can_create_child_agent, 'can_create_child_agent' => (bool) ($profile?->can_create_child_agent ?? false),
'can_create_player' => $profile === null || $profile->can_create_player, 'can_create_player' => (bool) ($profile?->can_create_player ?? false),
]; ];
} }
} }

View File

@@ -152,7 +152,7 @@ final class AdminAuthorizationRegistry
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], ['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' => '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' => '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' => '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' => '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']], ['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_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' => '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' => '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']], ['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.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.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.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.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, '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, '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.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.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']], ['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.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.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.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.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']], ['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.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.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.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.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.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.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.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.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, '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, '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, 'legacy_permission_slugs' => ['prd.payout.manage']], ['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, '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, '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.pools.index', 'module_code' => 'jackpot', 'name' => '奖池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/pools', 'route_name' => 'api.v1.admin.jackpot.pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['code' => 'admin.jackpot.payout-logs.index', 'module_code' => 'jackpot', 'name' => '奖池派彩日志', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/jackpot/payout-logs', 'route_name' => 'api.v1.admin.jackpot.payout-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.jackpot.manage', 'prd.jackpot.view']], ['code' => 'admin.jackpot.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.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.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.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.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.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.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.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']], ['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']],

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Services\LotterySettings;
use Illuminate\Validation\ValidationException;
final class AdminSettingPolicy
{
private const WALLET_LIMIT_KEYS = [
'wallet.transfer_in_min_minor',
'wallet.transfer_in_max_minor',
'wallet.transfer_out_min_minor',
'wallet.transfer_out_max_minor',
];
public static function canUpdate(AdminUser $admin, string $key): bool
{
if (str_starts_with($key, 'settlement.')) {
return $admin->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<array{key: string, value: mixed}> $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<string, int> $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' => ['钱包转账最大金额不能小于最小金额。'],
]);
}
}

View File

@@ -32,7 +32,6 @@ final class AgentDefaultRolePermissions
private const PLAYER_MANAGE_SLUGS = [ private const PLAYER_MANAGE_SLUGS = [
'prd.users.manage', 'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs', 'prd.users.view_cs',
]; ];
@@ -43,7 +42,6 @@ final class AgentDefaultRolePermissions
'prd.agent.role.manage', 'prd.agent.role.manage',
'prd.agent.user.manage', 'prd.agent.user.manage',
'prd.users.manage', 'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs', 'prd.users.view_cs',
'prd.settlement.agent.manage', 'prd.settlement.agent.manage',
]; ];
@@ -106,10 +104,7 @@ final class AgentDefaultRolePermissions
*/ */
public static function defaultOwnerSlugsWithoutProfile(): array public static function defaultOwnerSlugsWithoutProfile(): array
{ {
return array_values(array_unique(array_merge( return self::BASE_SLUGS;
self::BASE_SLUGS,
self::PLAYER_MANAGE_SLUGS,
)));
} }
/** /**

View File

@@ -32,7 +32,6 @@ final class AgentProfileCapabilityFilter
/** @var list<string> */ /** @var list<string> */
private const PLAYER_LEGACY_SLUGS = [ private const PLAYER_LEGACY_SLUGS = [
'prd.users.manage', 'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs', 'prd.users.view_cs',
'prd.player_freeze.manage', 'prd.player_freeze.manage',
]; ];
@@ -45,10 +44,6 @@ final class AgentProfileCapabilityFilter
*/ */
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
{ {
if ($profile === null) {
return $permissionCodes;
}
$set = []; $set = [];
foreach ($permissionCodes as $code) { foreach ($permissionCodes as $code) {
if (is_string($code) && $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) { foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) {
unset($set[$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) { foreach (self::PLAYER_PERMISSION_CODES as $code) {
unset($set[$code]); unset($set[$code]);
} }
@@ -97,15 +92,11 @@ final class AgentProfileCapabilityFilter
*/ */
public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array
{ {
if ($profile === null) {
return $legacySlugs;
}
$deny = []; $deny = [];
if (! $profile->can_create_child_agent) { if (! ($profile?->can_create_child_agent ?? false)) {
$deny = array_merge($deny, self::CHILD_AGENT_LEGACY_SLUGS); $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); $deny = array_merge($deny, self::PLAYER_LEGACY_SLUGS);
} }

View File

@@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
ensureAdminActionCatalogSeeded();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });

View File

@@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
ensureAdminActionCatalogSeeded();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });

View File

@@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
ensureAdminActionCatalogSeeded();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $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(); ->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 { test('auth me returns agent context for bound operator', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = agentRootNodeId($siteId); $rootId = agentRootNodeId($siteId);

View File

@@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
ensureAdminActionCatalogSeeded();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });

View File

@@ -152,6 +152,89 @@ test('credit ledger index includes payment on agent bill', function (): void {
->assertJsonPath('data.items.0.bill_type', 'agent'); ->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 { test('credit ledger entry_kind share returns share ledger rows', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first(); $site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id; $siteId = (int) $site->id;

View File

@@ -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(); 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', '<div>old</div>', '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' => '<div>new</div>',
])
->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('<div>new</div>');
});

View File

@@ -0,0 +1,185 @@
<?php
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\WalletTxn;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Schema::create('players', function (Blueprint $table): void {
$table->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);
});

View File

@@ -2,7 +2,9 @@
use Tests\TestCase; use Tests\TestCase;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Foundation\Testing\RefreshDatabase; 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,
],
);
}
}