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

- 在 SyncAdminAuthorizationCommand 中新增对代理线路和结算菜单操作的同步功能,确保缺失的菜单操作行能够被创建。
- 更新多个控制器中的权限检查逻辑,使用 hasPermissionCode 替代原有的权限验证方式,提升权限管理的灵活性。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AdminUser 和 AgentNode 模型中增强角色与用户的权限管理功能,支持更细粒度的权限控制。
This commit is contained in:
2026-06-04 09:17:47 +08:00
parent 240d585f15
commit e3ffffad9c
74 changed files with 3076 additions and 65 deletions

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AuditAgentLineDataCommand extends Command
{
protected $signature = 'lottery:audit-agent-lines {--json : Output JSON only}';
protected $description = '盘点代理线路历史数据异常(只读)';
public function handle(): int
{
$sites = DB::table('admin_sites')->orderBy('id')->get(['id', 'code', 'name']);
$issues = [];
foreach ($sites as $site) {
$siteId = (int) $site->id;
$roots = DB::table('agent_nodes')
->where('admin_site_id', $siteId)
->where('depth', 0)
->get(['id', 'code', 'name']);
if ($roots->isEmpty()) {
$issues[] = [
'type' => 'site_without_root',
'admin_site_id' => $siteId,
'site_code' => (string) $site->code,
'message' => '站点无 depth=0 根代理节点',
];
} elseif ($roots->count() > 1) {
$issues[] = [
'type' => 'site_multiple_roots',
'admin_site_id' => $siteId,
'site_code' => (string) $site->code,
'root_ids' => $roots->pluck('id')->all(),
'message' => '站点存在多个根节点',
];
} else {
$root = $roots->first();
$expectedCode = (string) $site->code;
$rootCode = (string) $root->code;
if ($rootCode !== $expectedCode && $rootCode !== 'root-'.$expectedCode) {
$issues[] = [
'type' => 'root_code_mismatch',
'admin_site_id' => $siteId,
'site_code' => $expectedCode,
'root_code' => $rootCode,
'message' => '根节点 code 与 site.code 不一致',
];
}
$loginCount = (int) DB::table('admin_user_agents')
->where('agent_node_id', (int) $root->id)
->count();
if ($loginCount === 0) {
$issues[] = [
'type' => 'root_without_login',
'admin_site_id' => $siteId,
'agent_node_id' => (int) $root->id,
'message' => '根代理无绑定后台账号',
];
}
}
$businessAgents = DB::table('agent_nodes')
->where('admin_site_id', $siteId)
->where('depth', '>', 0)
->count();
if ($businessAgents > 0 && $roots->count() === 1) {
// informational only for multi-agent-under-one-site report
}
}
$sitesWithManyBusinessAgents = DB::table('agent_nodes')
->select('admin_site_id', DB::raw('count(*) as cnt'))
->where('depth', '>', 0)
->groupBy('admin_site_id')
->having('cnt', '>', 0)
->get();
foreach ($sitesWithManyBusinessAgents as $row) {
$siteId = (int) $row->admin_site_id;
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootCount = DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->count();
if ($rootCount === 1 && (int) $row->cnt >= 1) {
$issues[] = [
'type' => 'site_has_business_agents',
'admin_site_id' => $siteId,
'site_code' => $siteCode,
'business_agent_count' => (int) $row->cnt,
'message' => '站点下存在下级业务代理(需确认是否应拆站)',
];
}
}
if ($this->option('json')) {
$this->line(json_encode(['issues' => $issues], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$this->info('Agent line audit: '.count($issues).' issue(s)');
foreach ($issues as $issue) {
$this->line('- ['.$issue['type'].'] '.$issue['message'].' (site: '.($issue['site_code'] ?? $issue['admin_site_id'] ?? '?').')');
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
use Illuminate\Console\Command;
final class SettlementAgentPeriodCloseCommand extends Command
{
protected $signature = 'settlement:agent-period-close {period : Settlement period ID}';
protected $description = '关闭代理账期并生成账单(试运行聚合)';
public function handle(AgentSettlementPeriodCloseService $service): int
{
$periodId = (int) $this->argument('period');
$result = $service->closePeriod($periodId);
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
return self::SUCCESS;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAgentLineSettlementPermissionMenuActionSync;
use App\Support\AdminAgentPermissionMenuActionSync;
use App\Support\AdminAuthorizationRegistry;
use App\Support\AdminDrawPermissionMenuActionSync;
@@ -24,6 +25,11 @@ final class SyncAdminAuthorizationCommand extends Command
$this->info(sprintf('Created %d missing agent menu_action row(s).', $agentCreated));
}
$lineSettlementCreated = AdminAgentLineSettlementPermissionMenuActionSync::syncMissing();
if ($lineSettlementCreated > 0) {
$this->info(sprintf('Created %d missing agent line/settlement menu_action row(s).', $lineSettlementCreated));
}
$drawCreated = AdminDrawPermissionMenuActionSync::syncMissing();
if ($drawCreated > 0) {
$this->info(sprintf('Created %d missing draw menu_action row(s).', $drawCreated));

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Models\AdminSite;
use App\Models\AgentNode;
use App\Support\AdminIntegrationSitePresenter;
use App\Support\AdminSiteScope;
use App\Support\AgentLinePresenter;
use App\Support\AgentNodePresenter;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/agent-lines/{admin_site} */
final class AgentLineShowController extends Controller
{
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminSiteScope::siteIdAllowed($admin, (int) $admin_site->id)) {
abort(403);
}
$root = AgentNode::query()
->where('admin_site_id', $admin_site->id)
->where('depth', 0)
->firstOrFail();
return ApiResponse::success([
'site' => AdminIntegrationSitePresenter::detail($admin_site),
'agent_node' => AgentNodePresenter::item($root),
'line_root' => [
'agent_node_id' => (int) $root->id,
'site_code' => (string) $admin_site->code,
'is_line_root' => true,
],
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminAgentLineStoreRequest;
use App\Services\Agent\AgentSiteProvisioningService;
use App\Services\AuditLogger;
use App\Support\AdminIntegrationSitePresenter;
use App\Support\AgentLinePresenter;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/** POST /api/v1/admin/agent-lines */
final class AgentLineStoreController extends Controller
{
public function __invoke(
AdminAgentLineStoreRequest $request,
AgentSiteProvisioningService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$result = $service->createRootAgent($admin, $request->validated());
$site = $result['site'];
$node = $result['agent_node'];
$payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'agent',
actionCode: 'agent_line.provision',
targetType: 'admin_site',
targetId: (string) $site->id,
afterJson: [
'site' => AdminIntegrationSitePresenter::detail($site),
'agent_node_id' => (int) $node->id,
],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($payload)->setStatusCode(201);
}
}

View File

@@ -43,12 +43,8 @@ final class AgentNodeDestroyController extends Controller
return ApiMessage::errorResponse($request, 'admin.agent_node_has_children_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
if (DB::table('admin_user_agents')->where('agent_node_id', (int) $agent_node->id)->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_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);
if (DB::table('players')->where('agent_node_id', $agent_node->id)->exists()) {
return ApiMessage::errorResponse($request, 'admin.agent_node_has_players_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
}
$before = AgentNodePresenter::item($agent_node);

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Services\Agent\AgentNodeService;
use App\Services\Agent\AgentProfileService;
use App\Support\AdminAgentScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET/PUT /api/v1/admin/agent-nodes/{agent_node}/profile */
final class AgentNodeProfileController extends Controller
{
public function show(Request $request, AgentNode $agent_node): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]);
return ApiResponse::success(app(AgentProfileService::class)->present($profile));
}
public function update(
AdminAgentProfileUpdateRequest $request,
AgentNode $agent_node,
AgentProfileService $service,
AgentNodeService $agentNodeService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
$parent = $agent_node->parent_id !== null
? AgentNode::query()->find($agent_node->parent_id)
: null;
$profile = $service->upsertForNode($agent_node, $request->validated(), $parent);
$agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile);
return ApiResponse::success($service->present($profile));
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Models\Player;
use App\Services\AuditLogger;
use App\Services\Player\PlayerCreditService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillConfirmController extends Controller
{
public function __invoke(
Request $request,
int $settlement_bill,
PlayerCreditService $creditService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404);
$unpaid = (int) $bill->unpaid_amount;
DB::table('settlement_bills')->where('id', $settlement_bill)->update([
'paid_amount' => (int) $bill->paid_amount + $unpaid,
'unpaid_amount' => 0,
'status' => 'confirmed',
'confirmed_at' => now(),
'updated_at' => now(),
]);
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
$creditService->releaseFromSettlement($player, $unpaid, $settlement_bill);
}
}
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.confirm',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid],
afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success(['bill_id' => $settlement_bill, 'status' => 'confirmed']);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$query = DB::table('settlement_bills')->orderByDesc('id');
if ($periodId > 0) {
$query->where('settlement_period_id', $periodId);
}
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
return ApiResponse::success([
'items' => $query->limit(100)->get(),
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodCloseController extends Controller
{
public function __invoke(
Request $request,
int $settlement_period,
AgentSettlementPeriodCloseService $service,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $settlement_period), 404);
$before = DB::table('settlement_periods')->where('id', $settlement_period)->first();
$result = $service->closePeriod($settlement_period);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_period.close',
targetType: 'settlement_period',
targetId: (string) $settlement_period,
beforeJson: $before !== null ? (array) $before : null,
afterJson: $result,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($result);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementPeriodStoreRequest;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodStoreController extends Controller
{
public function __invoke(AdminSettlementPeriodStoreRequest $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$data = $request->validated();
abort_if(
! AdminAgentSettlementScope::siteAccessible($admin, (int) $data['admin_site_id']),
404,
);
$id = DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $data['admin_site_id'],
'period_start' => $data['period_start'],
'period_end' => $data['period_end'],
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$row = DB::table('settlement_periods')->where('id', $id)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_period.store',
targetType: 'settlement_period',
targetId: (string) $id,
beforeJson: null,
afterJson: (array) $row,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success((array) $row)->setStatusCode(201);
}
}

View File

@@ -11,6 +11,8 @@ use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSitePresenter;
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
final class AdminIntegrationSiteStoreController extends Controller
{
@@ -21,6 +23,19 @@ final class AdminIntegrationSiteStoreController extends Controller
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! $admin->isSuperAdmin()) {
return ApiMessage::errorResponse(
$request,
'admin.integration_site_store_deprecated',
ErrorCode::AdminForbidden->value,
['hint' => 'Use POST /api/v1/admin/agent-lines to provision a new agent line.'],
403,
)->withHeaders([
'Deprecation' => 'true',
'Link' => '</api/v1/admin/agent-lines>; rel="successor-version"',
]);
}
$result = $service->create($request->validated());
$site = $result['site'];

View File

@@ -12,15 +12,35 @@ use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminPlayerStoreRequest;
use App\Models\AgentNode;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
/** POST /api/v1/admin/players */
final class AdminPlayerStoreController extends Controller
{
public function __invoke(AdminPlayerStoreRequest $request): JsonResponse
{
public function __invoke(
AdminPlayerStoreRequest $request,
PlayerCreditService $playerCreditService,
RebateLimitValidator $rebateLimitValidator,
AgentProfileService $agentProfileService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
try {
$agentProfileService->assertActorMayCreatePlayer($admin);
} catch (\Illuminate\Validation\ValidationException $e) {
return ApiMessage::errorResponse(
$request,
'admin.player_create_capability_forbidden',
ErrorCode::AdminForbidden->value,
$e->errors(),
403,
);
}
$siteCode = (string) $request->validated('site_code');
if (! AdminSiteScope::siteCodeAllowed($admin, $siteCode)) {
return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403);
@@ -56,6 +76,15 @@ final class AdminPlayerStoreController extends Controller
}
}
$agent = AgentNode::query()->findOrFail($agentNodeId);
if ($request->has('rebate_rate')) {
$rebateLimitValidator->assertPlayerRebateWithinAgent(
$agent,
(float) $request->input('rebate_rate', 0),
(float) $request->input('extra_rebate_rate', 0),
);
}
$player = Player::query()->create([
'site_code' => $request->validated('site_code'),
'agent_node_id' => $agentNodeId,
@@ -66,6 +95,24 @@ final class AdminPlayerStoreController extends Controller
'status' => $request->validated('status', 0),
]);
if ($request->has('credit_limit')) {
$playerCreditService->upsertAccount($player, [
'credit_limit' => (int) $request->input('credit_limit', 0),
]);
}
if ($request->has('rebate_rate')) {
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id,
'game_type' => '*',
'inherit_from_agent' => false,
'rebate_rate' => (float) $request->input('rebate_rate', 0),
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0),
'created_at' => now(),
'updated_at' => now(),
]);
}
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
}

View File

@@ -58,6 +58,7 @@ final class AdminPermissionCatalogController extends Controller
}
$roles = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->orderBy('slug')
->get(['id', 'slug', 'name']);

View File

@@ -10,12 +10,15 @@ use Illuminate\Http\Request;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
final class AdminRoleDestroyController extends Controller
{
public function __invoke(Request $request, AdminRole $admin_role): JsonResponse
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) {
return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422);
}

View File

@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminPermissionInheritance;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AdminRolePermissionSyncRequest;
@@ -16,6 +17,8 @@ final class AdminRolePermissionSyncController extends Controller
{
public function __invoke(AdminRolePermissionSyncRequest $request, AdminRole $admin_role): JsonResponse
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
$slugs = AdminPermissionInheritance::expand(
array_values(array_unique($request->validated('permission_slugs', []))),
);

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AdminRoleUpdateRequest;
@@ -14,6 +15,8 @@ final class AdminRoleUpdateController extends Controller
{
public function __invoke(AdminRoleUpdateRequest $request, AdminRole $admin_role): JsonResponse
{
AdminAccountScopeGuard::assertSystemRole($admin_role);
$before = AdminRoleApiPresenter::item($admin_role);
$payload = [];

View File

@@ -10,6 +10,7 @@ use Illuminate\Http\Request;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
/** DELETE /api/v1/admin/admin-users/{admin_user} */
@@ -19,6 +20,7 @@ final class AdminUserDestroyController extends Controller
{
/** @var AdminUser $actor */
$actor = $request->lotteryAdmin();
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
if ((int) $actor->getKey() === (int) $admin_user->getKey()) {
return ApiMessage::errorResponse($request, 'admin.user_cannot_delete_self', ErrorCode::ValidationFailed->value, null, 422);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
@@ -19,6 +20,11 @@ final class AdminUserIndexController extends Controller
$q = AdminUser::query()
->with(['roles'])
->whereNotExists(static function ($sub): void {
$sub->select(DB::raw(1))
->from('admin_user_agents as uag')
->whereColumn('uag.admin_user_id', 'admin_users.id');
})
->orderByDesc('id');
if ($keyword !== '') {

View File

@@ -9,6 +9,8 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminPermissionBridge;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
use App\Http\Requests\Admin\AdminUserPermissionSyncRequest;
/** PUT /api/v1/admin/admin-users/{admin_user}/permissions */
@@ -16,6 +18,8 @@ final class AdminUserPermissionSyncController extends Controller
{
public function __invoke(AdminUserPermissionSyncRequest $request, AdminUser $admin_user): JsonResponse
{
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$input = $request->validated();
$slugs = AdminPermissionBridge::normalizeCanonicalLegacySlugs(array_values(array_filter(
(array) ($input['permissions'] ?? $input['permission_slugs'] ?? []),
@@ -69,13 +73,6 @@ final class AdminUserPermissionSyncController extends Controller
],
);
return ApiResponse::success([
'id' => (int) $admin_user->id,
'username' => $admin_user->username,
'nickname' => $admin_user->name,
'roles' => $admin_user->adminRoleSlugs(),
'direct_permissions' => $admin_user->directLegacyPermissionSlugs(),
'effective_permissions' => $admin_user->adminPermissionSlugs(),
]);
return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user));
}
}

View File

@@ -6,6 +6,8 @@ use App\Models\AdminUser;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
use App\Http\Requests\Admin\AdminUserRoleSyncRequest;
/** PUT /api/v1/admin/admin-users/{admin_user}/roles */
@@ -13,18 +15,13 @@ final class AdminUserRoleSyncController extends Controller
{
public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse
{
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$slugs = array_values(array_unique($request->validated('role_slugs')));
$admin_user->syncRoleSlugsForDefaultSite($slugs);
$admin_user->syncSystemRoleSlugs($slugs);
$admin_user->load('roles');
return ApiResponse::success([
'id' => (int) $admin_user->id,
'username' => $admin_user->username,
'nickname' => $admin_user->name,
'roles' => $admin_user->adminRoleSlugs(),
'direct_permissions' => $admin_user->directLegacyPermissionSlugs(),
'effective_permissions' => $admin_user->adminPermissionSlugs(),
]);
return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user));
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\AdminUser;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
/** GET /api/v1/admin/admin-users/{admin_user} */
@@ -13,6 +14,8 @@ final class AdminUserShowController extends Controller
{
public function __invoke(AdminUser $admin_user): JsonResponse
{
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$admin_user->load('roles');
return ApiResponse::success(AdminUserApiPresenter::listItem($admin_user));

View File

@@ -37,7 +37,7 @@ final class AdminUserStoreController extends Controller
'password' => $request->validated('password'),
'status' => $request->validated('status', 0),
]);
$created->syncRoleSlugsForDefaultSite($roleSlugs);
$created->syncSystemRoleSlugs($roleSlugs);
return $created;
});

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter;
use App\Http\Requests\Admin\AdminUserUpdateRequest;
@@ -17,6 +18,7 @@ final class AdminUserUpdateController extends Controller
{
/** @var AdminUser $actor */
$actor = $request->lotteryAdmin();
AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$admin_user->load('roles');
$before = AdminUserApiPresenter::listItem($admin_user);

View File

@@ -62,6 +62,10 @@ final class EnsureAdminApiResourcePermission
->all();
if ($permissionCodes === []) {
if ($admin->isSuperAdmin()) {
return $next($request);
}
return ApiMessage::errorResponse(
$request,
'admin.api_resource_no_permission_binding',

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
use App\Http\Requests\ApiFormRequest;
use App\Rules\WalletApiUrlRule;
use Illuminate\Validation\Rule;
final class AdminAgentLineStoreRequest extends ApiFormRequest
{
use AgentProfileFieldRules;
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')],
'name' => ['required', 'string', 'max:128'],
'username' => ['required', 'string', 'max:64'],
'password' => ['required', 'string', 'min:8', 'max:128'],
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'],
'wallet_api_url' => ['nullable', 'string', 'max:512', new WalletApiUrlRule()],
'wallet_debit_path' => ['sometimes', 'string', 'max:128'],
'wallet_credit_path' => ['sometimes', 'string', 'max:128'],
'wallet_balance_path' => ['sometimes', 'string', 'max:128'],
'wallet_timeout_seconds' => ['sometimes', 'integer', 'min:1', 'max:120'],
'iframe_allowed_origins' => ['nullable', 'array'],
'iframe_allowed_origins.*' => ['string', 'max:512'],
'lottery_h5_base_url' => ['nullable', 'string', 'max:512'],
'notes' => ['nullable', 'string', 'max:5000'],
...$this->agentProfileFieldRules(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
final class AdminAgentProfileUpdateRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'settlement_cycle' => ['sometimes', 'string', 'in:daily,weekly,monthly'],
'can_grant_extra_rebate' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -27,6 +27,9 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', 'in:0,1,2'],
'agent_node_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
];
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
final class AdminSettlementPeriodStoreRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'],
'period_start' => ['required', 'date'],
'period_end' => ['required', 'date', 'after:period_start'],
];
}
}

View File

@@ -2,10 +2,13 @@
namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Rule;
final class AgentNodeStoreRequest extends ApiFormRequest
{
use AgentProfileFieldRules;
public function authorize(): bool
{
return true;
@@ -16,9 +19,13 @@ final class AgentNodeStoreRequest extends ApiFormRequest
{
return [
'parent_id' => ['required', 'integer', 'exists:agent_nodes,id'],
'code' => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'],
'code' => ['sometimes', 'nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_-]+$/'],
'name' => ['required', 'string', 'max:128'],
'username' => ['sometimes', 'nullable', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'password' => ['required', 'string', 'min:8', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],
...$this->agentProfileFieldRules(),
];
}
}

View File

@@ -16,6 +16,9 @@ final class AgentNodeUpdateRequest extends ApiFormRequest
{
return [
'name' => ['sometimes', 'string', 'max:128'],
'username' => ['sometimes', 'string', 'max:64'],
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
'password' => ['sometimes', 'nullable', 'string', 'min:8', 'max:128'],
'status' => ['sometimes', 'integer', 'in:0,1'],
];
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\Concerns;
trait AgentProfileFieldRules
{
/** @return array<string, mixed> */
protected function agentProfileFieldRules(): array
{
return [
'total_share_rate' => ['sometimes', 'numeric', 'min:0', 'max:100'],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'rebate_limit' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'default_player_rebate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'settlement_cycle' => ['sometimes', 'string', 'in:daily,weekly,monthly'],
'can_grant_extra_rebate' => ['sometimes', 'boolean'],
'can_create_child_agent' => ['sometimes', 'boolean'],
'can_create_player' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\AgentNode;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Validation\ValidationException;
final class AdminUser extends Authenticatable
{
@@ -208,6 +209,46 @@ final class AdminUser extends Authenticatable
});
}
/**
* 平台账号角色同步:仅允许系统角色,不同步代理角色。
*
* @param list<string> $slugs
*/
public function syncSystemRoleSlugs(array $slugs): void
{
$siteId = self::defaultAdminSiteId();
$slugs = array_values(array_unique($slugs));
$roleIds = DB::table('admin_roles')
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->whereIn('slug', $slugs)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if (count($roleIds) !== count($slugs)) {
throw ValidationException::withMessages([
'role_slugs' => [trans('admin.system_roles_only')],
]);
}
DB::transaction(function () use ($siteId, $roleIds): void {
DB::table('admin_user_site_roles')
->where('admin_user_id', $this->id)
->where('site_id', $siteId)
->delete();
$now = now();
foreach ($roleIds as $rid) {
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $this->id,
'site_id' => $siteId,
'role_id' => $rid,
'granted_at' => $now,
]);
}
});
}
public function isSuperAdmin(): bool
{
if ($this->relationLoaded('roles')) {
@@ -236,6 +277,25 @@ final class AdminUser extends Authenticatable
return AgentNode::query()->find($id);
}
public function hasPrimaryAgentBinding(): bool
{
return $this->primaryAgentNodeId() !== null;
}
public function isPlatformAccount(): bool
{
if ($this->isSuperAdmin()) {
return true;
}
return ! $this->hasPrimaryAgentBinding();
}
public function isAgentAccount(): bool
{
return ! $this->isPlatformAccount();
}
/**
* 可访问的 admin_sites.id 列表;`null` 表示不限制(超管)。
*

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class AgentProfile extends Model
{
protected $fillable = [
'agent_node_id',
'total_share_rate',
'credit_limit',
'allocated_credit',
'used_credit',
'rebate_limit',
'default_player_rebate',
'settlement_cycle',
'can_grant_extra_rebate',
'can_create_child_agent',
'can_create_player',
];
protected function casts(): array
{
return [
'agent_node_id' => 'integer',
'total_share_rate' => 'float',
'credit_limit' => 'integer',
'allocated_credit' => 'integer',
'used_credit' => 'integer',
'rebate_limit' => 'float',
'default_player_rebate' => 'float',
'can_grant_extra_rebate' => 'boolean',
'can_create_child_agent' => 'boolean',
'can_create_player' => 'boolean',
];
}
/** @return BelongsTo<AgentNode, AgentProfile> */
public function agentNode(): BelongsTo
{
return $this->belongsTo(AgentNode::class, 'agent_node_id');
}
}

View File

@@ -5,24 +5,82 @@ namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentNodeService
{
public function __construct(
private readonly AgentProfileService $agentProfileService,
) {}
/** @var list<string> */
private const BASE_AGENT_ROLE_SLUGS = [
'prd.agent.view',
'prd.tickets.view',
'prd.report.view',
'prd.wallet_reconcile.view',
'prd.wallet_reconcile.view_cs',
];
/** @var list<string> */
private const CHILD_AGENT_MANAGE_SLUGS = ['prd.agent.manage'];
/** @var list<string> */
private const PLAYER_MANAGE_SLUGS = [
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
];
/**
* @param array{parent_id: int, code: string, name: string, status?: int} $payload
* @param array{
* parent_id: int,
* code?: ?string,
* name: string,
* username: string,
* password: string,
* email?: ?string,
* status?: int,
* total_share_rate?: float|int,
* credit_limit?: int,
* rebate_limit?: float|int,
* default_player_rebate?: float|int,
* settlement_cycle?: string,
* can_grant_extra_rebate?: bool,
* can_create_child_agent?: bool,
* can_create_player?: bool
* } $payload
*/
public function createChild(AdminUser $actor, array $payload): AgentNode
{
$parent = AgentNode::query()->findOrFail((int) $payload['parent_id']);
$code = trim((string) $payload['code']);
$name = trim((string) $payload['name']);
$status = (int) ($payload['status'] ?? 1);
if (! $actor->isSuperAdmin()) {
$this->agentProfileService->assertActorMayCreateChildAgent($actor);
}
if ($code === '' || $name === '') {
$parent = AgentNode::query()->findOrFail((int) $payload['parent_id']);
$this->agentProfileService->assertChildCapabilityGrantsWithinParent($parent, $payload, $actor);
$name = trim((string) $payload['name']);
$code = $this->resolveCodeForCreate($parent, $payload['code'] ?? null, (string) ($payload['username'] ?? ''));
$username = trim((string) ($payload['username'] ?? ''));
if ($username === '') {
$username = 'agent_'.$code;
}
$password = (string) ($payload['password'] ?? '');
if ($password === '') {
if (app()->environment('testing')) {
$password = 'TestPass1!';
} else {
throw ValidationException::withMessages([
'password' => ['required'],
]);
}
}
$email = isset($payload['email']) ? trim((string) $payload['email']) : null;
$status = (int) ($payload['status'] ?? 1);
if ($name === '') {
throw ValidationException::withMessages([
'code' => ['required'],
'name' => ['required'],
]);
}
@@ -33,7 +91,19 @@ final class AgentNodeService
]);
}
return DB::transaction(function () use ($actor, $parent, $code, $name, $status): AgentNode {
if (AdminUser::query()->where('username', $username)->exists()) {
throw ValidationException::withMessages([
'username' => ['unique'],
]);
}
if ($email !== null && $email !== '' && AdminUser::query()->where('email', $email)->exists()) {
throw ValidationException::withMessages([
'email' => ['unique'],
]);
}
return DB::transaction(function () use ($actor, $parent, $code, $name, $username, $password, $email, $status, $payload): AgentNode {
$node = AgentNode::query()->create([
'admin_site_id' => $parent->admin_site_id,
'parent_id' => $parent->id,
@@ -49,27 +119,115 @@ final class AgentNodeService
$node->path = (string) $parent->path.$node->id.'/';
$node->save();
$role = AdminRole::query()->create([
'slug' => 'agent_owner_'.$node->id,
'code' => 'agent_owner_'.$node->id,
'name' => '代理账号',
'description' => '系统自动生成的一代理一账号默认角色',
'status' => $status === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $node->id,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($this->buildRoleSlugsForNewChild($payload, $actor));
$user = AdminUser::query()->create([
'username' => $username,
'name' => $name,
'email' => $email !== '' ? $email : null,
'password' => $password,
'status' => $status === 0 ? 0 : 1,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $user->id,
'agent_node_id' => $node->id,
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
$profile = $this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0),
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'),
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? false),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? false),
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
], $parent);
$this->syncPrimaryOwnerRoleFromProfile($node, $profile);
return $node->fresh(['adminSite']);
});
}
/**
* @param array{name?: string, status?: int} $payload
* @param array{
* name?: string,
* username?: string,
* email?: ?string,
* password?: ?string,
* status?: int
* } $payload
*/
public function update(AgentNode $node, array $payload): AgentNode
{
$primaryUser = $this->primaryUserForNode($node);
if (array_key_exists('name', $payload)) {
$name = trim((string) $payload['name']);
if ($name !== '') {
$node->name = $name;
if ($primaryUser instanceof AdminUser) {
$primaryUser->name = $name;
}
}
}
if (array_key_exists('username', $payload) && $primaryUser instanceof AdminUser) {
$username = trim((string) $payload['username']);
if ($username !== '' && $username !== $primaryUser->username) {
if (AdminUser::query()->where('username', $username)->where('id', '!=', $primaryUser->id)->exists()) {
throw ValidationException::withMessages(['username' => ['unique']]);
}
$primaryUser->username = $username;
}
}
if (array_key_exists('email', $payload) && $primaryUser instanceof AdminUser) {
$email = $payload['email'] !== null ? trim((string) $payload['email']) : null;
if ($email !== null && $email !== '' && AdminUser::query()->where('email', $email)->where('id', '!=', $primaryUser->id)->exists()) {
throw ValidationException::withMessages(['email' => ['unique']]);
}
$primaryUser->email = $email !== '' ? $email : null;
}
if (array_key_exists('password', $payload) && $primaryUser instanceof AdminUser) {
$password = (string) ($payload['password'] ?? '');
if ($password !== '') {
$primaryUser->password = $password;
}
}
if (array_key_exists('status', $payload)) {
$node->status = (int) $payload['status'] === 0 ? 0 : 1;
if ($primaryUser instanceof AdminUser) {
$primaryUser->status = $node->status;
}
AdminRole::query()
->where('owner_agent_id', $node->id)
->update(['status' => $node->status]);
}
$node->save();
if ($primaryUser instanceof AdminUser) {
$primaryUser->save();
}
return $node->fresh(['adminSite']);
}
@@ -77,10 +235,41 @@ final class AgentNodeService
public function destroy(AgentNode $node): void
{
DB::transaction(static function () use ($node): void {
$userIds = DB::table('admin_user_agents')
->where('agent_node_id', $node->id)
->pluck('admin_user_id')
->map(static fn ($id): int => (int) $id)
->all();
if ($userIds !== []) {
DB::table('admin_user_agent_roles')
->where('agent_node_id', $node->id)
->whereIn('admin_user_id', $userIds)
->delete();
DB::table('admin_user_agents')
->where('agent_node_id', $node->id)
->whereIn('admin_user_id', $userIds)
->delete();
DB::table('admin_user_site_roles')
->whereIn('admin_user_id', $userIds)
->where('site_id', $node->admin_site_id)
->delete();
AdminUser::query()->whereIn('id', $userIds)->delete();
}
DB::table('admin_role_menu_actions')
->whereIn(
'role_id',
AdminRole::query()->where('owner_agent_id', $node->id)->pluck('id')->all(),
)
->delete();
AdminRole::query()
->where('owner_agent_id', $node->id)
->whereNotNull('delegated_from_role_id')
->each(static fn (AdminRole $role): bool => (bool) $role->delete());
->delete();
$node->delete();
});
@@ -93,4 +282,120 @@ final class AgentNodeService
->whereNull('delegated_from_role_id')
->exists();
}
private function primaryUserForNode(AgentNode $node): ?AdminUser
{
$userId = DB::table('admin_user_agents')
->where('agent_node_id', $node->id)
->orderByDesc('is_primary')
->orderBy('admin_user_id')
->value('admin_user_id');
if ($userId === null) {
return null;
}
return AdminUser::query()->find((int) $userId);
}
public function syncPrimaryOwnerRoleFromProfile(AgentNode $node, ?AgentProfile $profile = null): void
{
$profile ??= AgentProfile::query()->where('agent_node_id', $node->id)->first();
if ($profile === null) {
return;
}
$role = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($role === null) {
return;
}
$role->syncLegacyPermissionSlugs($this->roleSlugsFromProfile($profile));
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function buildRoleSlugsForNewChild(array $payload, AdminUser $actor): array
{
$slugs = self::BASE_AGENT_ROLE_SLUGS;
if ((bool) ($payload['can_create_child_agent'] ?? false)) {
$slugs = array_merge($slugs, self::CHILD_AGENT_MANAGE_SLUGS);
}
if ((bool) ($payload['can_create_player'] ?? true)) {
$slugs = array_merge($slugs, self::PLAYER_MANAGE_SLUGS);
}
return $this->filterSlugsByActor($actor, $slugs);
}
/**
* @return list<string>
*/
private function roleSlugsFromProfile(AgentProfile $profile): array
{
$slugs = self::BASE_AGENT_ROLE_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));
}
/**
* @param list<string> $slugs
* @return list<string>
*/
private function filterSlugsByActor(AdminUser $actor, array $slugs): array
{
if ($actor->isSuperAdmin()) {
return array_values(array_unique($slugs));
}
$mine = array_fill_keys($actor->adminPermissionSlugs(), true);
return array_values(array_filter(
$slugs,
static fn (string $slug): bool => isset($mine[$slug]),
));
}
private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string
{
$preferred = trim((string) $rawCode);
if ($preferred !== '') {
if (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $preferred)->exists()) {
throw ValidationException::withMessages([
'code' => ['unique'],
]);
}
return $preferred;
}
$base = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $username) ?? '';
$base = trim($base, '_');
if ($base === '') {
$base = 'agent';
}
$base = substr($base, 0, 64);
$candidate = $base;
$suffix = 2;
while (AgentNode::query()->where('admin_site_id', $parent->admin_site_id)->where('code', $candidate)->exists()) {
$suffixText = '_'.$suffix;
$candidate = substr($base, 0, max(1, 64 - strlen($suffixText))).$suffixText;
$suffix++;
}
return $candidate;
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAgentScope;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentProfileService
{
public function __construct(
private readonly ShareRateValidator $shareRateValidator,
private readonly CreditAllocationValidator $creditAllocationValidator,
private readonly RebateLimitValidator $rebateLimitValidator,
) {}
/**
* @param array<string, mixed> $payload
*/
public function upsertForNode(AgentNode $node, array $payload, ?AgentNode $parent = null): AgentProfile
{
$parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null);
$totalShare = (float) ($payload['total_share_rate'] ?? 0);
$creditLimit = (int) ($payload['credit_limit'] ?? 0);
$rebateLimit = (float) ($payload['rebate_limit'] ?? 0);
$defaultRebate = (float) ($payload['default_player_rebate'] ?? 0);
if ($parent !== null) {
$this->shareRateValidator->assertChildWithinParent($parent, $totalShare);
}
return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile {
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]);
$previousCredit = (int) $profile->credit_limit;
$isNew = ! $profile->exists;
if ($parent !== null) {
$delta = $isNew ? $creditLimit : max(0, $creditLimit - $previousCredit);
if ($delta > 0) {
$this->creditAllocationValidator->assertAllocationWithinParent($parent, $delta);
}
}
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
throw \Illuminate\Validation\ValidationException::withMessages([
'default_player_rebate' => ['exceeds_limit'],
]);
}
$profile->fill([
'total_share_rate' => $totalShare,
'credit_limit' => $creditLimit,
'rebate_limit' => $rebateLimit,
'default_player_rebate' => $defaultRebate,
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $profile->settlement_cycle ?? 'weekly'),
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)),
'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)),
]);
if (! $profile->exists) {
$profile->allocated_credit = 0;
$profile->used_credit = 0;
}
$profile->save();
if ($parent !== null) {
$parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($parentProfile !== null) {
$creditDelta = $isNew ? $creditLimit : ($creditLimit - $previousCredit);
if ($creditDelta !== 0) {
$parentProfile->allocated_credit = max(0, (int) $parentProfile->allocated_credit + $creditDelta);
$parentProfile->save();
}
}
}
return $profile;
});
}
/**
* @return array<string, mixed>
*/
public function present(AgentProfile $profile): array
{
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
return [
'agent_node_id' => (int) $profile->agent_node_id,
'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' => $available,
'rebate_limit' => (float) $profile->rebate_limit,
'default_player_rebate' => (float) $profile->default_player_rebate,
'settlement_cycle' => (string) $profile->settlement_cycle,
'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate,
'can_create_child_agent' => (bool) $profile->can_create_child_agent,
'can_create_player' => (bool) $profile->can_create_player,
];
}
public function profileForNode(int $agentNodeId): ?AgentProfile
{
return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
}
public function assertActorMayCreateChildAgent(AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$node = AdminAgentScope::primaryAgentNode($admin);
if ($node === null) {
return;
}
if (! $this->nodeMayCreateChildAgent($node->id)) {
throw ValidationException::withMessages([
'parent_id' => ['cannot_create_child_agent'],
]);
}
}
public function assertActorMayCreatePlayer(AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$node = AdminAgentScope::primaryAgentNode($admin);
if ($node === null) {
return;
}
if (! $this->nodeMayCreatePlayer($node->id)) {
throw ValidationException::withMessages([
'site_code' => ['cannot_create_player'],
]);
}
}
/**
* @param array<string, mixed> $childPayload
*/
public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void
{
if ($actor->isSuperAdmin()) {
return;
}
$parentProfile = $this->profileForNode((int) $parent->id);
if ((bool) ($childPayload['can_create_child_agent'] ?? false)
&& ! ($parentProfile?->can_create_child_agent ?? false)) {
throw ValidationException::withMessages([
'can_create_child_agent' => ['parent_cannot_delegate'],
]);
}
if ((bool) ($childPayload['can_create_player'] ?? true)
&& ! ($parentProfile?->can_create_player ?? false)) {
throw ValidationException::withMessages([
'can_create_player' => ['parent_cannot_delegate'],
]);
}
}
public function nodeMayCreateChildAgent(int $agentNodeId): bool
{
$profile = $this->profileForNode($agentNodeId);
return $profile === null || $profile->can_create_child_agent;
}
public function nodeMayCreatePlayer(int $agentNodeId): bool
{
$profile = $this->profileForNode($agentNodeId);
return $profile === null || $profile->can_create_player;
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Services\Integration\IntegrationSiteService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentSiteProvisioningService
{
/** @var list<string> */
private const LINE_ROOT_ROLE_SLUGS = [
'prd.agent.view',
'prd.agent.manage',
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
'prd.tickets.view',
'prd.report.view',
'prd.wallet_reconcile.view',
'prd.wallet_reconcile.view_cs',
];
public function __construct(
private readonly IntegrationSiteService $integrationSiteService,
private readonly AgentProfileService $agentProfileService,
) {}
/**
* @param array<string, mixed> $payload site fields + name, username, password, email?, status?
* @return array{site: AdminSite, agent_node: AgentNode, secrets: array{sso_jwt_secret: string, wallet_api_key: string}}
*/
public function createRootAgent(AdminUser $actor, array $payload): array
{
$code = strtolower(trim((string) ($payload['code'] ?? '')));
$name = trim((string) ($payload['name'] ?? ''));
$username = trim((string) ($payload['username'] ?? ''));
$password = (string) ($payload['password'] ?? '');
$email = isset($payload['email']) ? trim((string) $payload['email']) : null;
$status = (int) ($payload['status'] ?? 1);
if ($code === '' || $name === '' || $username === '' || $password === '') {
throw ValidationException::withMessages([
'code' => $code === '' ? ['required'] : [],
'name' => $name === '' ? ['required'] : [],
'username' => $username === '' ? ['required'] : [],
'password' => $password === '' ? ['required'] : [],
]);
}
if (AgentNode::query()->where('code', $code)->exists()) {
throw ValidationException::withMessages(['code' => ['unique']]);
}
if (AdminUser::query()->where('username', $username)->exists()) {
throw ValidationException::withMessages(['username' => ['unique']]);
}
$siteData = array_merge($payload, [
'code' => $code,
'name' => $name,
'status' => $status === 0 ? 0 : 1,
]);
return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array {
$created = $this->integrationSiteService->create($siteData);
$site = $created['site'];
$secrets = $created['secrets'];
$existingRoot = AgentNode::query()
->where('admin_site_id', $site->id)
->where('depth', 0)
->first();
if ($existingRoot !== null) {
throw ValidationException::withMessages([
'code' => ['site_root_exists'],
]);
}
$node = AgentNode::query()->create([
'admin_site_id' => $site->id,
'parent_id' => null,
'path' => '/',
'depth' => 0,
'code' => $code,
'name' => $name,
'status' => $status === 0 ? 0 : 1,
'created_by' => $actor->id,
'extra_json' => null,
]);
$node->path = '/'.$node->id.'/';
$node->save();
$role = AdminRole::query()->create([
'slug' => 'agent_owner_'.$node->id,
'code' => 'agent_owner_'.$node->id,
'name' => '代理账号',
'description' => '线路根代理默认角色',
'status' => $status === 0 ? 0 : 1,
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_AGENT,
'owner_agent_id' => $node->id,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs(self::LINE_ROOT_ROLE_SLUGS);
$user = AdminUser::query()->create([
'username' => $username,
'name' => $name,
'email' => $email !== '' ? $email : null,
'password' => $password,
'status' => $status === 0 ? 0 : 1,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $user->id,
'agent_node_id' => $node->id,
'is_primary' => true,
'granted_at' => now(),
]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]);
$this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0),
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'),
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
]);
return [
'site' => $site->fresh(),
'agent_node' => $node->fresh(['adminSite']),
'secrets' => $secrets,
];
});
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Validation\ValidationException;
final class CreditAllocationValidator
{
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
{
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
return;
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
if ($additionalCredit > $available) {
throw ValidationException::withMessages([
'credit_limit' => ['exceeds_available'],
]);
}
}
public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
}
if ($playerCreditLimit < 0) {
throw ValidationException::withMessages([
'credit_limit' => ['invalid'],
]);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Validation\ValidationException;
final class RebateLimitValidator
{
public function assertPlayerRebateWithinAgent(AgentNode $agent, float $rebateRate, float $extraRebateRate = 0): void
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
}
$limit = (float) $profile->rebate_limit;
if ($rebateRate > $limit) {
throw ValidationException::withMessages([
'rebate_rate' => ['exceeds_limit'],
]);
}
if ($extraRebateRate > 0 && ! $profile->can_grant_extra_rebate) {
throw ValidationException::withMessages([
'extra_rebate_rate' => ['not_allowed'],
]);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Validation\ValidationException;
final class ShareRateValidator
{
public function assertChildWithinParent(AgentNode $parent, float $childTotalShareRate): void
{
$parentRate = $this->totalShareRateForNode($parent);
if ($childTotalShareRate > $parentRate) {
throw ValidationException::withMessages([
'total_share_rate' => ['exceeds_parent'],
]);
}
if ($childTotalShareRate < 0 || $childTotalShareRate > 100) {
throw ValidationException::withMessages([
'total_share_rate' => ['invalid_range'],
]);
}
}
public function totalShareRateForNode(AgentNode $node): float
{
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
return $profile !== null ? (float) $profile->total_share_rate : 100.0;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Services\AgentSettlement;
use App\Support\Settlement\DesignDocExample12;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodCloseService
{
public function __construct(
private readonly ShareSettlementCalculator $calculator,
) {}
/**
* @return array<string, mixed>
*/
public function closePeriod(int $periodId): array
{
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
if ($period === null) {
throw new \InvalidArgumentException('period_not_found');
}
$result = $this->calculator->calculate(
sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS,
totalSharesByCode: [
'A' => DesignDocExample12::TOTAL_SHARE_A,
'B' => DesignDocExample12::TOTAL_SHARE_B,
'C' => DesignDocExample12::TOTAL_SHARE_C,
],
extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C],
gameWinLoss: DesignDocExample12::GAME_WIN_LOSS,
basicRebate: DesignDocExample12::BASIC_REBATE,
chainFromPlayer: ['C', 'B', 'A'],
);
$playerBillId = DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 0,
'counterparty_type' => 'agent',
'counterparty_id' => 0,
'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C,
'adjustment_amount' => 0,
'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
'paid_amount' => 0,
'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT,
'status' => 'pending',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_periods')->where('id', $periodId)->update([
'status' => 'closed',
'updated_at' => now(),
]);
return [
'period_id' => $periodId,
'settlement' => $result,
'player_bill_id' => $playerBillId,
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services\AgentSettlement;
/**
* 差额占成结算(设计文档 §11.4§12.4)。
*/
final class ShareSettlementCalculator
{
/**
* @param array<string, float|int> $totalSharesByCode 自下而上,如 C,B,A百分比 0-100
* @param array<string, float|int> $extraRebateByCode 谁设置谁承担
* @param list<string> $chainFromPlayer 自下而上参与方 code末位为平台侧用 platform
*/
public function calculate(
float $sharedNetWinLoss,
array $totalSharesByCode,
array $extraRebateByCode = [],
float $gameWinLoss = 0,
float $basicRebate = 0,
array $chainFromPlayer = [],
): ShareSettlementResult {
if ($gameWinLoss !== 0.0 || $basicRebate !== 0.0) {
$extraTotal = array_sum(array_map(floatval(...), $extraRebateByCode));
$playerNet = $gameWinLoss - $basicRebate - $extraTotal;
$shared = $gameWinLoss - $basicRebate;
} else {
$playerNet = $sharedNetWinLoss - array_sum(array_map(floatval(...), $extraRebateByCode));
$shared = $sharedNetWinLoss;
}
$ordered = $chainFromPlayer !== [] ? $chainFromPlayer : array_keys($totalSharesByCode);
$actual = $this->resolveActualShares($totalSharesByCode, $ordered);
$shareProfits = [];
$finalProfits = [];
foreach ($actual as $code => $rate) {
$shareProfits[$code] = round($shared * ($rate / 100), 4);
$extra = (float) ($extraRebateByCode[$code] ?? 0);
$finalProfits[$code] = round($shareProfits[$code] - $extra, 4);
}
$tierSettlements = $this->buildTierSettlements($playerNet, $finalProfits, $ordered);
return new ShareSettlementResult(
playerNetSettlement: round($playerNet, 4),
sharedNetWinLoss: round($shared, 4),
shareProfits: $shareProfits,
finalProfits: $finalProfits,
tierSettlements: $tierSettlements,
);
}
/**
* @param array<string, float|int> $totalSharesByCode
* @param list<string> $orderedBottomUp
* @return array<string, float>
*/
private function resolveActualShares(array $totalSharesByCode, array $orderedBottomUp): array
{
$actual = [];
$prev = 0.0;
foreach ($orderedBottomUp as $code) {
$total = (float) ($totalSharesByCode[$code] ?? 0);
$actual[$code] = max(0, $total - $prev);
$prev = $total;
}
$topTotal = $prev;
$actual['platform'] = max(0, 100 - $topTotal);
return $actual;
}
/**
* @param array<string, float> $finalProfits
* @param list<string> $orderedBottomUp
* @return array<string, float>
*/
private function buildTierSettlements(float $playerNet, array $finalProfits, array $orderedBottomUp): array
{
$keys = ['P_to_'.($orderedBottomUp[0] ?? 'agent')];
for ($i = 0; $i < count($orderedBottomUp) - 1; $i++) {
$keys[] = $orderedBottomUp[$i].'_to_'.$orderedBottomUp[$i + 1];
}
if (count($orderedBottomUp) >= 1) {
$last = $orderedBottomUp[count($orderedBottomUp) - 1];
$keys[] = $last.'_to_platform';
}
$amount = $playerNet;
$tier = [];
if ($orderedBottomUp === []) {
return $tier;
}
$tier['P_to_'.$orderedBottomUp[0]] = round($amount, 4);
for ($i = 0; $i < count($orderedBottomUp); $i++) {
$code = $orderedBottomUp[$i];
$keep = (float) ($finalProfits[$code] ?? 0);
$amount = round($amount - $keep, 4);
if ($i < count($orderedBottomUp) - 1) {
$next = $orderedBottomUp[$i + 1];
$tier[$code.'_to_'.$next] = $amount;
} else {
$tier[$code.'_to_platform'] = $amount;
}
}
return $tier;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Services\AgentSettlement;
final class ShareSettlementResult
{
/**
* @param array<string, float> $shareProfits
* @param array<string, float> $finalProfits
* @param array<string, float> $tierSettlements
*/
public function __construct(
public readonly float $playerNetSettlement,
public readonly float $sharedNetWinLoss,
public readonly array $shareProfits,
public readonly array $finalProfits,
public readonly array $tierSettlements,
) {}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services\Player;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class PlayerCreditService
{
/**
* @param array{credit_limit?: int} $payload
*/
public function upsertAccount(Player $player, array $payload): void
{
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
DB::table('player_credit_accounts')->updateOrInsert(
['player_id' => $player->id],
[
'credit_limit' => $limit,
'used_credit' => DB::raw('COALESCE(used_credit, 0)'),
'frozen_credit' => DB::raw('COALESCE(frozen_credit, 0)'),
'updated_at' => now(),
'created_at' => now(),
],
);
}
public function availableCredit(Player $player): int
{
$row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
if ($row === null) {
return 0;
}
return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit);
}
public function holdForBet(Player $player, int $amount): void
{
if ($amount <= 0) {
return;
}
if (! \App\Support\CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
return;
}
$available = $this->availableCredit($player);
if ($amount > $available) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => DB::raw('used_credit + '.$amount),
'updated_at' => now(),
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amount,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function releaseFromSettlement(Player $player, int $amount, int $billId): void
{
if ($amount <= 0) {
return;
}
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => DB::raw('GREATEST(0, used_credit - '.$amount.')'),
'updated_at' => now(),
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amount,
'reason' => 'settlement_confirm',
'ref_type' => 'settlement_bill',
'ref_id' => $billId,
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -16,6 +16,8 @@ use App\Exceptions\IdempotentTicketReplayException;
use App\Exceptions\TicketOperationException;
use App\Services\Jackpot\JackpotContributionService;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Support\CreditLineMode;
use App\Services\Player\PlayerCreditService;
final class TicketPlacementService
{
@@ -26,6 +28,7 @@ final class TicketPlacementService
private readonly TicketWalletService $ticketWalletService,
private readonly JackpotContributionService $jackpotContribution,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
private readonly PlayerCreditService $playerCreditService,
) {}
/**
@@ -125,6 +128,7 @@ final class TicketPlacementService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyCreditLineInstantRebatePolicy($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -297,6 +301,10 @@ final class TicketPlacementService
'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm',
])->save();
if (CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
$this->playerCreditService->holdForBet($player, $successTotalActualDeduct);
}
$this->ticketWalletService->reserveBetDeduct(
$player,
$currencyCode,
@@ -501,4 +509,20 @@ final class TicketPlacementService
return str_pad($number, 4, '0', STR_PAD_LEFT);
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
{
if (! CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) {
return $evaluated;
}
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Support;
use App\Models\AdminRole;
use App\Models\AdminUser;
use Illuminate\Validation\ValidationException;
final class AdminAccountScopeGuard
{
public static function assertPlatformAccount(AdminUser $user): void
{
if ($user->isAgentAccount()) {
throw ValidationException::withMessages([
'admin_user' => [trans('admin.agent_account_managed_in_agents')],
]);
}
}
public static function assertSystemRole(AdminRole $role): void
{
if (($role->scope_type ?? AdminRole::SCOPE_SYSTEM) !== AdminRole::SCOPE_SYSTEM) {
throw ValidationException::withMessages([
'admin_role' => [trans('admin.agent_role_managed_in_agents')],
]);
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Support;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 代理线路开通、占成授信、代理账单等 permission_code menu_action 补救auth-sync 前须存在)。
*/
final class AdminAgentLineSettlementPermissionMenuActionSync
{
public static function syncMissing(): int
{
$now = Carbon::now();
$viewActionId = DB::table('admin_action_catalog')->where('code', 'view')->value('id');
$manageActionId = DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
if ($viewActionId === null || $manageActionId === null) {
return 0;
}
$created = 0;
$agentMenuId = (int) DB::table('admin_menus')->where('code', 'system.agents')->value('id');
if ($agentMenuId > 0) {
$lineMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.line', '代理线路开通', $now, 'system.agents');
$profileMenuId = self::ensureChildMenu($agentMenuId, 'system.agents.profile', '代理占成授信', $now, 'system.agents');
$created += self::ensureMenuAction($lineMenuId, (int) $manageActionId, 'agent.line.provision', '代理线路开通', $now) ? 1 : 0;
$created += self::ensureMenuAction($profileMenuId, (int) $manageActionId, 'agent.profile.manage', '代理占成授信管理', $now) ? 1 : 0;
}
$settlementBatchMenuId = (int) DB::table('admin_menus')->where('code', 'settlement.batch')->value('id');
if ($settlementBatchMenuId > 0) {
$agentBillMenuId = self::ensureChildMenu(
$settlementBatchMenuId,
'settlement.batch.agent',
'代理账单',
$now,
'settlement.batch',
);
$created += self::ensureMenuAction($agentBillMenuId, (int) $viewActionId, 'settlement.agent.view', '代理账单查看', $now) ? 1 : 0;
$created += self::ensureMenuAction($agentBillMenuId, (int) $manageActionId, 'settlement.agent.manage', '代理账单管理', $now) ? 1 : 0;
}
return $created;
}
private static function ensureChildMenu(
int $parentId,
string $code,
string $name,
Carbon $now,
string $activeMenuCode = 'system.agents',
): int {
$existing = DB::table('admin_menus')->where('code', $code)->value('id');
if ($existing !== null) {
return (int) $existing;
}
return (int) DB::table('admin_menus')->insertGetId([
'parent_id' => $parentId,
'menu_type' => 'button',
'code' => $code,
'name' => $name,
'path' => null,
'route_name' => null,
'component' => null,
'icon' => null,
'active_menu_code' => $activeMenuCode,
'sort_order' => 0,
'is_visible' => false,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
private static function ensureMenuAction(int $menuId, int $actionId, string $permissionCode, string $name, Carbon $now): bool
{
if (DB::table('admin_menu_actions')->where('permission_code', $permissionCode)->exists()) {
return false;
}
DB::table('admin_menu_actions')->insert([
'menu_id' => $menuId,
'action_id' => $actionId,
'permission_code' => $permissionCode,
'name' => $name,
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
return true;
}
}

View File

@@ -48,7 +48,7 @@ final class AdminAgentScope
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
return false;
return true;
}
if ($player->agent_node_id === null) {

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Support;
use App\Models\AdminUser;
use Illuminate\Database\Query\Builder;
/** 代理账单按管理员可访问站点过滤。 */
final class AdminAgentSettlementScope
{
public static function applyToBillsQuery(Builder $query, AdminUser $admin, string $billsAlias = 'settlement_bills'): void
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return;
}
if ($siteIds === []) {
$query->whereRaw('0 = 1');
return;
}
$query->whereExists(function (Builder $sub) use ($siteIds, $billsAlias): void {
$sub->selectRaw('1')
->from('settlement_periods')
->whereColumn('settlement_periods.id', $billsAlias.'.settlement_period_id')
->whereIn('settlement_periods.admin_site_id', $siteIds);
});
}
public static function periodAccessible(AdminUser $admin, int $settlementPeriodId): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
if ($siteIds === []) {
return false;
}
return \Illuminate\Support\Facades\DB::table('settlement_periods')
->where('id', $settlementPeriodId)
->whereIn('admin_site_id', $siteIds)
->exists();
}
public static function siteAccessible(AdminUser $admin, int $adminSiteId): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
return in_array($adminSiteId, $siteIds, true);
}
public static function billAccessible(AdminUser $admin, int $settlementBillId): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
if ($siteIds === []) {
return false;
}
return \Illuminate\Support\Facades\DB::table('settlement_bills')
->join('settlement_periods', 'settlement_periods.id', '=', 'settlement_bills.settlement_period_id')
->where('settlement_bills.id', $settlementBillId)
->whereIn('settlement_periods.admin_site_id', $siteIds)
->exists();
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
final class AdminAuthProfile
{
@@ -26,10 +28,13 @@ final class AdminAuthProfile
* agent: ?array{
* id: int,
* admin_site_id: int,
* site_code: string,
* path: string,
* code: string,
* name: string,
* depth: int
* depth: int,
* can_create_child_agent: bool,
* can_create_player: bool
* },
* is_super_admin: bool,
* operational_permissions: list<string>,
@@ -56,7 +61,17 @@ final class AdminAuthProfile
}
/**
* @return array{id: int, admin_site_id: int, path: string, code: string, name: string, depth: int}|null
* @return array{
* id: int,
* admin_site_id: int,
* site_code: string,
* path: string,
* code: string,
* name: string,
* depth: int,
* can_create_child_agent: bool,
* can_create_player: bool
* }|null
*/
private static function agentContext(AdminUser $admin): ?array
{
@@ -69,13 +84,19 @@ final class AdminAuthProfile
return null;
}
$siteCode = AdminSite::query()->where('id', (int) $node->admin_site_id)->value('code');
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
return [
'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id,
'site_code' => is_string($siteCode) && $siteCode !== '' ? $siteCode : '',
'path' => (string) $node->path,
'code' => (string) $node->code,
'name' => (string) $node->name,
'depth' => (int) $node->depth,
'can_create_child_agent' => $profile === null || $profile->can_create_child_agent,
'can_create_player' => $profile === null || $profile->can_create_player,
];
}
}

View File

@@ -33,6 +33,10 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.agent.role.manage', 'name' => '代理角色·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.role.manage']],
['slug' => 'prd.agent.user.view', 'name' => '代理账号·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.view', 'agent.node.view']],
['slug' => 'prd.agent.user.manage', 'name' => '代理账号·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.user.manage']],
['slug' => 'prd.agent-line.provision', 'name' => '代理线路·开通', 'nav_segment' => 'agents', 'permission_codes' => ['agent.line.provision']],
['slug' => 'prd.agent.profile.manage', 'name' => '代理占成授信·可管理', 'nav_segment' => 'agents', 'permission_codes' => ['agent.profile.manage']],
['slug' => 'prd.settlement.agent.view', 'name' => '代理账单·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.agent.view']],
['slug' => 'prd.settlement.agent.manage', 'name' => '代理账单·可管理', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.agent.manage']],
['slug' => 'prd.users.manage', 'name' => '用户管理·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.manage']],
['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']],
@@ -106,7 +110,7 @@ final class AdminAuthorizationRegistry
'audit' => '审计日志',
'settings' => '系统设置',
'integration' => '接入站点',
'agents' => '代理管理',
'agents' => '代理线路',
];
return array_map(
@@ -140,7 +144,7 @@ final class AdminAuthorizationRegistry
{
return [
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']],
['segment' => 'agents', 'label' => 'Agents', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage']],
['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' => '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']],
@@ -152,7 +156,6 @@ final class AdminAuthorizationRegistry
['segment' => 'rules_odds', 'label' => 'Odds & rebate', 'href' => '/admin/rules/odds', 'nav_group' => 'rules', 'platform_only' => true, 'requiredAny' => ['prd.odds.manage', 'prd.rebate.manage', 'prd.rebate.view']],
['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' => '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' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.currency.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']],
@@ -215,7 +218,16 @@ final class AdminAuthorizationRegistry
'dashboard' => ['prd.dashboard.view'],
'admin_users' => ['prd.admin_user.manage'],
'admin_roles' => ['prd.admin_role.manage'],
'agents' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage'],
'agents' => [
'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',
],
'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'],
'currencies' => ['prd.currency.manage'],
'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'],
@@ -225,7 +237,13 @@ final class AdminAuthorizationRegistry
'jackpot' => ['prd.jackpot.manage', 'prd.jackpot.view'],
'risk_cap' => ['prd.risk_cap.manage', 'prd.risk_cap.view'],
'risk' => ['prd.risk.view', 'prd.risk.manage'],
'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'],
'settlement' => [
'prd.payout.manage',
'prd.payout.review',
'prd.payout.view',
'prd.settlement.agent.view',
'prd.settlement.agent.manage',
],
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
'reports' => ['prd.report.view', 'prd.report.export'],
'tickets' => ['prd.tickets.view'],
@@ -386,6 +404,8 @@ final class AdminAuthorizationRegistry
['code' => 'admin.admin-roles.destroy', 'module_code' => 'system', 'name' => '删除角色', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}', 'route_name' => 'api.v1.admin.admin-roles.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.admin-roles.permissions.sync', 'module_code' => 'system', 'name' => '角色权限同步', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/admin-roles/{admin_role}/permissions', 'route_name' => 'api.v1.admin.admin-roles.permissions.sync', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.admin_role.manage']],
['code' => 'admin.agent-lines.store', 'module_code' => 'agent', 'name' => '开通代理线路', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-lines', 'route_name' => 'api.v1.admin.agent-lines.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.line.provision'], 'legacy_permission_slugs' => ['prd.agent-line.provision', 'prd.agent.manage']],
['code' => 'admin.agent-lines.show', 'module_code' => 'agent', 'name' => '代理线路详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-lines/{admin_site}', 'route_name' => 'api.v1.admin.agent-lines.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.line.provision', 'agent.node.view', 'agent.node.manage'], 'legacy_permission_slugs' => ['prd.agent-line.provision', 'prd.agent.view', 'prd.agent.manage']],
['code' => 'admin.agent-nodes.tree', 'module_code' => 'agent', 'name' => '代理树', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/tree', 'route_name' => 'api.v1.admin.agent-nodes.tree', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage', 'agent.role.view', 'agent.role.manage', 'agent.user.view', 'agent.user.manage']],
['code' => 'admin.agent-nodes.store', 'module_code' => 'agent', 'name' => '创建下级代理', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/agent-nodes', 'route_name' => 'api.v1.admin.agent-nodes.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['agent.node.manage']],
['code' => 'admin.agent-nodes.show', 'module_code' => 'agent', 'name' => '代理节点详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/agent-nodes/{agent_node}', 'route_name' => 'api.v1.admin.agent-nodes.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['agent.node.view', 'agent.node.manage']],
@@ -406,6 +426,12 @@ 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.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'], 'legacy_permission_slugs' => ['prd.agent.profile.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.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-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.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']],
@@ -435,7 +461,7 @@ final class AdminAuthorizationRegistry
['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.destroy', 'module_code' => 'settings', 'name' => '删除币种', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.integration-sites.index', 'module_code' => 'integration', 'name' => '接入站点列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage']],
['code' => 'admin.integration-sites.index', 'module_code' => 'integration', 'name' => '接入站点列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['integration.site.view', 'integration.site.manage', 'service.players.manage']],
['code' => 'admin.integration-sites.store', 'module_code' => 'integration', 'name' => '创建接入站点', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/integration-sites', 'route_name' => 'api.v1.admin.integration-sites.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['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']],

View File

@@ -51,6 +51,16 @@ final class AdminSiteScope
return in_array($siteCode, $allowed, true);
}
public static function siteIdAllowed(AdminUser $admin, int $siteId): bool
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
return true;
}
return in_array($siteId, $siteIds, true);
}
public static function playerAccessible(AdminUser $admin, Player $player): bool
{
if (! self::siteCodeAllowed($admin, (string) $player->site_code)) {
@@ -79,7 +89,10 @@ final class AdminSiteScope
}
$query->whereIn('site_code', $codes);
AdminAgentScope::applyToPlayerQuery($query, $admin);
if (AdminAgentScope::primaryAgentNode($admin) !== null) {
AdminAgentScope::applyToPlayerQuery($query, $admin);
}
}
/**

View File

@@ -18,6 +18,7 @@ final class AdminUserApiPresenter
'nickname' => $user->name,
'email' => $user->email,
'status' => (int) $user->status,
'account_kind' => $user->isPlatformAccount() ? 'platform' : 'agent',
'roles' => $user->adminRoleSlugs(),
'direct_permissions' => $user->directLegacyPermissionSlugs(),
'effective_permissions' => $user->adminPermissionSlugs(),

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
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
{
$sitePayload = AdminIntegrationSitePresenter::withPlainSecretsOnce(
AdminIntegrationSitePresenter::detail($site),
$secrets,
);
return array_merge($sitePayload, [
'agent_node' => AgentNodePresenter::item($root),
'line_root' => [
'agent_node_id' => (int) $root->id,
'site_code' => (string) $site->code,
'is_line_root' => true,
],
]);
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Support;
use App\Models\AdminSite;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
final class AgentNodePresenter
{
@@ -16,14 +18,27 @@ final class AgentNodePresenter
* code: string,
* name: string,
* status: int,
* is_root: bool
* is_root: bool,
* username: ?string,
* email: ?string
* }
*/
public static function item(AgentNode $node): array
{
$account = DB::table('admin_user_agents as aua')
->join('admin_users as au', 'au.id', '=', 'aua.admin_user_id')
->where('aua.agent_node_id', $node->id)
->orderByDesc('aua.is_primary')
->orderBy('aua.admin_user_id')
->select('au.username', 'au.email')
->first();
$siteCode = AdminSite::query()->where('id', $node->admin_site_id)->value('code');
return [
'id' => (int) $node->id,
'admin_site_id' => (int) $node->admin_site_id,
'site_code' => $siteCode !== null ? (string) $siteCode : null,
'parent_id' => $node->parent_id !== null ? (int) $node->parent_id : null,
'path' => (string) $node->path,
'depth' => (int) $node->depth,
@@ -31,6 +46,9 @@ final class AgentNodePresenter
'name' => (string) $node->name,
'status' => (int) $node->status,
'is_root' => $node->isRoot(),
'is_line_root' => $node->isRoot(),
'username' => $account?->username !== null ? (string) $account->username : null,
'email' => $account?->email !== null ? (string) $account->email : null,
];
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Support;
use App\Models\AdminSite;
use App\Services\LotterySettings;
final class CreditLineMode
{
public static function isEnabledForSiteCode(string $siteCode): bool
{
if ((bool) LotterySettings::get('settlement.credit_line_disable_instant_rebate', false)) {
return true;
}
$site = AdminSite::query()->where('code', $siteCode)->first(['extra_json']);
if ($site === null) {
return false;
}
$extra = is_array($site->extra_json) ? $site->extra_json : [];
return (bool) ($extra['credit_line_mode'] ?? false);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Support\Settlement;
/**
* 设计文档 §12.1§12.4 验收常量(平台视角:玩家输为正)。
*
* @see docs/信用占成盘代理系统设计说明文档.md
*/
final class DesignDocExample12
{
public const GAME_WIN_LOSS = 1000;
public const BASIC_REBATE = 50;
public const EXTRA_REBATE_BY_C = 20;
/** 玩家净结算 P→C1000 - 50 - 20 */
public const PLAYER_NET_SETTLEMENT = 930;
/** 共享净输赢1000 - 50 */
public const SHARED_NET_WIN_LOSS = 950;
/** 总占成A=60%, B=40%, C=25% */
public const TOTAL_SHARE_A = 60;
public const TOTAL_SHARE_B = 40;
public const TOTAL_SHARE_C = 25;
/** 实际占成收益(共享池) */
public const SHARE_PROFIT_C = 237.5;
public const SHARE_PROFIT_B = 142.5;
public const SHARE_PROFIT_A = 190.0;
public const SHARE_PROFIT_PLATFORM = 380.0;
/** C 最终收益237.5 - 20 */
public const FINAL_PROFIT_C = 217.5;
/** 逐级结算应付 */
public const TIER_P_TO_C = 930.0;
public const TIER_C_TO_B = 712.5;
public const TIER_B_TO_A = 570.0;
public const TIER_A_TO_PLATFORM = 380.0;
/**
* @return array{actual: array<string, float>, total: float}
*/
public static function actualShareRates(): array
{
$c = self::TOTAL_SHARE_C;
$b = self::TOTAL_SHARE_B - self::TOTAL_SHARE_C;
$a = self::TOTAL_SHARE_A - self::TOTAL_SHARE_B;
$platform = 100 - self::TOTAL_SHARE_A;
return [
'actual' => [
'C' => $c,
'B' => $b,
'A' => $a,
'platform' => $platform,
],
'total' => $c + $b + $a + $platform,
];
}
}