feat: 增强代理和玩家管理功能

- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
2026-06-04 18:00:50 +08:00
parent 96545f87f6
commit a44679665d
183 changed files with 10054 additions and 857 deletions

View File

@@ -76,6 +76,32 @@ final class AdminAgentScope
return self::nodeVisibleTo($admin, $node);
}
/** 占成/授信/回水仅可由上级或平台修改,代理本人不可改自己的 profile。 */
public static function nodeProfileEditableBy(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
if (
! $admin->hasPermissionCode('agent.profile.manage')
&& ! $admin->hasPermissionCode('agent.node.manage')
) {
return false;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
return false;
}
if ((int) $actor->id === (int) $node->id) {
return false;
}
return $node->isDescendantOf($actor);
}
/**
* @return Builder<AgentNode>
*/

View File

@@ -8,6 +8,22 @@ use Illuminate\Database\Query\Builder;
/** 代理账单按管理员可访问站点过滤。 */
final class AdminAgentSettlementScope
{
public static function applyToPeriodsQuery(Builder $query, AdminUser $admin, string $periodsAlias = 'settlement_periods'): void
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return;
}
if ($siteIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereIn($periodsAlias.'.admin_site_id', $siteIds);
}
public static function applyToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
{
$siteIds = $admin->accessibleAdminSiteIds();

View File

@@ -38,26 +38,34 @@ final class AdminAuthProfile
* },
* is_super_admin: bool,
* operational_permissions: list<string>,
* delegation_ceiling: list<string>
* delegation_ceiling: list<string>,
* accessible_sites?: list<array{id: int, code: string, name: string}>
* }
*/
public static function fromAdmin(AdminUser $admin): array
{
$fresh = $admin->fresh();
$permissionSlugs = $fresh->adminPermissionSlugs();
$agent = self::agentContext($fresh);
return [
$payload = [
'id' => $fresh->id,
'username' => $fresh->username,
'nickname' => $fresh->name,
'email' => $fresh->email,
'permissions' => $permissionSlugs,
'navigation' => AdminAuthorizationRegistry::visibleNavigationItems($permissionSlugs, $fresh),
'agent' => self::agentContext($fresh),
'agent' => $agent,
'is_super_admin' => $fresh->isSuperAdmin(),
'operational_permissions' => $permissionSlugs,
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
];
if ($agent === null) {
$payload['accessible_sites'] = AdminUserSiteBindingPresenter::accessibleSitesFor($fresh);
}
return $payload;
}
/**

View File

@@ -144,7 +144,8 @@ final class AdminAuthorizationRegistry
{
return [
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']],
['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage', 'prd.settlement.agent.view', 'prd.settlement.agent.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))],
['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))],
['segment' => 'settlement_center', 'label' => 'Credit settlement', 'href' => '/admin/settlement-center', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/settlement-center', 'requiredAny' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
['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']],
@@ -157,6 +158,7 @@ final class AdminAuthorizationRegistry
['segment' => 'jackpot', 'label' => 'Jackpot', 'href' => '/admin/jackpot', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/jackpot', 'requiredAny' => ['prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk_cap', 'label' => 'Risk cap rules', 'href' => '/admin/risk/cap', 'nav_group' => 'rules', 'platform_only' => true, 'activeMatchPrefix' => '/admin/risk/cap', 'requiredAny' => ['prd.risk_cap.manage', 'prd.risk_cap.view']],
['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.manage']],
['segment' => 'integration', 'label' => 'Integration sites', 'href' => '/admin/config/integration-sites', 'nav_group' => 'platform', 'platform_only' => true, 'activeMatchPrefix' => '/admin/config/integration-sites', 'requiredAny' => AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites')],
['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']],
@@ -426,12 +428,22 @@ final class AdminAuthorizationRegistry
['code' => 'admin.agent-delegation-grants.index', 'module_code' => 'agent', 'name' => '代理下放上限查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
['code' => 'admin.agent-delegation-grants.sync', 'module_code' => 'agent', 'name' => '代理下放上限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/delegation-grants', 'route_name' => 'api.v1.admin.agent-delegation-grants.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
['code' => 'admin.agent-nodes.profile.show', 'module_code' => 'agent', 'name' => '代理占成授信查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage', 'agent.node.view'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage', 'prd.agent.view']],
['code' => 'admin.agent-nodes.profile.update', 'module_code' => 'agent', 'name' => '代理占成授信更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}/profile', 'route_name' => 'api.v1.admin.agent-nodes.profile.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.profile.manage', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent.profile.manage', 'prd.agent.manage']],
['code' => 'admin.settlement-periods.index', 'module_code' => 'settlement', 'name' => '代理账期列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-periods.store', 'module_code' => 'settlement', 'name' => '创建代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods', 'route_name' => 'api.v1.admin.settlement-periods.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-payments.index', 'module_code' => 'settlement', 'name' => '代理账单收付记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-payments', 'route_name' => 'api.v1.admin.settlement-payments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-adjustments.index', 'module_code' => 'settlement', 'name' => '代理账单调账记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-adjustments', 'route_name' => 'api.v1.admin.settlement-adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.show', 'module_code' => 'settlement', 'name' => '代理账单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}', 'route_name' => 'api.v1.admin.settlement-bills.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.confirm', 'module_code' => 'settlement', 'name' => '确认代理账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/confirm', 'route_name' => 'api.v1.admin.settlement-bills.confirm', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.payments', 'module_code' => 'settlement', 'name' => '登记代理账单收付', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/payments', 'route_name' => 'api.v1.admin.settlement-bills.payments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.adjustments', 'module_code' => 'settlement', 'name' => '代理账单补差冲正', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/adjustments', 'route_name' => 'api.v1.admin.settlement-bills.adjustments', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.bad-debt-write-off', 'module_code' => 'settlement', 'name' => '代理账单坏账核销', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}/bad-debt-write-off', 'route_name' => 'api.v1.admin.settlement-bills.bad-debt-write-off', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.settlement-reports.summary', 'module_code' => 'settlement', 'name' => '代理结算报表摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports/summary', 'route_name' => 'api.v1.admin.settlement-reports.summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-reports.show', 'module_code' => 'settlement', 'name' => '信用占成盘报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-reports', 'route_name' => 'api.v1.admin.settlement-reports.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.play-types.index', 'module_code' => 'config', 'name' => '玩法类型列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/play-types', 'route_name' => 'api.v1.admin.play-types.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.odds.view', 'prd.rebate.manage', 'prd.rebate.view']],
['code' => 'admin.play-types.patch', 'module_code' => 'config', 'name' => '玩法类型切换', 'http_method' => 'PATCH', 'uri_pattern' => '/api/v1/admin/play-types/{play_code}', 'route_name' => 'api.v1.admin.play-types.patch', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage']],
@@ -468,10 +480,11 @@ final class AdminAuthorizationRegistry
['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.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.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.finance-summary', 'module_code' => 'draw', 'name' => '期开奖资金摘要', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/finance-summary', 'route_name' => 'api.v1.admin.draws.finance-summary', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.payout.view', 'prd.payout.manage', 'prd.payout.review', 'prd.report.view', 'prd.users.view_finance']],
['code' => 'admin.draws.result-batches.index', 'module_code' => 'draw', 'name' => '开奖结果批次列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/result-batches', 'route_name' => 'api.v1.admin.draws.result-batches.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.risk-pool-lock-logs.index', 'module_code' => 'risk', 'name' => '风控锁池日志列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pool-lock-logs', 'route_name' => 'api.v1.admin.draws.risk-pool-lock-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],
['code' => 'admin.draws.risk-pools.index', 'module_code' => 'risk', 'name' => '风控池列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}/risk-pools', 'route_name' => 'api.v1.admin.draws.risk-pools.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']],

View File

@@ -112,7 +112,7 @@ final class AdminDataScope
return;
}
$query->whereHas($relation, static function (Builder $playerQuery) use ($admin): void {
$query->whereHas($relation, static function (\Illuminate\Database\Eloquent\Builder $playerQuery) use ($admin): void {
AdminSiteScope::applyToPlayerQuery($playerQuery, $admin);
});
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Support;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\AdminUser;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Lottery\DrawResultBatchStatus;
use App\Services\Draw\DrawHallSnapshotBuilder;
final class AdminDrawApiPresenter
{
/**
* @param array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}|null $stats
* @return array<string, mixed>
*/
public static function listRow(Draw $draw, ?array $stats, AdminUser $admin): array
{
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
$finance = AdminDrawResponsePolicy::canViewDrawFinance($admin);
$row = [
'id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'business_date' => self::formatBusinessDate($draw->business_date),
'sequence_no' => (int) $draw->sequence_no,
'status' => $draw->status,
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'updated_at' => $draw->updated_at?->toIso8601String(),
];
if ($manage) {
$row['result_source'] = $draw->result_source;
$row['current_result_version'] = (int) $draw->current_result_version;
$row['settle_version'] = (int) $draw->settle_version;
$row['is_reopened'] = (bool) $draw->is_reopened;
}
if ($finance && $stats !== null) {
$row['total_bet_minor'] = $stats['total_bet_minor'];
$row['total_payout_minor'] = $stats['total_payout_minor'];
$row['profit_loss_minor'] = $stats['profit_loss_minor'];
}
return $row;
}
/** @return array<string, mixed> */
public static function show(Draw $draw, AdminUser $admin, DrawHallSnapshotBuilder $hallPreview): array
{
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
$nowUtc = now()->utc();
$batchCounts = [
'published' => $draw->resultBatches()
->where('status', DrawResultBatchStatus::Published->value)
->count(),
];
if ($manage) {
$batchCounts['total'] = $draw->resultBatches()->count();
$batchCounts['pending_review'] = $draw->resultBatches()
->where('status', DrawResultBatchStatus::PendingReview->value)
->count();
}
$payload = [
'id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'business_date' => self::formatBusinessDate($draw->business_date),
'sequence_no' => (int) $draw->sequence_no,
'status' => $draw->status,
'hall_preview_status' => $hallPreview->effectiveHallDisplayStatus($draw, $nowUtc),
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'result_batch_counts' => $batchCounts,
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
];
if ($manage) {
$payload['result_source'] = $draw->result_source;
$payload['current_result_version'] = (int) $draw->current_result_version;
$payload['settle_version'] = (int) $draw->settle_version;
$payload['is_reopened'] = (bool) $draw->is_reopened;
$payload['created_at'] = $draw->created_at?->toIso8601String();
$payload['updated_at'] = $draw->updated_at?->toIso8601String();
}
return $payload;
}
/** @return array<string, mixed> */
public static function resultBatch(DrawResultBatch $batch, AdminUser $admin): array
{
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
$row = [
'id' => (int) $batch->id,
'result_version' => (int) $batch->result_version,
'status' => $batch->status,
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),
'items' => $batch->items->map(static fn (DrawResultItem $item): array => [
'prize_type' => $item->prize_type,
'prize_index' => (int) $item->prize_index,
'number_4d' => $item->number_4d,
'suffix_3d' => $item->suffix_3d,
'suffix_2d' => $item->suffix_2d,
'head_digit' => $item->head_digit,
'tail_digit' => $item->tail_digit,
])->values()->all(),
];
if ($manage) {
$row['source_type'] = $batch->source_type;
$row['rng_seed_hash'] = $batch->rng_seed_hash;
$row['created_by'] = $batch->created_by;
$row['confirmed_by'] = $batch->confirmed_by;
$row['created_at'] = $batch->created_at?->toIso8601String();
$row['updated_at'] = $batch->updated_at?->toIso8601String();
}
return $row;
}
private static function formatBusinessDate(mixed $businessDate): string
{
return $businessDate instanceof Carbon
? $businessDate->format('Y-m-d')
: (string) $businessDate;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
/** 期号 API 响应字段按「开奖查看 / 管理 / 资金」权限裁剪。 */
final class AdminDrawResponsePolicy
{
public static function canManageDrawResults(AdminUser $admin): bool
{
return $admin->hasAdminPermission('prd.draw_result.manage');
}
public static function canViewDrawFinance(AdminUser $admin): bool
{
if (self::canManageDrawResults($admin)) {
return true;
}
foreach ([
'prd.payout.view',
'prd.payout.manage',
'prd.payout.review',
'prd.report.view',
'prd.users.view_finance',
] as $slug) {
if ($admin->hasAdminPermission($slug)) {
return true;
}
}
return false;
}
/** @return array{can_manage_draw_results: bool, can_view_draw_finance: bool} */
public static function capabilities(AdminUser $admin): array
{
return [
'can_manage_draw_results' => self::canManageDrawResults($admin),
'can_view_draw_finance' => self::canViewDrawFinance($admin),
];
}
}

View File

@@ -9,15 +9,17 @@ final class AdminIntegrationSitePresenter
/**
* @return array<string, mixed>
*/
public static function listItem(AdminSite $site): array
public static function listItem(AdminSite $site, bool $hasLineRoot = false): array
{
return [
'id' => (int) $site->id,
'code' => (string) $site->code,
'name' => (string) $site->name,
'has_line_root' => $hasLineRoot,
'currency_code' => (string) $site->currency_code,
'status' => (int) $site->status,
'wallet_api_url' => $site->wallet_api_url,
'lottery_h5_base_url' => $site->lottery_h5_base_url,
'wallet_timeout_seconds' => (int) ($site->wallet_timeout_seconds ?? 10),
'has_sso_secret' => is_string($site->sso_jwt_secret_encrypted) && $site->sso_jwt_secret_encrypted !== '',
'has_wallet_api_key' => is_string($site->wallet_api_key_encrypted) && $site->wallet_api_key_encrypted !== '',

View File

@@ -69,8 +69,7 @@ final class AdminPermissionBridge
}
/**
* 若管理员拥有的任意 menu_action.permission_code 落在某 `prd.*` 映射集合内,则视为拥有该 `prd.*`
*(与路由中间件「满足其一」及 Next 侧栏 `requiredAny` 语义一致)。
* 由已授权的 menu_action.permission_code 反推 `prd.*` 展示 slug须满足映射中的全部 code
*
* @param list<string> $menuActionCodes
* @return list<string>
@@ -93,12 +92,21 @@ final class AdminPermissionBridge
$out = [];
foreach (self::legacyMap() as $legacySlug => $requiredCodes) {
if ($requiredCodes === []) {
continue;
}
$hasAll = true;
foreach ($requiredCodes as $code) {
if (isset($set[$code])) {
$out[$legacySlug] = true;
if (! isset($set[$code])) {
$hasAll = false;
break;
}
}
if ($hasAll) {
$out[$legacySlug] = true;
}
}
$keys = array_keys($out);

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AdminUser;
use Illuminate\Validation\ValidationException;
/** 平台账号创建/改角色时,操作者对目标站点的授权校验。 */
final class AdminPlatformUserSiteGuard
{
public static function assertActorCanAssignSite(AdminUser $actor, int $siteId): void
{
$site = AdminSite::query()->find($siteId);
if ($site === null) {
throw ValidationException::withMessages([
'admin_site_id' => [trans('validation.exists', ['attribute' => 'admin_site_id'])],
]);
}
if ($actor->isSuperAdmin()) {
return;
}
if (! AdminIntegrationSiteAccess::canAccess($actor, $site)) {
throw ValidationException::withMessages([
'admin_site_id' => [trans('admin.site_access_denied')],
]);
}
}
}

View File

@@ -11,6 +11,7 @@ final class AdminUserApiPresenter
public static function listItem(AdminUser $user): array
{
$user->loadMissing('roles');
$siteBindings = AdminUserSiteBindingPresenter::bindingsFor($user);
return [
'id' => (int) $user->id,
@@ -20,6 +21,7 @@ final class AdminUserApiPresenter
'status' => (int) $user->status,
'account_kind' => $user->isPlatformAccount() ? 'platform' : 'agent',
'roles' => $user->adminRoleSlugs(),
'site_bindings' => $siteBindings,
'direct_permissions' => $user->directLegacyPermissionSlugs(),
'effective_permissions' => $user->adminPermissionSlugs(),
];

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
/** 平台账号在各站点上的角色绑定API 展示用)。 */
final class AdminUserSiteBindingPresenter
{
/**
* @return list<array{site_id: int, site_code: string, site_name: string, role_slugs: list<string>}>
*/
public static function bindingsFor(AdminUser $user): array
{
if ($user->hasPrimaryAgentBinding()) {
return [];
}
$rows = DB::table('admin_user_site_roles as usr')
->join('admin_sites as s', 's.id', '=', 'usr.site_id')
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
->where('usr.admin_user_id', $user->id)
->orderBy('s.code')
->orderBy('r.slug')
->get(['usr.site_id', 's.code as site_code', 's.name as site_name', 'r.slug as role_slug']);
/** @var array<int, array{site_id: int, site_code: string, site_name: string, role_slugs: list<string>}> $bySite */
$bySite = [];
foreach ($rows as $row) {
$siteId = (int) $row->site_id;
if (! isset($bySite[$siteId])) {
$bySite[$siteId] = [
'site_id' => $siteId,
'site_code' => (string) $row->site_code,
'site_name' => (string) $row->site_name,
'role_slugs' => [],
];
}
$slug = (string) $row->role_slug;
if ($slug !== '' && ! in_array($slug, $bySite[$siteId]['role_slugs'], true)) {
$bySite[$siteId]['role_slugs'][] = $slug;
}
}
foreach ($bySite as &$binding) {
sort($binding['role_slugs']);
}
unset($binding);
return array_values($bySite);
}
/**
* @return list<array{id: int, code: string, name: string}>
*/
public static function accessibleSitesFor(AdminUser $admin): array
{
return AdminIntegrationSiteAccess::queryFor($admin)
->get(['id', 'code', 'name'])
->map(static fn ($site): array => [
'id' => (int) $site->id,
'code' => (string) $site->code,
'name' => (string) $site->name,
])
->values()
->all();
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use App\Models\AgentNode;
use App\Models\AgentProfile;
/**
* 平台「代理」系统角色slug=agent的默认 prd.* 模板。
* 经营代理主账号只绑定该角色;权限在「平台角色管理」调整,不按线路写 agent_owner_*
*
* @see \App\Support\AgentPlatformRole
*/
final class AgentDefaultRolePermissions
{
/** 所有经营代理主账号均具备的基础能力(不含钱包对账 / 平台配置)。 */
private const BASE_SLUGS = [
'prd.dashboard.view',
'prd.agent.view',
'prd.agent.role.view',
'prd.agent.user.view',
'prd.tickets.view',
'prd.report.view',
'prd.settlement.agent.view',
];
private const CHILD_AGENT_MANAGE_SLUGS = [
'prd.agent.manage',
'prd.agent.profile.manage',
];
private const PLAYER_MANAGE_SLUGS = [
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
];
/** 线路根代理depth=0在基础包之上额外具备的经营权限。 */
private const LINE_ROOT_EXTRA_SLUGS = [
'prd.agent.manage',
'prd.agent.profile.manage',
'prd.agent.role.manage',
'prd.agent.user.manage',
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
'prd.settlement.agent.manage',
];
/**
* @return list<string>
*/
public static function baseSlugs(): array
{
return self::BASE_SLUGS;
}
/**
* @return list<string>
*/
public static function ownerSlugsForNode(AgentNode $node, ?AgentProfile $profile = null): array
{
if ($node->isRoot()) {
return self::lineRootOwnerSlugs();
}
$profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first();
if ($profile === null) {
return self::defaultOwnerSlugsWithoutProfile();
}
return self::ownerSlugsFromProfile($profile);
}
/**
* @return list<string>
*/
public static function lineRootOwnerSlugs(): array
{
return array_values(array_unique(array_merge(
self::BASE_SLUGS,
self::LINE_ROOT_EXTRA_SLUGS,
)));
}
/**
* @return list<string>
*/
public static function ownerSlugsFromProfile(AgentProfile $profile): array
{
$slugs = self::BASE_SLUGS;
if ($profile->can_create_child_agent) {
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
}
if ($profile->can_create_player) {
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
}
return array_values(array_unique($slugs));
}
/**
* @return list<string>
*/
public static function defaultOwnerSlugsWithoutProfile(): array
{
return array_values(array_unique(array_merge(
self::BASE_SLUGS,
self::PLAYER_MANAGE_SLUGS,
)));
}
/**
* @param array<string, mixed> $createPayload
* @return list<string>
*/
public static function ownerSlugsForNewChild(array $createPayload): array
{
$slugs = self::BASE_SLUGS;
if ((bool) ($createPayload['can_create_child_agent'] ?? false)) {
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
}
if ((bool) ($createPayload['can_create_player'] ?? true)) {
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
}
return array_values(array_unique($slugs));
}
/**
* 平台「代理」系统角色模板(出现在「平台角色管理」列表,供手动分配或作站点 pivot 回退)。
*
* @return list<string>
*/
public static function platformAgentRoleTemplateSlugs(): array
{
return self::defaultOwnerSlugsWithoutProfile();
}
/** 确保存在 slug=agent 的平台系统角色,并同步模板权限。 */
public static function ensurePlatformAgentRole(): AdminRole
{
$role = AdminRole::query()->updateOrCreate(
[
'slug' => 'agent',
'scope_type' => AdminRole::SCOPE_SYSTEM,
],
[
'code' => 'agent',
'name' => '代理',
'description' => '经营代理默认权限模板(与线路内 agent_owner 默认包一致)',
'status' => 1,
'is_system' => true,
'sort_order' => 50,
'owner_agent_id' => null,
'delegated_from_role_id' => null,
],
);
$role->syncLegacyPermissionSlugs(self::platformAgentRoleTemplateSlugs());
return $role->fresh() ?? $role;
}
}

View File

@@ -7,16 +7,10 @@ use App\Models\AgentNode;
final class AgentLinePresenter
{
/**
* @param array{sso_jwt_secret: string, wallet_api_key: string} $secrets
* @return array<string, mixed>
*/
public static function provisioned(AdminSite $site, AgentNode $root, array $secrets): array
/** @return array<string, mixed> */
public static function provisioned(AdminSite $site, AgentNode $root): array
{
$sitePayload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
AdminIntegrationSitePresenter::detail($site),
$secrets,
);
$sitePayload = AdminIntegrationSitePresenter::detail($site);
return array_merge($sitePayload, [
'agent_node' => AgentNodePresenter::item($root),

View File

@@ -4,6 +4,8 @@ namespace App\Support;
use App\Models\AdminSite;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
final class AgentNodePresenter
@@ -23,7 +25,24 @@ final class AgentNodePresenter
* email: ?string
* }
*/
public static function item(AgentNode $node): array
/**
* @return array<string, mixed>
*/
public static function profileSummary(AgentProfile $profile): array
{
return [
'total_share_rate' => (float) $profile->total_share_rate,
'credit_limit' => (int) $profile->credit_limit,
'allocated_credit' => (int) $profile->allocated_credit,
'used_credit' => (int) $profile->used_credit,
'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit),
'rebate_limit' => (float) $profile->rebate_limit,
'default_player_rebate' => (float) $profile->default_player_rebate,
'settlement_cycle' => AgentSettlementCycle::normalize($profile->settlement_cycle),
];
}
public static function item(AgentNode $node, ?AgentProfile $profile = null): array
{
$account = DB::table('admin_user_agents as aua')
->join('admin_users as au', 'au.id', '=', 'aua.admin_user_id')
@@ -35,7 +54,7 @@ final class AgentNodePresenter
$siteCode = AdminSite::query()->where('id', $node->admin_site_id)->value('code');
return [
$payload = [
'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id,
'site_code' => $siteCode !== null ? (string) $siteCode : null,
@@ -50,6 +69,12 @@ final class AgentNodePresenter
'username' => $account?->username !== null ? (string) $account->username : null,
'email' => $account?->email !== null ? (string) $account->email : null,
];
if ($profile !== null) {
$payload['profile_summary'] = self::profileSummary($profile);
}
return $payload;
}
/**
@@ -58,11 +83,18 @@ final class AgentNodePresenter
*/
public static function tree(iterable $nodes): array
{
$nodeList = $nodes instanceof Collection ? $nodes : collect($nodes);
$profiles = AgentProfile::query()
->whereIn('agent_node_id', $nodeList->pluck('id'))
->get()
->keyBy('agent_node_id');
$items = [];
$byParent = [];
foreach ($nodes as $node) {
$row = self::item($node);
foreach ($nodeList as $node) {
$profile = $profiles->get($node->id);
$row = self::item($node, $profile instanceof AgentProfile ? $profile : null);
$row['children'] = [];
$items[(int) $node->id] = $row;
$parentKey = $node->parent_id !== null ? (int) $node->parent_id : 0;

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\DB;
final class AgentOverdueGuard
{
public static function agentHasOverdueBills(int $agentNodeId): bool
{
if ($agentNodeId <= 0) {
return false;
}
return DB::table('settlement_bills')
->where('owner_type', 'agent')
->where('owner_id', $agentNodeId)
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->exists();
}
public static function assertAgentMayGrantCredit(int $agentNodeId): void
{
if (self::agentHasOverdueBills($agentNodeId)) {
throw \Illuminate\Validation\ValidationException::withMessages([
'credit' => ['agent_overdue'],
]);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use Illuminate\Validation\ValidationException;
/** 经营代理主账号统一使用平台系统角色 {@see AdminRole} slug=agent。 */
final class AgentPlatformRole
{
public static function resolve(): AdminRole
{
return AgentDefaultRolePermissions::ensurePlatformAgentRole();
}
public static function id(): int
{
$role = self::resolve();
return (int) $role->id;
}
/** 主账号:仅绑定平台「代理」角色(权限在「平台角色管理」维护)。 */
public static function assignPrimaryOperator(AdminUser $user, AgentNode $node): void
{
$user->syncAgentRoleIds((int) $node->id, [self::id()]);
}
public static function idOrFail(): int
{
$id = (int) (AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->where('slug', 'agent')
->where('status', 1)
->value('id') ?? 0);
if ($id <= 0) {
throw ValidationException::withMessages([
'role' => ['platform_agent_role_missing: run php artisan lottery:agent-roles-sync'],
]);
}
return $id;
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Support;
use App\Models\AgentProfile;
/**
* 将「平台 agent 角色」权限与线路内 {@see AgentProfile} 能力开关联动。
*
* 主账号绑定 slug=agent不再按节点维护 agent_owner_*;创建玩家/下级 的开关须在登录态生效。
*/
final class AgentProfileCapabilityFilter
{
/** @var list<string> */
private const CHILD_AGENT_PERMISSION_CODES = [
'agent.node.manage',
'agent.profile.manage',
];
/** @var list<string> */
private const PLAYER_PERMISSION_CODES = [
'service.players.manage',
'service.players.freeze',
];
/** @var list<string> */
private const CHILD_AGENT_LEGACY_SLUGS = [
'prd.agent.manage',
'prd.agent.profile.manage',
];
/** @var list<string> */
private const PLAYER_LEGACY_SLUGS = [
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
'prd.player_freeze.manage',
];
/**
* Profile 能力收紧或补足登录态 permission_code平台 agent 角色模板未必含 manage
*
* @param list<string> $permissionCodes
* @return list<string>
*/
public static function applyToMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
{
if ($profile === null) {
return $permissionCodes;
}
$set = [];
foreach ($permissionCodes as $code) {
if (is_string($code) && $code !== '') {
$set[$code] = true;
}
}
if (! $profile->can_create_child_agent) {
foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) {
unset($set[$code]);
}
} else {
foreach (self::CHILD_AGENT_PERMISSION_CODES as $code) {
$set[$code] = true;
}
}
if (! $profile->can_create_player) {
foreach (self::PLAYER_PERMISSION_CODES as $code) {
unset($set[$code]);
}
} else {
foreach (self::PLAYER_PERMISSION_CODES as $code) {
$set[$code] = true;
}
}
$out = array_keys($set);
sort($out);
return $out;
}
/**
* @param list<string> $permissionCodes
* @return list<string>
*/
public static function filterMenuActionCodes(array $permissionCodes, ?AgentProfile $profile): array
{
return self::applyToMenuActionCodes($permissionCodes, $profile);
}
/**
* @param list<string> $legacySlugs
* @return list<string>
*/
public static function filterLegacySlugs(array $legacySlugs, ?AgentProfile $profile): array
{
if ($profile === null) {
return $legacySlugs;
}
$deny = [];
if (! $profile->can_create_child_agent) {
$deny = array_merge($deny, self::CHILD_AGENT_LEGACY_SLUGS);
}
if (! $profile->can_create_player) {
$deny = array_merge($deny, self::PLAYER_LEGACY_SLUGS);
}
if ($deny === []) {
return $legacySlugs;
}
$denySet = array_fill_keys($deny, true);
return array_values(array_filter(
$legacySlugs,
static fn (string $slug): bool => ! isset($denySet[$slug]),
));
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Support;
final class AgentSettlementProductionGuard
{
public static function assertProductionCloseAllowed(): void
{
if (app()->environment('testing')) {
return;
}
if (config('agent_settlement.allow_demo_close', false)) {
return;
}
}
}

View File

@@ -97,6 +97,11 @@ final class ApiValidationErrors
return $humanized;
}
$compact = self::humanizeCompactEnglish($field, $trimmed, $locale, $attribute);
if ($compact !== null) {
return $compact;
}
return $trimmed;
}
@@ -243,6 +248,91 @@ final class ApiValidationErrors
return null;
}
/**
* Laravel 11 locale=en 时常用「{attribute} must not be greater than 1.」短句(无 "The … field" 前缀)。
*/
private static function humanizeCompactEnglish(
string $field,
string $message,
string $locale,
string $attribute,
): ?string {
if (preg_match('/^(.+?)\s+must not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) {
$attribute = self::attributeLabelFromEnglish($max[1], $field, $locale);
$custom = self::customRuleLine($field, 'max', $attribute, $locale);
if ($custom !== null) {
return $custom;
}
return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale);
}
if (preg_match('/^(.+?)\s+may not be greater than ([\d.]+)\.?$/i', $message, $max) === 1) {
$attribute = self::attributeLabelFromEnglish($max[1], $field, $locale);
return trans('validation.max.numeric', ['attribute' => $attribute, 'max' => $max[2]], $locale);
}
if (preg_match('/^(.+?)\s+must be less than or equal to ([\d.]+)\.?$/i', $message, $lte) === 1) {
$attribute = self::attributeLabelFromEnglish($lte[1], $field, $locale);
return trans('validation.lte.numeric', ['attribute' => $attribute, 'value' => $lte[2]], $locale);
}
if (preg_match('/^(.+?)\s+must not be less than ([\d.]+)\.?$/i', $message, $min) === 1) {
$attribute = self::attributeLabelFromEnglish($min[1], $field, $locale);
$custom = self::customRuleLine($field, 'min', $attribute, $locale);
if ($custom !== null) {
return $custom;
}
return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale);
}
if (preg_match('/^(.+?)\s+must be at least ([\d.]+)\.?$/i', $message, $min) === 1) {
$attribute = self::attributeLabelFromEnglish($min[1], $field, $locale);
$custom = self::customRuleLine($field, 'min', $attribute, $locale);
if ($custom !== null) {
return $custom;
}
return trans('validation.min.numeric', ['attribute' => $attribute, 'min' => $min[2]], $locale);
}
if (preg_match('/^(.+?)\s+must be between ([\d.]+) and ([\d.]+)\.?$/i', $message, $between) === 1) {
$attribute = self::attributeLabelFromEnglish($between[1], $field, $locale);
return trans('validation.between.numeric', [
'attribute' => $attribute,
'min' => $between[2],
'max' => $between[3],
], $locale);
}
$compactTails = [
'must be a number' => 'validation.numeric',
'must be an integer' => 'validation.integer',
'must be a string' => 'validation.string',
'must be a boolean' => 'validation.boolean',
'must be an array' => 'validation.array',
'is required' => 'validation.required',
];
foreach ($compactTails as $suffix => $ruleKey) {
$pattern = '/^(.+?)\s+'.preg_quote($suffix, '/').'\.?$/i';
if (preg_match($pattern, $message, $match) !== 1) {
continue;
}
$attribute = self::attributeLabelFromEnglish($match[1], $field, $locale);
$line = trans($ruleKey, ['attribute' => $attribute], $locale);
return $line !== $ruleKey ? $line : null;
}
return null;
}
private static function attributeLabelFromEnglish(string $englishName, string $field, string $locale): string
{
$normalized = strtolower(trim($englishName));

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Currency;
use App\Services\LotterySettings;
/**
* 信用占成盘额度(代理/玩家授信、已用)在库内按「主货币整数」存储;
* 彩票下注、钱包 API 使用最小货币单位minor。本类负责二者换算。
*/
final class CreditAmountScale
{
public static function minorUnitFactor(string $currencyCode): int
{
$code = strtoupper(trim($currencyCode));
if ($code === '') {
return (int) max(1, 10 ** LotterySettings::currencyDisplayDecimals());
}
$currency = Currency::query()->where('code', $code)->first();
$decimals = $currency !== null
? (int) $currency->decimal_places
: LotterySettings::currencyDisplayDecimals();
return (int) max(1, 10 ** max(0, min(12, $decimals)));
}
public static function majorToMinor(int $major, string $currencyCode): int
{
$major = max(0, $major);
return $major * self::minorUnitFactor($currencyCode);
}
/** 最小单位 → 主货币整数(四舍五入)。 */
public static function minorToMajor(int $minor, string $currencyCode): int
{
$factor = self::minorUnitFactor($currencyCode);
if ($factor <= 1) {
return $minor;
}
if ($minor >= 0) {
return intdiv($minor + intdiv($factor, 2), $factor);
}
return -intdiv(-$minor + intdiv($factor, 2), $factor);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
/** 平台角色管理仅维护的两个内置系统角色。 */
final class PlatformSystemRoles
{
public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN;
public const SLUG_AGENT = 'agent';
/** @return list<string> */
public static function fixedSlugs(): array
{
return [self::SLUG_SUPER_ADMIN, self::SLUG_AGENT];
}
public static function isFixedSlug(string $slug): bool
{
return in_array($slug, self::fixedSlugs(), true);
}
/** 超级管理员:平台内置,同步当前目录中的全部 `prd.*`。 */
public static function ensureSuperAdminRole(): AdminRole
{
$role = AdminRole::query()->updateOrCreate(
[
'slug' => self::SLUG_SUPER_ADMIN,
'scope_type' => AdminRole::SCOPE_SYSTEM,
],
[
'code' => self::SLUG_SUPER_ADMIN,
'name' => '超级管理员',
'description' => '平台内置角色,拥有全部权限',
'status' => 1,
'is_system' => true,
'sort_order' => 10,
'owner_agent_id' => null,
'delegated_from_role_id' => null,
],
);
$role->syncAllActiveMenuActions();
return $role->fresh() ?? $role;
}
public static function ensureAll(): void
{
self::ensureSuperAdminRole();
AgentDefaultRolePermissions::ensurePlatformAgentRole();
}
}

View File

@@ -2,8 +2,11 @@
namespace App\Support;
use App\Models\AgentProfile;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
/** 玩家 API 统一 JSON 形状(列表行 / 详情)。 */
final class PlayerApiPresenter
@@ -28,11 +31,23 @@ final class PlayerApiPresenter
? $player->agentNode
: ($player->agent_node_id ? $player->agentNode()->first() : null);
$usesCredit = PlayerFundingMode::usesCredit($player);
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
$creditLimit = $credit !== null ? (int) $credit->credit_limit : ($usesCredit ? 0 : null);
$usedCredit = $credit !== null ? (int) $credit->used_credit : ($usesCredit ? 0 : null);
$availableCredit = $credit !== null
? max(0, (int) $credit->credit_limit - (int) $credit->used_credit - (int) $credit->frozen_credit)
: ($usesCredit ? 0 : null);
[$rebateRate, $rebateInherited] = self::resolveListRebate($player, $agent);
return [
'id' => (int) $player->id,
...AgentNodeApiPresenter::embed($agent),
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'auth_source' => $player->auth_source,
'funding_mode' => $player->funding_mode,
'username' => $player->username,
'nickname' => $player->nickname,
'default_currency' => $player->default_currency,
@@ -40,6 +55,47 @@ final class PlayerApiPresenter
'last_login_at' => $player->last_login_at?->toIso8601String(),
'created_at' => $player->created_at?->toIso8601String(),
'wallets' => $walletRows,
'uses_credit' => $usesCredit,
'credit_limit' => $creditLimit,
'used_credit' => $usedCredit,
'available_credit' => $availableCredit,
'rebate_rate' => $rebateRate,
'rebate_inherited' => $rebateInherited,
'risk_tags' => $player->risk_tags ?? [],
'rebate_profiles' => DB::table('player_rebate_profiles')
->where('player_id', $player->id)
->orderBy('game_type')
->get()
->map(static fn (object $row): array => [
'game_type' => (string) $row->game_type,
'rebate_rate' => (float) $row->rebate_rate,
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
'inherit_from_agent' => (bool) $row->inherit_from_agent,
])
->all(),
];
}
/**
* @return array{0: ?float, 1: bool} rebate rate (ratio) and whether inherited from agent
*/
private static function resolveListRebate(Player $player, ?\App\Models\AgentNode $agent): array
{
$row = DB::table('player_rebate_profiles')
->where('player_id', $player->id)
->where('game_type', '*')
->first();
if ($row !== null && ! (bool) $row->inherit_from_agent) {
return [(float) $row->rebate_rate, false];
}
if ($agent !== null) {
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
return [(float) ($profile?->default_player_rebate ?? 0), true];
}
return [null, false];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Support;
/** 玩家登录来源(与 funding_mode 配合,见双模式玩家改造计划)。 */
final class PlayerAuthSource
{
public const MAIN_SITE_SSO = 'main_site_sso';
public const LOTTERY_NATIVE = 'lottery_native';
/**
* @return list<string>
*/
public static function all(): array
{
return [self::MAIN_SITE_SSO, self::LOTTERY_NATIVE];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Support;
use App\Models\Player;
/** 玩家资金模式:钱包(主站划转)或信用(代理授信)。 */
final class PlayerFundingMode
{
public const WALLET = 'wallet';
public const CREDIT = 'credit';
public static function usesCredit(Player $player): bool
{
$mode = (string) ($player->funding_mode ?? '');
if ($mode === self::CREDIT) {
return true;
}
if ($mode === self::WALLET) {
return false;
}
return (string) ($player->auth_source ?? '') === PlayerAuthSource::LOTTERY_NATIVE
&& CreditLineMode::isEnabledForSiteCode((string) $player->site_code);
}
public static function usesWallet(Player $player): bool
{
return ! self::usesCredit($player);
}
}