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

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

View File

@@ -19,3 +19,16 @@
## 后台 RBAC ## 后台 RBAC
`app/Support/AdminAuthorizationRegistry.php` 后,在已有库执行 `php artisan lottery:admin-auth-sync --audit`(见 `docs/admin-rbac.md`)。`migrate:fresh --seed` 会走迁移内的 resync一般不必再手动 sync。 `app/Support/AdminAuthorizationRegistry.php` 后,在已有库执行 `php artisan lottery:admin-auth-sync --audit`(见 `docs/admin-rbac.md`)。`migrate:fresh --seed` 会走迁移内的 resync一般不必再手动 sync。
## 双模式玩家(主站钱包 / 代理信用)
- `players.auth_source``main_site_sso`(主站 JWT`lottery_native`(彩票端账号密码)。
- `players.funding_mode``wallet`(主站划转)与 `credit`(授信下注);**禁止**仅用整站 `credit_line_mode` 代替玩家级判断,用 `PlayerFundingMode::usesCredit($player)`
- 生产环境配置独立 `LOTTERY_NATIVE_JWT_SECRET`(勿与主站 SSO 混用)。
## 信用占成盘(代理账期结算)
- 业务真理源:`docs/信用占成盘代理系统设计说明文档.md`;实施路线:`docs/信用占成盘代理体系改造计划.md`
- **代理账期**代码包:`App\Services\AgentSettlement\`(勿与彩票开奖 `App\Services\Settlement\` / `SettlementBatch` 混用)。
- **禁止**在生产关账路径使用 `DesignDocExample12` 硬编码账单;仅单元/Feature 测试可引用。
- 占成账单聚合必须读注单**快照**`share_snapshot`),禁止按当前 `agent_profiles` 重算历史。

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/** @deprecated 经营代理已改用平台角色 slug=agent */
final class AgentOwnerPermissionsResyncCommand extends Command
{
protected $signature = 'lottery:agent-owner-permissions-resync';
protected $description = '已废弃:请使用 lottery:agent-roles-sync并在「平台角色管理」编辑「代理」角色';
public function handle(): int
{
$this->warn('agent_owner_* 已不再用于经营主账号;正在转调 lottery:agent-roles-sync …');
return $this->call('lottery:agent-roles-sync');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\AdminUser;
use App\Support\AgentDefaultRolePermissions;
use App\Support\AgentPlatformRole;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class AgentRolesSyncCommand extends Command
{
protected $signature = 'lottery:agent-roles-sync';
protected $description = '确保平台「代理」角色存在,并将所有经营代理主账号绑定到该角色(权限在平台角色管理维护)';
public function handle(): int
{
$platform = AgentDefaultRolePermissions::ensurePlatformAgentRole();
$this->info('平台角色 agent#'.$platform->id.')权限数: '.count($platform->legacyPermissionSlugs()));
$bindingCount = 0;
foreach (DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']) as $binding) {
$user = AdminUser::query()->find((int) $binding->admin_user_id);
if ($user === null) {
continue;
}
$user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id);
$bindingCount++;
}
$this->info("已绑定 {$bindingCount} 个经营代理主账号到平台「代理」角色。");
$this->line('调整权限请编辑:平台角色管理 → 代理');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class CreditLineDisableAllSitesCommand extends Command
{
protected $signature = 'lottery:credit-line-disable-all-sites';
protected $description = 'Disable credit_line_mode on all admin sites (rollback)';
public function handle(): int
{
$sites = DB::table('admin_sites')->get();
$count = 0;
foreach ($sites as $site) {
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
unset($extra['credit_line_mode']);
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$count++;
}
$this->info("Disabled credit_line_mode on {$count} site(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class CreditLineEnableAllSitesCommand extends Command
{
protected $signature = 'lottery:credit-line-enable-all-sites';
protected $description = 'Enable credit_line_mode on all admin sites (big bang switch)';
public function handle(): int
{
$sites = DB::table('admin_sites')->get();
$count = 0;
foreach ($sites as $site) {
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$count++;
}
$this->info("Enabled credit_line_mode on {$count} site(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class SettlementMarkOverdueBillsCommand extends Command
{
protected $signature = 'settlement:mark-overdue-bills {--days=7}';
protected $description = 'Mark unpaid settlement bills as overdue past grace days';
public function handle(): int
{
$days = max(1, (int) $this->option('days'));
$cutoff = now()->subDays($days);
$updated = DB::table('settlement_bills')
->whereIn('status', ['confirmed', 'partial_paid'])
->where('unpaid_amount', '>', 0)
->where('updated_at', '<', $cutoff)
->update([
'status' => 'overdue',
'updated_at' => now(),
]);
$this->info("Marked {$updated} bill(s) overdue.");
return self::SUCCESS;
}
}

View File

@@ -9,6 +9,7 @@ use App\Support\AdminAgentLineSettlementPermissionMenuActionSync;
use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminAgentPermissionMenuActionSync;
use App\Support\AdminAuthorizationRegistry; use App\Support\AdminAuthorizationRegistry;
use App\Support\AdminDrawPermissionMenuActionSync; use App\Support\AdminDrawPermissionMenuActionSync;
use App\Support\PlatformSystemRoles;
final class SyncAdminAuthorizationCommand extends Command final class SyncAdminAuthorizationCommand extends Command
{ {
@@ -92,6 +93,13 @@ final class SyncAdminAuthorizationCommand extends Command
count(AdminAuthorizationRegistry::resources()), count(AdminAuthorizationRegistry::resources()),
)); ));
PlatformSystemRoles::ensureAll();
$super = PlatformSystemRoles::ensureSuperAdminRole();
$this->info(sprintf(
'Platform system roles synced (super_admin permissions: %d).',
count($super->legacyPermissionSlugs()),
));
if ((bool) $this->option('audit')) { if ((bool) $this->option('audit')) {
return $this->call('lottery:admin-auth-audit'); return $this->call('lottery:admin-auth-audit');
} }

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Services\Agent\AgentCreditAllocatedSyncService;
use Illuminate\Console\Command;
/** 按直属玩家/下级代理授信重算 allocated_credit修复历史增量漂移。 */
final class SyncAgentAllocatedCreditCommand extends Command
{
protected $signature = 'lottery:sync-agent-allocated-credit {--site= : 仅处理指定 admin_sites.code}';
protected $description = '重算代理已下发额度allocated_credit';
public function handle(AgentCreditAllocatedSyncService $sync): int
{
$siteCode = $this->option('site');
$query = AgentNode::query()->orderBy('id');
if (is_string($siteCode) && $siteCode !== '') {
$query->whereHas('adminSite', static fn ($q) => $q->where('code', $siteCode));
}
$nodes = $query->get();
$updated = 0;
foreach ($nodes as $node) {
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$before = $profile !== null ? (int) $profile->allocated_credit : null;
$sync->syncForAgent($node);
$profile?->refresh();
$after = $profile !== null ? (int) $profile->allocated_credit : null;
if ($before !== $after) {
$updated++;
}
}
$this->info('已处理 '.count($nodes).' 个代理节点,其中 '.$updated.' 个 allocated_credit 有变更。');
return self::SUCCESS;
}
}

View File

@@ -26,7 +26,7 @@ final class AgentLineStoreController extends Controller
$site = $result['site']; $site = $result['site'];
$node = $result['agent_node']; $node = $result['agent_node'];
$payload = AgentLinePresenter::provisioned($site, $node, $result['secrets']); $payload = AgentLinePresenter::provisioned($site, $node);
AuditLogger::recordForAdmin( AuditLogger::recordForAdmin(
$admin, $admin,

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Agent; namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -22,10 +23,21 @@ final class AgentNodeChildrenController extends Controller
return $denied; return $denied;
} }
$items = $agent_node->children() $children = $agent_node->children()->orderBy('code')->get();
->orderBy('code') $profiles = AgentProfile::query()
->whereIn('agent_node_id', $children->pluck('id'))
->get() ->get()
->map(static fn (AgentNode $child): array => AgentNodePresenter::item($child)) ->keyBy('agent_node_id');
$items = $children
->map(static function (AgentNode $child) use ($profiles): array {
$profile = $profiles->get($child->id);
return AgentNodePresenter::item(
$child,
$profile instanceof AgentProfile ? $profile : null,
);
})
->all(); ->all();
return ApiResponse::success(['items' => $items]); return ApiResponse::success(['items' => $items]);

View File

@@ -3,11 +3,12 @@
namespace App\Http\Controllers\Api\V1\Admin\Agent; namespace App\Http\Controllers\Api\V1\Admin\Agent;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest; use App\Http\Requests\Admin\AdminAgentProfileUpdateRequest;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Services\Agent\AgentNodeService;
use App\Services\Agent\AgentProfileService; use App\Services\Agent\AgentProfileService;
use App\Services\AuditLogger;
use App\Support\AdminAgentScope; use App\Support\AdminAgentScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -22,20 +23,28 @@ final class AgentNodeProfileController extends Controller
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
$service = app(AgentProfileService::class);
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]); $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $agent_node->id]);
$parent = $agent_node->parent_id !== null
? AgentNode::query()->find($agent_node->parent_id)
: null;
return ApiResponse::success(app(AgentProfileService::class)->present($profile)); return ApiResponse::success([
...$service->present($profile),
'parent_caps' => $service->parentCapsForNode($parent),
'risk_tags' => $agent_node->risk_tags ?? [],
]);
} }
public function update( public function update(
AdminAgentProfileUpdateRequest $request, AdminAgentProfileUpdateRequest $request,
AgentNode $agent_node, AgentNode $agent_node,
AgentProfileService $service, AgentProfileService $service,
AgentNodeService $agentNodeService,
): JsonResponse { ): JsonResponse {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403); abort_if(! AdminAgentScope::nodeVisibleTo($admin, $agent_node), 403);
abort_if(! AdminAgentScope::nodeProfileEditableBy($admin, $agent_node), 403);
$parent = $agent_node->parent_id !== null $parent = $agent_node->parent_id !== null
? AgentNode::query()->find($agent_node->parent_id) ? AgentNode::query()->find($agent_node->parent_id)
@@ -46,9 +55,33 @@ final class AgentNodeProfileController extends Controller
$service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin); $service->assertChildCapabilityGrantsWithinParent($parent, $payload, $admin);
} }
$profile = $service->upsertForNode($agent_node, $payload, $parent); $beforeProfile = AgentProfile::query()->where('agent_node_id', $agent_node->id)->first();
$agentNodeService->syncPrimaryOwnerRoleFromProfile($agent_node, $profile); $beforeJson = $beforeProfile !== null ? $service->present($beforeProfile) : [];
return ApiResponse::success($service->present($profile)); if ($request->has('risk_tags')) {
$agent_node->risk_tags = array_values(array_unique(array_filter(
array_map('strval', $request->input('risk_tags', [])),
)));
$agent_node->save();
}
$profile = $service->upsertForNode($agent_node, $payload, $parent);
$afterJson = array_merge($service->present($profile), [
'risk_tags' => $agent_node->risk_tags ?? [],
]);
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'agent',
actionCode: 'agent_profile.update',
targetType: 'agent_node',
targetId: (string) $agent_node->id,
beforeJson: $beforeJson,
afterJson: $afterJson,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success($afterJson);
} }
} }

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\SettlementCenterLedgerService;
use App\Services\AgentSettlement\SettlementLedgerListFilters;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* 结算中心:信用盘玩家实时流水({@see credit_ledger}),与关账后 {@see settlement_bills} 不同。
*/
final class AdminCreditLedgerIndexController extends Controller
{
use PaginationTrait;
public function __construct(
private readonly SettlementCenterLedgerService $ledgerService,
) {}
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$adminSiteId = (int) $request->query('admin_site_id', 0);
abort_if($adminSiteId <= 0, 422, 'admin_site_id required');
abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $adminSiteId), 403);
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
abort_if($siteCode === '', 422, 'admin_site not found');
$periodId = (int) $request->query('settlement_period_id', 0);
if ($periodId > 0) {
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
}
$filters = SettlementLedgerListFilters::fromQuery(array_merge(
$request->query(),
$periodId > 0 ? ['settlement_period_id' => $periodId] : [],
));
$perPage = $this->perPage($request, 'per_page', 20, 100);
$page = $this->page($request);
$result = $this->ledgerService->listUnified(
$admin,
$siteCode,
$page,
$perPage,
$filters,
);
return ApiResponse::success($result);
}
}

View File

@@ -0,0 +1,62 @@
<?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 AgentSettlementAdjustmentIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$adminSiteId = (int) $request->query('admin_site_id', 0);
$adjustmentType = trim((string) $request->query('adjustment_type', ''));
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->select([
'sa.*',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
'sb.bill_type as original_bill_type',
'sb.owner_type as original_owner_type',
'sb.owner_id as original_owner_id',
])
->orderByDesc('sa.id');
if ($periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
if ($adjustmentType !== '') {
$query->where('sa.adjustment_type', $adjustmentType);
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
$query->whereRaw('0 = 1');
} else {
$query->whereIn('sp.admin_site_id', $siteIds);
}
}
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillAdjustmentRequest;
use App\Services\AgentSettlement\AgentSettlementBillAdjustmentService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillAdjustmentController extends Controller
{
public function __invoke(
AdminSettlementBillAdjustmentRequest $request,
int $settlement_bill,
AgentSettlementBillAdjustmentService $adjustments,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$newBillId = $adjustments->createAdjustment(
$settlement_bill,
(int) $request->validated('amount'),
(string) ($request->validated('adjustment_type') ?? 'adjustment'),
$request->validated('reason'),
(int) $admin->id,
);
$after = DB::table('settlement_bills')->where('id', $newBillId)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.adjustment',
targetType: 'settlement_bill',
targetId: (string) $newBillId,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'original_bill_id' => $settlement_bill,
'adjustment_bill_id' => $newBillId,
'bill' => $after,
]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillBadDebtWriteOffController extends Controller
{
public function __invoke(
AdminSettlementBillBadDebtRequest $request,
int $settlement_bill,
AgentSettlementBadDebtService $badDebt,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$archiveBillId = $badDebt->writeOff(
$settlement_bill,
$request->validated('reason'),
(int) $admin->id,
);
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.bad_debt',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'original_bill_id' => $settlement_bill,
'bad_debt_bill_id' => $archiveBillId,
'bill' => $after,
]);
}
}

View File

@@ -4,9 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Middleware\RecordAdminApiAudit;
use App\Models\Player; use App\Services\AgentSettlement\SettlementPaymentService;
use App\Services\AuditLogger; use App\Services\AuditLogger;
use App\Services\Player\PlayerCreditService;
use App\Support\AdminAgentSettlementScope; use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -18,7 +17,7 @@ final class AgentSettlementBillConfirmController extends Controller
public function __invoke( public function __invoke(
Request $request, Request $request,
int $settlement_bill, int $settlement_bill,
PlayerCreditService $creditService, SettlementPaymentService $payments,
): JsonResponse { ): JsonResponse {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
@@ -28,21 +27,7 @@ final class AgentSettlementBillConfirmController extends Controller
$bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); $bill = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($bill === null, 404); abort_if($bill === null, 404);
$unpaid = (int) $bill->unpaid_amount; $payments->confirmBill($settlement_bill);
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( AuditLogger::recordForAdmin(
$admin, $admin,
@@ -51,8 +36,8 @@ final class AgentSettlementBillConfirmController extends Controller
actionCode: 'settlement_bill.confirm', actionCode: 'settlement_bill.confirm',
targetType: 'settlement_bill', targetType: 'settlement_bill',
targetId: (string) $settlement_bill, targetId: (string) $settlement_bill,
beforeJson: ['status' => (string) $bill->status, 'unpaid_amount' => $unpaid], beforeJson: ['status' => (string) $bill->status],
afterJson: ['status' => 'confirmed', 'paid_amount' => (int) $bill->paid_amount + $unpaid], afterJson: ['status' => 'confirmed'],
); );
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true); $request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);

View File

@@ -7,6 +7,7 @@ use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class AgentSettlementBillIndexController extends Controller final class AgentSettlementBillIndexController extends Controller
@@ -17,15 +18,142 @@ final class AgentSettlementBillIndexController extends Controller
abort_if($admin === null, 401); abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0); $periodId = (int) $request->query('settlement_period_id', 0);
$query = DB::table('settlement_bills')->orderByDesc('id'); $adminSiteId = (int) $request->query('admin_site_id', 0);
$query = DB::table('settlement_bills as sb')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->select([
'sb.*',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
])
->orderByDesc('sb.id');
if ($periodId > 0) { if ($periodId > 0) {
$query->where('settlement_period_id', $periodId); $query->where('sb.settlement_period_id', $periodId);
} }
AdminAgentSettlementScope::applyToBillsQuery($query, $admin); if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
$billType = (string) $request->query('bill_type', '');
if ($billType !== '') {
$query->where('sb.bill_type', $billType);
}
$scope = (string) $request->query('scope', '');
match ($scope) {
'pending_confirm' => $query->where('sb.status', 'pending_confirm'),
'awaiting_payment' => $query
->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue'])
->where('sb.unpaid_amount', '>', 0),
'settled' => $query->where('sb.status', 'settled'),
'adjustment' => $query->whereIn('sb.bill_type', ['adjustment', 'reversal']),
default => null,
};
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
/** @var Collection<int, object> $items */
$items = $query->limit(200)->get();
return ApiResponse::success([ return ApiResponse::success([
'items' => $query->limit(100)->get(), 'items' => $this->enrichBillRows($items),
]); ]);
} }
/**
* @param Collection<int, object> $items
* @return list<array<string, mixed>>
*/
private function enrichBillRows(Collection $items): array
{
if ($items->isEmpty()) {
return [];
}
$playerIds = [];
$agentIds = [];
foreach ($items as $row) {
if ((string) $row->owner_type === 'player') {
$playerIds[] = (int) $row->owner_id;
} elseif ((string) $row->owner_type === 'agent') {
$agentIds[] = (int) $row->owner_id;
}
if ((string) $row->counterparty_type === 'agent' && (int) $row->counterparty_id > 0) {
$agentIds[] = (int) $row->counterparty_id;
}
}
$players = $playerIds !== []
? DB::table('players')
->whereIn('id', array_unique($playerIds))
->select(['id', 'username', 'site_player_id', 'funding_mode', 'auth_source'])
->get()
->keyBy('id')
: collect();
$agents = $agentIds !== []
? DB::table('agent_nodes')->whereIn('id', array_unique($agentIds))->get()->keyBy('id')
: collect();
$out = [];
foreach ($items as $row) {
$item = (array) $row;
$item['owner_label'] = $this->resolvePartyLabel(
(string) $row->owner_type,
(int) $row->owner_id,
$players,
$agents,
);
$item['counterparty_label'] = $this->resolvePartyLabel(
(string) $row->counterparty_type,
(int) $row->counterparty_id,
$players,
$agents,
);
if ((string) $row->owner_type === 'player') {
$player = $players->get((int) $row->owner_id);
$item['owner_funding_mode'] = $player !== null ? (string) ($player->funding_mode ?? '') : null;
$item['owner_auth_source'] = $player !== null ? $player->auth_source : null;
}
$out[] = $item;
}
return $out;
}
/**
* @param Collection<int, object> $players
* @param Collection<int, object> $agents
*/
private function resolvePartyLabel(
string $type,
int $id,
Collection $players,
Collection $agents,
): string {
if ($type === 'platform' || $id <= 0) {
return 'platform';
}
if ($type === 'player') {
$player = $players->get($id);
return $player !== null
? (string) ($player->username ?: $player->site_player_id)
: "player#{$id}";
}
if ($type === 'agent') {
$agent = $agents->get($id);
return $agent !== null
? (string) ($agent->name ?: $agent->code)
: "agent#{$id}";
}
return "{$type}#{$id}";
}
} }

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillPaymentRequest;
use App\Services\AgentSettlement\SettlementPaymentService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillPaymentController extends Controller
{
public function __invoke(
AdminSettlementBillPaymentRequest $request,
int $settlement_bill,
SettlementPaymentService $payments,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
abort_if(! AdminAgentSettlementScope::billAccessible($admin, $settlement_bill), 404);
$before = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
abort_if($before === null, 404);
$validated = $request->validated();
$payments->recordPayment(
$settlement_bill,
(int) $validated['amount'],
(int) $admin->id,
[
'method' => $validated['method'] ?? null,
'proof' => $validated['proof'] ?? null,
'remark' => $validated['remark'] ?? null,
],
);
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'settlement',
actionCode: 'settlement_bill.payment',
targetType: 'settlement_bill',
targetId: (string) $settlement_bill,
beforeJson: (array) $before,
afterJson: (array) $after,
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success(['bill' => $after]);
}
}

View File

@@ -0,0 +1,53 @@
<?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 AgentSettlementBillShowController extends Controller
{
public function __invoke(Request $request, int $settlement_bill): 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);
$payments = DB::table('payment_records')
->where('settlement_bill_id', $settlement_bill)
->orderBy('id')
->get();
$rebateAllocations = DB::table('rebate_allocations')
->where('settlement_bill_id', $settlement_bill)
->orderBy('id')
->get();
$adjustments = DB::table('settlement_adjustments')
->where('original_bill_id', $settlement_bill)
->orderByDesc('id')
->get();
$meta = $bill->meta_json ?? null;
$tierSettlements = null;
if (is_string($meta) && $meta !== '') {
$decoded = json_decode($meta, true);
$tierSettlements = is_array($decoded) ? ($decoded['edge'] ?? $decoded['tier_settlements'] ?? null) : null;
}
return ApiResponse::success([
'bill' => $bill,
'payments' => $payments,
'rebate_allocations' => $rebateAllocations,
'adjustments' => $adjustments,
'tier_edge' => $tierSettlements,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPaymentIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
$adminSiteId = (int) $request->query('admin_site_id', 0);
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->select([
'pr.*',
'sb.bill_type',
'sb.owner_type',
'sb.owner_id',
'sb.counterparty_type',
'sb.counterparty_id',
'sp.period_start',
'sp.period_end',
'sp.admin_site_id',
])
->orderByDesc('pr.id');
if ($periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
if ($adminSiteId > 0) {
$query->where('sp.admin_site_id', $adminSiteId);
}
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
$query->whereRaw('0 = 1');
} else {
$query->whereIn('sp.admin_site_id', $siteIds);
}
}
return ApiResponse::success([
'items' => $query->limit(200)->get(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodIndexController extends Controller
{
public function __invoke(
Request $request,
AgentSettlementPeriodSummaryService $summaryService,
): JsonResponse {
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$query = DB::table('settlement_periods')->orderByDesc('id');
AdminAgentSettlementScope::applyToPeriodsQuery($query, $admin);
$siteId = (int) $request->query('admin_site_id', 0);
if ($siteId > 0) {
$query->where('admin_site_id', $siteId);
}
$periods = $query->limit(100)->get();
return ApiResponse::success([
'items' => $summaryService->attachToPeriodRows($periods),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** @deprecated 请用 AgentSettlementReportShowController?type=summary */
final class AgentSettlementReportIndexController extends Controller
{
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$periodId = (int) $request->query('settlement_period_id', 0);
return ApiResponse::success($reports->summary($admin, $periodId));
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Services\AgentSettlement\AgentSettlementReportQueryService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementReportShowController extends Controller
{
private const TYPES = [
'summary',
'player_win_loss',
'agent_share',
'rebate',
'credit',
'unpaid_bills',
'overdue',
'platform_pnl',
'draw_period',
];
public function __invoke(Request $request, AgentSettlementReportQueryService $reports): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$type = (string) $request->query('type', 'summary');
abort_unless(in_array($type, self::TYPES, true), 404);
$periodId = (int) $request->query('settlement_period_id', 0);
$period = $this->resolvePeriod($periodId, $request);
$data = match ($type) {
'summary' => $reports->summary($admin, $periodId),
'player_win_loss' => [
'items' => $reports->playerWinLoss($admin, $periodId, $period['start'], $period['end']),
],
'agent_share' => [
'items' => $reports->agentShare($admin, $period['start'], $period['end']),
],
'rebate' => $reports->rebate($admin, $periodId, $period['start'], $period['end']),
'credit' => $reports->credit($admin),
'unpaid_bills' => [
'items' => $reports->unpaidBills($admin, $periodId),
],
'overdue' => [
'items' => $reports->overdue($admin),
],
'platform_pnl' => $periodId > 0
? $reports->platformPnl($admin, $periodId)
: ['error' => 'settlement_period_id_required'],
'draw_period' => [
'items' => $reports->drawPeriod($admin, $period['start'], $period['end']),
],
default => [],
};
return ApiResponse::success([
'type' => $type,
'settlement_period_id' => $periodId > 0 ? $periodId : null,
'period_start' => $period['start'],
'period_end' => $period['end'],
'data' => $data,
'footnote' => $type === 'summary'
? null
: 'agent_credit_line_settlement',
]);
}
/**
* @return array{start: string, end: string}
*/
private function resolvePeriod(int $periodId, Request $request): array
{
if ($periodId > 0) {
$row = DB::table('settlement_periods')->where('id', $periodId)->first();
abort_if($row === null, 404);
return [
'start' => (string) $row->period_start,
'end' => (string) $row->period_end,
];
}
$request->validate([
'period_start' => ['required_with:period_end', 'date'],
'period_end' => ['required_with:period_start', 'date', 'after_or_equal:period_start'],
]);
$start = $request->query('period_start');
$end = $request->query('period_end');
if ($start && $end) {
return ['start' => (string) $start, 'end' => (string) $end];
}
$now = now();
return [
'start' => $now->copy()->subDays(7)->toDateTimeString(),
'end' => $now->toDateTimeString(),
];
}
}

View File

@@ -10,7 +10,10 @@ use App\Support\ApiResponse;
use App\Models\SettlementBatch; use App\Models\SettlementBatch;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Lottery\ErrorCode;
use App\Support\AdminDrawResponsePolicy;
use App\Support\AdminScopePolicy; use App\Support\AdminScopePolicy;
use App\Support\ApiMessage;
/** /**
* GET /api/v1/admin/draws/{draw}/finance-summary 单期投注/派彩汇总(客服/财务视角PRD §15.4)。 * GET /api/v1/admin/draws/{draw}/finance-summary 单期投注/派彩汇总(客服/财务视角PRD §15.4)。
@@ -23,6 +26,17 @@ final class AdminDrawFinanceSummaryController extends Controller
{ {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if(! $admin instanceof AdminUser, 401); abort_if(! $admin instanceof AdminUser, 401);
if (! AdminDrawResponsePolicy::canViewDrawFinance($admin)) {
return ApiMessage::errorResponse(
$request,
'admin.permission_denied',
ErrorCode::AdminForbidden->value,
null,
403,
);
}
$scope = AdminScopePolicy::resolveContext($request, $admin); $scope = AdminScopePolicy::resolveContext($request, $admin);
$drawId = (int) $draw->id; $drawId = (int) $draw->id;

View File

@@ -2,7 +2,6 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw; namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\Draw; use App\Models\Draw;
use App\Models\TicketItem; use App\Models\TicketItem;
@@ -10,6 +9,8 @@ use App\Models\TicketOrder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use App\Support\AdminScopeContext; use App\Support\AdminScopeContext;
use App\Support\AdminDrawApiPresenter;
use App\Support\AdminDrawResponsePolicy;
use App\Support\AdminScopePolicy; use App\Support\AdminScopePolicy;
use App\Services\LotterySettings; use App\Services\LotterySettings;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -44,19 +45,30 @@ final class AdminDrawIndexController extends Controller
/** @var LengthAwarePaginator $paginator */ /** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
$statsByDrawId = $this->aggregateListStats( $statsByDrawId = AdminDrawResponsePolicy::canViewDrawFinance($admin)
$paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(), ? $this->aggregateListStats(
$scope, $paginator->getCollection()->pluck('id')->map(fn ($id) => (int) $id)->all(),
); $scope,
)
: [];
return AdminApiList::jsonWith($paginator, fn (Draw $row) => $this->row($row, $statsByDrawId), [ return AdminApiList::jsonWith(
'schedule' => [ $paginator,
'timezone' => LotterySettings::drawTimezone(), fn (Draw $row): array => AdminDrawApiPresenter::listRow(
'interval_minutes' => LotterySettings::drawIntervalMinutes(), $row,
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(), $statsByDrawId[(int) $row->id] ?? null,
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(), $admin,
),
[
'schedule' => [
'timezone' => LotterySettings::drawTimezone(),
'interval_minutes' => LotterySettings::drawIntervalMinutes(),
'betting_window_seconds' => LotterySettings::drawBettingWindowSeconds(),
'close_before_draw_seconds' => LotterySettings::drawCloseBeforeDrawSeconds(),
],
'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
], ],
]); );
} }
/** /**
@@ -129,38 +141,4 @@ final class AdminDrawIndexController extends Controller
}); });
} }
/**
* @param array<int, array{total_bet_minor: int, total_payout_minor: int, profit_loss_minor: int}> $statsByDrawId
* @return array<string, mixed>
*/
private function row(Draw $draw, array $statsByDrawId): array
{
$stats = $statsByDrawId[(int) $draw->id] ?? [
'total_bet_minor' => 0,
'total_payout_minor' => 0,
'profit_loss_minor' => 0,
];
return [
'id' => (int) $draw->id,
'draw_no' => $draw->draw_no,
'business_date' => $draw->business_date instanceof Carbon
? $draw->business_date->format('Y-m-d')
: (string) $draw->business_date,
'sequence_no' => (int) $draw->sequence_no,
'status' => $draw->status,
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'result_source' => $draw->result_source,
'current_result_version' => (int) $draw->current_result_version,
'settle_version' => (int) $draw->settle_version,
'is_reopened' => (bool) $draw->is_reopened,
'total_bet_minor' => $stats['total_bet_minor'],
'total_payout_minor' => $stats['total_payout_minor'],
'profit_loss_minor' => $stats['profit_loss_minor'],
'updated_at' => $draw->updated_at?->toIso8601String(),
];
}
} }

View File

@@ -4,56 +4,46 @@ namespace App\Http\Controllers\Api\V1\Admin\Draw;
use App\Models\Draw; use App\Models\Draw;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch; use App\Models\DrawResultBatch;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminDrawApiPresenter;
use App\Support\AdminDrawResponsePolicy;
use App\Lottery\DrawResultBatchStatus;
/** /**
* GET /api/v1/admin/draws/{draw}/result-batches 开奖批次与号码(审核/结果核对)。 * GET /api/v1/admin/draws/{draw}/result-batches 开奖批次与号码(审核/结果核对)。
*/ */
final class AdminDrawResultBatchesIndexController extends Controller final class AdminDrawResultBatchesIndexController extends Controller
{ {
public function __invoke(Draw $draw): JsonResponse public function __invoke(Request $request, Draw $draw): JsonResponse
{ {
$batches = $draw->resultBatches() $admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
$manage = AdminDrawResponsePolicy::canManageDrawResults($admin);
$query = $draw->resultBatches()
->with(['items' => function ($q): void { ->with(['items' => function ($q): void {
$q->orderBy('prize_type')->orderBy('prize_index'); $q->orderBy('prize_type')->orderBy('prize_index');
}]) }])
->orderByDesc('result_version') ->orderByDesc('result_version');
->get();
if (! $manage) {
$query->where('status', DrawResultBatchStatus::Published->value);
}
$batches = $query->get();
return ApiResponse::success([ return ApiResponse::success([
'draw_id' => (int) $draw->id, 'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'draw_status' => $draw->status, 'draw_status' => $draw->status,
'batches' => $batches->map(fn (DrawResultBatch $b) => $this->serializeBatch($b))->all(), 'capabilities' => AdminDrawResponsePolicy::capabilities($admin),
'batches' => $batches
->map(fn (DrawResultBatch $b): array => AdminDrawApiPresenter::resultBatch($b, $admin))
->all(),
]); ]);
} }
/** @return array<string, mixed> */
private function serializeBatch(DrawResultBatch $batch): array
{
return [
'id' => (int) $batch->id,
'result_version' => (int) $batch->result_version,
'source_type' => $batch->source_type,
'rng_seed_hash' => $batch->rng_seed_hash,
'status' => $batch->status,
'created_by' => $batch->created_by,
'confirmed_by' => $batch->confirmed_by,
'confirmed_at' => $batch->confirmed_at?->toIso8601String(),
'created_at' => $batch->created_at?->toIso8601String(),
'updated_at' => $batch->updated_at?->toIso8601String(),
'items' => $batch->items->map(fn (DrawResultItem $item) => [
'prize_type' => $item->prize_type,
'prize_index' => (int) $item->prize_index,
'number_4d' => $item->number_4d,
'suffix_3d' => $item->suffix_3d,
'suffix_2d' => $item->suffix_2d,
'head_digit' => $item->head_digit,
'tail_digit' => $item->tail_digit,
])->values()->all(),
];
}
} }

View File

@@ -2,12 +2,12 @@
namespace App\Http\Controllers\Api\V1\Admin\Draw; namespace App\Http\Controllers\Api\V1\Admin\Draw;
use Carbon\Carbon;
use App\Models\Draw; use App\Models\Draw;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Lottery\DrawResultBatchStatus; use App\Support\AdminDrawApiPresenter;
use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Draw\DrawHallSnapshotBuilder;
/** /**
@@ -19,41 +19,13 @@ final class AdminDrawShowController extends Controller
private readonly DrawHallSnapshotBuilder $hallPreview, private readonly DrawHallSnapshotBuilder $hallPreview,
) {} ) {}
public function __invoke(Draw $draw): JsonResponse public function __invoke(Request $request, Draw $draw): JsonResponse
{ {
$nowUtc = now()->utc(); $admin = $request->lotteryAdmin();
$batchCounts = [ abort_if($admin === null, 401);
'total' => $draw->resultBatches()->count(),
'pending_review' => $draw->resultBatches()
->where('status', DrawResultBatchStatus::PendingReview->value)
->count(),
'published' => $draw->resultBatches()
->where('status', DrawResultBatchStatus::Published->value)
->count(),
];
return ApiResponse::success([ return ApiResponse::success(
'id' => (int) $draw->id, AdminDrawApiPresenter::show($draw, $admin, $this->hallPreview),
'draw_no' => $draw->draw_no, );
'business_date' => $draw->business_date instanceof Carbon
? $draw->business_date->format('Y-m-d')
: (string) $draw->business_date,
'sequence_no' => (int) $draw->sequence_no,
/** 数据库当期状态(权威) */
'status' => $draw->status,
/** 与玩家大厅 snapshot 对齐的展示态(未跑 tick 时可能与 status 不一致) */
'hall_preview_status' => $this->hallPreview->effectiveHallDisplayStatus($draw, $nowUtc),
'start_time' => $draw->start_time?->toIso8601String(),
'close_time' => $draw->close_time?->toIso8601String(),
'draw_time' => $draw->draw_time?->toIso8601String(),
'cooling_end_time' => $draw->cooling_end_time?->toIso8601String(),
'result_source' => $draw->result_source,
'current_result_version' => (int) $draw->current_result_version,
'settle_version' => (int) $draw->settle_version,
'is_reopened' => (bool) $draw->is_reopened,
'created_at' => $draw->created_at?->toIso8601String(),
'updated_at' => $draw->updated_at?->toIso8601String(),
'result_batch_counts' => $batchCounts,
]);
} }
} }

View File

@@ -6,6 +6,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AgentNode;
use App\Support\AdminIntegrationSiteAccess; use App\Support\AdminIntegrationSiteAccess;
use App\Support\AdminIntegrationSitePresenter; use App\Support\AdminIntegrationSitePresenter;
@@ -16,9 +17,18 @@ final class AdminIntegrationSiteIndexController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
$items = AdminIntegrationSiteAccess::queryFor($admin) $sites = AdminIntegrationSiteAccess::queryFor($admin)->get();
->get() $rootSiteIds = AgentNode::query()
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem($site)) ->where('depth', 0)
->whereIn('admin_site_id', $sites->pluck('id'))
->pluck('admin_site_id')
->flip();
$items = $sites
->map(static fn ($site): array => AdminIntegrationSitePresenter::listItem(
$site,
isset($rootSiteIds[(int) $site->id]),
))
->all(); ->all();
return ApiResponse::success(['items' => $items]); return ApiResponse::success(['items' => $items]);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Http\Controllers\Controller;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Models\AdminSite;
use App\Lottery\ErrorCode;
use App\Services\AuditLogger;
use App\Support\AdminIntegrationSiteAccess;
use App\Support\ApiMessage;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/** GET /api/v1/admin/integration-sites/{admin_site}/secrets */
final class AdminIntegrationSiteSecretsController extends Controller
{
public function __invoke(Request $request, AdminSite $admin_site): JsonResponse
{
$admin = $request->lotteryAdmin();
abort_if($admin === null, 401);
if (! AdminIntegrationSiteAccess::canAccess($admin, $admin_site)) {
return ApiMessage::errorResponse($request, 'admin.site_access_denied', ErrorCode::AdminForbidden->value, null, 403);
}
if (! $admin->isSuperAdmin() && ! $admin->hasPermissionCode('integration.site.manage')) {
return ApiMessage::errorResponse($request, 'admin.permission_denied', ErrorCode::AdminForbidden->value, null, 403);
}
$sso = $admin_site->decryptedSsoJwtSecret();
$wallet = $admin_site->decryptedWalletApiKey();
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'integration',
actionCode: 'reveal_secrets',
targetType: 'admin_site',
targetId: (string) $admin_site->id,
afterJson: ['code' => $admin_site->code],
);
$request->attributes->set(RecordAdminApiAudit::ATTRIBUTE_AUDIT_RECORDED, true);
return ApiResponse::success([
'sso_jwt_secret' => is_string($sso) ? $sso : '',
'wallet_api_key' => is_string($wallet) ? $wallet : '',
]);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\V1\Admin\Integration; namespace App\Http\Controllers\Api\V1\Admin\Integration;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\AuditLogger; use App\Services\AuditLogger;
@@ -11,8 +10,6 @@ use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminIntegrationSitePresenter; use App\Support\AdminIntegrationSitePresenter;
use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest; use App\Http\Requests\Admin\AdminIntegrationSiteStoreRequest;
use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Middleware\RecordAdminApiAudit;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
final class AdminIntegrationSiteStoreController extends Controller final class AdminIntegrationSiteStoreController extends Controller
{ {
@@ -23,19 +20,6 @@ final class AdminIntegrationSiteStoreController extends Controller
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); 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()); $result = $service->create($request->validated());
$site = $result['site']; $site = $result['site'];

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Player; namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player; use App\Models\Player;
use App\Models\TicketOrder;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Support\ApiResponse; use App\Support\ApiResponse;
@@ -10,7 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope; use App\Support\AdminSiteScope;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Builder;
/** DELETE /api/v1/admin/players/{player} */ /** DELETE /api/v1/admin/players/{player} */
final class AdminPlayerDestroyController extends Controller final class AdminPlayerDestroyController extends Controller
@@ -26,16 +27,15 @@ final class AdminPlayerDestroyController extends Controller
$hasWallets = Player::query() $hasWallets = Player::query()
->whereKey($player->getKey()) ->whereKey($player->getKey())
->whereHas('wallets', static fn (HasMany $q) => $q->whereRaw('balance != 0')) ->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
->exists(); ->exists();
if ($hasWallets) { if ($hasWallets) {
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
} }
$hasTickets = Player::query() $hasTickets = TicketOrder::query()
->whereKey($player->getKey()) ->where('player_id', $player->getKey())
->whereHas('ticketOrders')
->exists(); ->exists();
if ($hasTickets) { if ($hasTickets) {

View File

@@ -37,7 +37,11 @@ final class AdminPlayerIndexController extends Controller
$term = '%'.addcslashes($keyword, '%_\\').'%'; $term = '%'.addcslashes($keyword, '%_\\').'%';
$q->where(static function ($sub) use ($term): void { $q->where(static function ($sub) use ($term): void {
$sub->where('site_player_id', 'like', $term) $sub->where('site_player_id', 'like', $term)
->orWhere('username', 'like', $term); ->orWhere('username', 'like', $term)
->orWhere('nickname', 'like', $term);
if (ctype_digit($keyword)) {
$sub->orWhere('id', (int) $keyword);
}
}); });
} }

View File

@@ -16,6 +16,10 @@ use App\Models\AgentNode;
use App\Services\Agent\AgentProfileService; use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator; use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService; use App\Services\Player\PlayerCreditService;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/** POST /api/v1/admin/players */ /** POST /api/v1/admin/players */
final class AdminPlayerStoreController extends Controller final class AdminPlayerStoreController extends Controller
@@ -46,15 +50,44 @@ final class AdminPlayerStoreController extends Controller
return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403); return ApiMessage::errorResponse($request, 'admin.player_create_site_forbidden', ErrorCode::AdminForbidden->value, null, 403);
} }
$isNative = $request->filled('password');
$sitePlayerId = (string) ($request->validated('site_player_id') ?? '');
if ($isNative) {
$sitePlayerId = $sitePlayerId !== ''
? $sitePlayerId
: 'native:'.Str::lower(Str::ulid());
}
if ($sitePlayerId === '') {
return ApiMessage::errorResponse($request, 'admin.player_site_player_id_required', ErrorCode::ValidationFailed->value, null, 422);
}
$exists = Player::query() $exists = Player::query()
->where('site_code', $request->validated('site_code')) ->where('site_code', $siteCode)
->where('site_player_id', $request->validated('site_player_id')) ->where('site_player_id', $sitePlayerId)
->exists(); ->exists();
if ($exists) { if ($exists) {
return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.player_already_registered', ErrorCode::ValidationFailed->value, null, 422);
} }
if ($isNative) {
$username = trim((string) $request->validated('username', ''));
if ($username === '') {
return ApiMessage::errorResponse($request, 'admin.player_native_username_required', ErrorCode::ValidationFailed->value, null, 422);
}
$usernameTaken = Player::query()
->where('site_code', $siteCode)
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->exists();
if ($usernameTaken) {
return ApiMessage::errorResponse($request, 'admin.player_username_taken', ErrorCode::ValidationFailed->value, null, 422);
}
}
$agentNodeId = $admin->isSuperAdmin() $agentNodeId = $admin->isSuperAdmin()
? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode) ? $this->resolveAgentNodeIdForSuperAdmin($request->validated('agent_node_id'), $siteCode)
: $admin->primaryAgentNodeId(); : $admin->primaryAgentNodeId();
@@ -85,33 +118,58 @@ final class AdminPlayerStoreController extends Controller
); );
} }
$player = Player::query()->create([ $creditLimit = $request->has('credit_limit')
'site_code' => $request->validated('site_code'), ? (int) $request->input('credit_limit', 0)
'agent_node_id' => $agentNodeId, : ($isNative ? 0 : 0);
'site_player_id' => $request->validated('site_player_id'),
'username' => $request->validated('username'),
'nickname' => $request->validated('nickname'),
'default_currency' => $request->validated('default_currency', 'NPR'),
'status' => $request->validated('status', 0),
]);
if ($request->has('credit_limit')) { $player = \Illuminate\Support\Facades\DB::transaction(function () use (
$playerCreditService->upsertAccount($player, [ $agent,
'credit_limit' => (int) $request->input('credit_limit', 0), $agentProfileService,
]); $playerCreditService,
} $request,
$isNative,
$siteCode,
$agentNodeId,
$sitePlayerId,
$creditLimit,
): Player {
$agentProfileService->assertMayIncreasePlayerCredit($agent, $creditLimit);
if ($request->has('rebate_rate')) { $player = Player::query()->create([
\Illuminate\Support\Facades\DB::table('player_rebate_profiles')->insert([ 'site_code' => $siteCode,
'player_id' => $player->id, 'agent_node_id' => $agentNodeId,
'game_type' => '*', 'site_player_id' => $sitePlayerId,
'inherit_from_agent' => false, 'auth_source' => $isNative ? PlayerAuthSource::LOTTERY_NATIVE : PlayerAuthSource::MAIN_SITE_SSO,
'rebate_rate' => (float) $request->input('rebate_rate', 0), 'funding_mode' => $isNative ? PlayerFundingMode::CREDIT : PlayerFundingMode::WALLET,
'extra_rebate_rate' => (float) $request->input('extra_rebate_rate', 0), 'username' => $isNative ? trim((string) $request->validated('username')) : $request->validated('username'),
'created_at' => now(), 'password_hash' => $isNative ? Hash::make((string) $request->validated('password')) : null,
'updated_at' => now(), 'nickname' => $request->validated('nickname'),
'default_currency' => $request->validated('default_currency', 'NPR'),
'status' => $request->validated('status', 0),
]); ]);
}
if ($request->has('credit_limit') || $isNative) {
$playerCreditService->upsertAccount($player, [
'credit_limit' => $creditLimit,
]);
}
$agentProfileService->refreshAllocatedCredit($agent);
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 $player;
});
return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201); return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
} }

View File

@@ -2,20 +2,31 @@
namespace App\Http\Controllers\Api\V1\Admin\Player; namespace App\Http\Controllers\Api\V1\Admin\Player;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use App\Support\PlayerApiPresenter;
use App\Http\Requests\Admin\AdminPlayerUpdateRequest; use App\Http\Requests\Admin\AdminPlayerUpdateRequest;
use App\Models\AgentNode;
use App\Models\Player;
use App\Services\Agent\AgentProfileService;
use App\Services\Agent\RebateLimitValidator;
use App\Services\Player\PlayerCreditService;
use App\Services\Player\PlayerRebateProfileService;
use App\Support\AdminSiteScope;
use App\Support\ApiResponse;
use App\Support\PlayerApiPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/** PUT /api/v1/admin/players/{player} */ /** PUT /api/v1/admin/players/{player} */
final class AdminPlayerUpdateController extends Controller final class AdminPlayerUpdateController extends Controller
{ {
public function __invoke(AdminPlayerUpdateRequest $request, Player $player): JsonResponse public function __invoke(
{ AdminPlayerUpdateRequest $request,
Player $player,
AgentProfileService $agentProfileService,
PlayerCreditService $playerCreditService,
RebateLimitValidator $rebateLimitValidator,
PlayerRebateProfileService $rebateProfileService,
): JsonResponse {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
abort_if($admin === null, 401); abort_if($admin === null, 401);
@@ -29,7 +40,57 @@ final class AdminPlayerUpdateController extends Controller
$data['status'] = (int) $data['status']; $data['status'] = (int) $data['status'];
} }
$player->fill(array_filter($data, static fn ($v) => $v !== '')); $agent = $player->agent_node_id !== null
? AgentNode::query()->find((int) $player->agent_node_id)
: null;
if ($agent !== null && $request->has('rebate_rate')) {
$rebateLimitValidator->assertPlayerRebateWithinAgent(
$agent,
(float) $request->input('rebate_rate', 0),
(float) $request->input('extra_rebate_rate', 0),
);
}
if ($request->has('credit_limit') && $agent !== null) {
$newLimit = (int) $request->input('credit_limit', 0);
$creditRow = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
$previous = (int) ($creditRow->credit_limit ?? 0);
$usedCredit = (int) ($creditRow->used_credit ?? 0) + (int) ($creditRow->frozen_credit ?? 0);
$agentProfileService->adjustPlayerCreditAllocation($agent, $previous, $newLimit, $usedCredit);
$playerCreditService->upsertAccount($player, ['credit_limit' => $newLimit]);
$agentProfileService->refreshAllocatedCredit($agent);
unset($data['credit_limit']);
}
if ($request->has('rebate_rate')) {
DB::table('player_rebate_profiles')->updateOrInsert(
['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),
'updated_at' => now(),
'created_at' => now(),
],
);
unset($data['rebate_rate'], $data['extra_rebate_rate']);
}
if ($request->has('rebate_profiles') && $agent !== null) {
$rebateProfileService->syncProfiles($player->id, $agent, $request->input('rebate_profiles', []));
unset($data['rebate_profiles']);
}
if ($request->has('risk_tags')) {
$player->risk_tags = array_values(array_unique(array_filter(
array_map('strval', $request->input('risk_tags', [])),
)));
unset($data['risk_tags']);
}
unset($data['rebate_profiles']);
$player->fill(array_filter($data, static fn ($v) => $v !== '' && ! is_array($v)));
$player->save(); $player->save();
return ApiResponse::success(PlayerApiPresenter::listItem($player)); return ApiResponse::success(PlayerApiPresenter::listItem($player));

View File

@@ -32,7 +32,7 @@ final class AdminReportRebateCommissionController extends Controller
$scope, $scope,
); );
return AdminApiList::json($paginator, static function (object $row): array { $response = AdminApiList::json($paginator, static function (object $row): array {
return [ return [
'play_code' => (string) $row->play_code, 'play_code' => (string) $row->play_code,
'total_rebate_minor' => (int) $row->total_rebate_minor, 'total_rebate_minor' => (int) $row->total_rebate_minor,
@@ -40,5 +40,13 @@ final class AdminReportRebateCommissionController extends Controller
'ticket_item_count' => (int) $row->ticket_item_count, 'ticket_item_count' => (int) $row->ticket_item_count,
]; ];
}); });
$payload = $response->getData(true);
if (is_array($payload)) {
$payload['disclaimer'] = 'wallet_instant_rebate_not_agent_period_settlement';
$response->setData($payload);
}
return $response;
} }
} }

View File

@@ -12,6 +12,7 @@ use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard; use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
final class AdminRoleDestroyController extends Controller final class AdminRoleDestroyController extends Controller
{ {
@@ -19,8 +20,8 @@ final class AdminRoleDestroyController extends Controller
{ {
AdminAccountScopeGuard::assertSystemRole($admin_role); AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === AdminRole::ROLE_SUPER_ADMIN) { if (PlatformSystemRoles::isFixedSlug((string) $admin_role->slug)) {
return ApiMessage::errorResponse($request, 'admin.role_cannot_delete_super_admin', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);
} }
if ((bool) $admin_role->is_system) { if ((bool) $admin_role->is_system) {
return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422); return ApiMessage::errorResponse($request, 'admin.role_builtin_cannot_delete', ErrorCode::ValidationFailed->value, null, 422);

View File

@@ -7,6 +7,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
final class AdminRoleIndexController extends Controller final class AdminRoleIndexController extends Controller
{ {
@@ -14,6 +15,7 @@ final class AdminRoleIndexController extends Controller
{ {
$roles = AdminRole::query() $roles = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM) ->where('scope_type', AdminRole::SCOPE_SYSTEM)
->whereIn('slug', PlatformSystemRoles::fixedSlugs())
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('id') ->orderBy('id')
->get(); ->get();

View File

@@ -9,8 +9,11 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminPermissionInheritance; use App\Support\AdminPermissionInheritance;
use App\Lottery\ErrorCode;
use App\Support\AdminAccountScopeGuard; use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\ApiMessage;
use App\Support\PlatformSystemRoles;
use App\Http\Requests\Admin\AdminRolePermissionSyncRequest; use App\Http\Requests\Admin\AdminRolePermissionSyncRequest;
final class AdminRolePermissionSyncController extends Controller final class AdminRolePermissionSyncController extends Controller
@@ -19,6 +22,16 @@ final class AdminRolePermissionSyncController extends Controller
{ {
AdminAccountScopeGuard::assertSystemRole($admin_role); AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
return ApiMessage::errorResponse(
$request,
'admin.role_super_admin_permissions_fixed',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$slugs = AdminPermissionInheritance::expand( $slugs = AdminPermissionInheritance::expand(
array_values(array_unique($request->validated('permission_slugs', []))), array_values(array_unique($request->validated('permission_slugs', []))),
); );

View File

@@ -2,53 +2,22 @@
namespace App\Http\Controllers\Api\V1\Admin\User; namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminRole; use App\Lottery\ErrorCode;
use App\Support\ApiResponse; use App\Support\ApiMessage;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminPermissionInheritance;
use App\Support\AdminRoleApiPresenter;
use App\Http\Requests\Admin\AdminRoleStoreRequest; use App\Http\Requests\Admin\AdminRoleStoreRequest;
final class AdminRoleStoreController extends Controller final class AdminRoleStoreController extends Controller
{ {
public function __invoke(AdminRoleStoreRequest $request): JsonResponse public function __invoke(AdminRoleStoreRequest $request): JsonResponse
{ {
$permissionSlugs = AdminPermissionInheritance::expand( return ApiMessage::errorResponse(
array_values(array_unique($request->validated('permission_slugs', []))),
);
$role = DB::transaction(function () use ($request, $permissionSlugs): AdminRole {
$role = AdminRole::query()->create([
'slug' => $request->validated('slug'),
'code' => $request->validated('slug'),
'name' => $request->validated('name'),
'description' => $request->validated('description'),
'status' => $request->validated('status', 1),
'is_system' => false,
'sort_order' => 0,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'owner_agent_id' => null,
'delegated_from_role_id' => null,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);
return $role->fresh();
});
AuditLogger::recordForAdmin(
$request->lotteryAdmin(),
$request, $request,
'system', 'admin.platform_roles_fixed',
'admin_role.create', ErrorCode::ValidationFailed->value,
'admin_role',
(string) $role->id,
null, null,
AdminRoleApiPresenter::item($role), 422,
); );
return ApiResponse::success(AdminRoleApiPresenter::item($role));
} }
} }

View File

@@ -3,12 +3,15 @@
namespace App\Http\Controllers\Api\V1\Admin\User; namespace App\Http\Controllers\Api\V1\Admin\User;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Lottery\ErrorCode;
use App\Support\ApiMessage;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Services\AuditLogger; use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard; use App\Support\AdminAccountScopeGuard;
use App\Support\AdminRoleApiPresenter; use App\Support\AdminRoleApiPresenter;
use App\Support\PlatformSystemRoles;
use App\Http\Requests\Admin\AdminRoleUpdateRequest; use App\Http\Requests\Admin\AdminRoleUpdateRequest;
final class AdminRoleUpdateController extends Controller final class AdminRoleUpdateController extends Controller
@@ -17,6 +20,16 @@ final class AdminRoleUpdateController extends Controller
{ {
AdminAccountScopeGuard::assertSystemRole($admin_role); AdminAccountScopeGuard::assertSystemRole($admin_role);
if ($admin_role->slug === PlatformSystemRoles::SLUG_SUPER_ADMIN) {
return ApiMessage::errorResponse(
$request,
'admin.role_super_admin_metadata_fixed',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$before = AdminRoleApiPresenter::item($admin_role); $before = AdminRoleApiPresenter::item($admin_role);
$payload = []; $payload = [];

View File

@@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminAccountScopeGuard; use App\Support\AdminAccountScopeGuard;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
use App\Support\AdminPlatformUserSiteGuard;
use App\Http\Requests\Admin\AdminUserRoleSyncRequest; use App\Http\Requests\Admin\AdminUserRoleSyncRequest;
/** PUT /api/v1/admin/admin-users/{admin_user}/roles */ /** PUT /api/v1/admin/admin-users/{admin_user}/roles */
@@ -15,10 +16,16 @@ final class AdminUserRoleSyncController extends Controller
{ {
public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse public function __invoke(AdminUserRoleSyncRequest $request, AdminUser $admin_user): JsonResponse
{ {
/** @var AdminUser $actor */
$actor = $request->lotteryAdmin();
AdminAccountScopeGuard::assertPlatformAccount($admin_user); AdminAccountScopeGuard::assertPlatformAccount($admin_user);
$siteId = (int) $request->validated('admin_site_id');
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
$slugs = array_values(array_unique($request->validated('role_slugs'))); $slugs = array_values(array_unique($request->validated('role_slugs')));
$admin_user->syncSystemRoleSlugs($slugs); $admin_user->syncSystemRoleSlugsForSite($siteId, $slugs);
$admin_user->load('roles'); $admin_user->load('roles');

View File

@@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Support\AdminUserApiPresenter; use App\Support\AdminUserApiPresenter;
use App\Support\AdminPlatformUserSiteGuard;
use App\Http\Requests\Admin\AdminUserStoreRequest; use App\Http\Requests\Admin\AdminUserStoreRequest;
/** /**
@@ -28,8 +29,10 @@ final class AdminUserStoreController extends Controller
: null; : null;
$roleSlugs = array_values(array_unique($request->validated('role_slugs'))); $roleSlugs = array_values(array_unique($request->validated('role_slugs')));
$siteId = (int) $request->validated('admin_site_id');
AdminPlatformUserSiteGuard::assertActorCanAssignSite($actor, $siteId);
$user = DB::transaction(function () use ($request, $email, $roleSlugs): AdminUser { $user = DB::transaction(function () use ($request, $email, $roleSlugs, $siteId): AdminUser {
$created = AdminUser::query()->create([ $created = AdminUser::query()->create([
'username' => $request->validated('username'), 'username' => $request->validated('username'),
'name' => $request->validated('nickname'), 'name' => $request->validated('nickname'),
@@ -37,7 +40,7 @@ final class AdminUserStoreController extends Controller
'password' => $request->validated('password'), 'password' => $request->validated('password'),
'status' => $request->validated('status', 0), 'status' => $request->validated('status', 0),
]); ]);
$created->syncSystemRoleSlugs($roleSlugs); $created->syncSystemRoleSlugsForSite($siteId, $roleSlugs);
return $created; return $created;
}); });

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Wallet; namespace App\Http\Controllers\Api\V1\Admin\Wallet;
use App\Models\Player;
use App\Models\WalletTxn; use App\Models\WalletTxn;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\PaginationTrait; use App\Support\PaginationTrait;
@@ -9,8 +10,10 @@ use Illuminate\Http\JsonResponse;
use App\Support\AdminScopePolicy; use App\Support\AdminScopePolicy;
use App\Support\AgentNodeApiPresenter; use App\Support\AgentNodeApiPresenter;
use App\Support\CurrencyFormatter; use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WalletTransactionListRequest; use App\Http\Requests\Admin\WalletTransactionListRequest;
use App\Services\Wallet\PlayerLedgerLogsService;
/** /**
* 后台:彩票钱包流水列表 {@see wallet_txns} * 后台:彩票钱包流水列表 {@see wallet_txns}
@@ -33,6 +36,10 @@ final class WalletTransactionListController extends Controller
private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed']; private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed'];
public function __construct(
private readonly PlayerLedgerLogsService $ledgerLogs,
) {}
public function __invoke(WalletTransactionListRequest $request): JsonResponse public function __invoke(WalletTransactionListRequest $request): JsonResponse
{ {
$admin = $request->lotteryAdmin(); $admin = $request->lotteryAdmin();
@@ -44,6 +51,20 @@ final class WalletTransactionListController extends Controller
$perPage = $this->perPage($request, 'per_page', 10, 100); $perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request); $page = $this->page($request);
if (! empty($validated['player_id'])) {
$player = Player::query()->find((int) $validated['player_id']);
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
$credit = $this->ledgerLogs->listForAdminPlayer(
$player,
$page,
$perPage,
isset($validated['biz_type']) ? (string) $validated['biz_type'] : null,
);
return ApiResponse::success($credit);
}
}
$query = WalletTxn::query() $query = WalletTxn::query()
->with([ ->with([
'player:id,site_code,site_player_id,username,nickname,agent_node_id', 'player:id,site_code,site_player_id,username,nickname,agent_node_id',
@@ -141,6 +162,9 @@ final class WalletTransactionListController extends Controller
'remark' => $t->remark, 'remark' => $t->remark,
'created_at' => $t->created_at?->toIso8601String(), 'created_at' => $t->created_at?->toIso8601String(),
'updated_at' => $t->updated_at?->toIso8601String(), 'updated_at' => $t->updated_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
'funding_mode' => $p?->funding_mode,
'auth_source' => $p?->auth_source,
]; ];
} }
} }

View File

@@ -27,6 +27,8 @@ final class MeController extends Controller
'id' => $player->id, 'id' => $player->id,
'site_code' => $player->site_code, 'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id, 'site_player_id' => $player->site_player_id,
'auth_source' => $player->auth_source,
'funding_mode' => $player->funding_mode,
'username' => $player->username, 'username' => $player->username,
'nickname' => $player->nickname, 'nickname' => $player->nickname,
'default_currency' => $player->default_currency, 'default_currency' => $player->default_currency,

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api\V1\Player;
use App\Http\Controllers\Controller;
use App\Http\Requests\Player\PlayerAuthLoginRequest;
use App\Services\Player\PlayerNativeAuthService;
use App\Support\ApiResponse;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Http\JsonResponse;
/** POST /api/v1/player/auth/login — 代理线下玩家账号密码登录 */
final class PlayerAuthLoginController extends Controller
{
public function __invoke(PlayerAuthLoginRequest $request, PlayerNativeAuthService $auth): JsonResponse
{
try {
$data = $auth->login(
(string) $request->validated('site_code'),
(string) $request->validated('username'),
(string) $request->validated('password'),
);
} catch (PlayerAuthenticationException $e) {
return ApiResponse::error(
$e->getMessage(),
$e->lotteryCode,
null,
$e->httpStatus,
);
}
return ApiResponse::success($data);
}
}

View File

@@ -7,9 +7,12 @@ use App\Lottery\ErrorCode;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Support\ApiMessage; use App\Support\ApiMessage;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\CurrencyResolver; use App\Support\CurrencyResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Support\CreditAmountScale;
use App\Support\CurrencyFormatter; use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Wallet\HttpMainSiteWalletBalanceClient; use App\Services\Wallet\HttpMainSiteWalletBalanceClient;
@@ -44,6 +47,37 @@ final class WalletBalanceController extends Controller
return $currencyCode; return $currencyCode;
} }
if (PlayerFundingMode::usesCredit($player)) {
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
$limitMajor = (int) ($credit->credit_limit ?? 0);
$usedMajor = (int) ($credit->used_credit ?? 0);
$frozenMajor = (int) ($credit->frozen_credit ?? 0);
$availableMajor = max(0, $limitMajor - $usedMajor - $frozenMajor);
$limitMinor = CreditAmountScale::majorToMinor($limitMajor, $currencyCode);
$usedMinor = CreditAmountScale::majorToMinor($usedMajor, $currencyCode);
$frozenMinor = CreditAmountScale::majorToMinor($frozenMajor, $currencyCode);
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currencyCode);
return ApiResponse::success([
'balance' => $limitMinor,
'balance_formatted' => CurrencyFormatter::fromMinor($limitMinor),
'available_balance' => $availableMinor,
'available_balance_formatted' => CurrencyFormatter::fromMinor($availableMinor),
'credit_limit' => $limitMinor,
'used_credit' => $usedMinor,
'credit_line_mode' => true,
'funding_mode' => PlayerFundingMode::CREDIT,
'auth_source' => $player->auth_source,
'main_balance' => null,
'main_balance_formatted' => null,
'currency_code' => $currencyCode,
'wallet_type' => self::WALLET_TYPE_LOTTERY,
'frozen_balance' => $frozenMinor,
'frozen_balance_formatted' => CurrencyFormatter::fromMinor($frozenMinor),
]);
}
$wallet = PlayerWallet::query()->firstOrCreate( $wallet = PlayerWallet::query()->firstOrCreate(
[ [
'player_id' => $player->id, 'player_id' => $player->id,
@@ -69,6 +103,9 @@ final class WalletBalanceController extends Controller
'balance_formatted' => CurrencyFormatter::fromMinor($balance), 'balance_formatted' => CurrencyFormatter::fromMinor($balance),
'available_balance' => $available, 'available_balance' => $available,
'available_balance_formatted' => CurrencyFormatter::fromMinor($available), 'available_balance_formatted' => CurrencyFormatter::fromMinor($available),
'credit_line_mode' => false,
'funding_mode' => PlayerFundingMode::WALLET,
'auth_source' => $player->auth_source,
'main_balance' => $mainBalance, 'main_balance' => $mainBalance,
'main_balance_formatted' => $mainBalance !== null 'main_balance_formatted' => $mainBalance !== null
? CurrencyFormatter::fromMinor($mainBalance) ? CurrencyFormatter::fromMinor($mainBalance)

View File

@@ -2,18 +2,17 @@
namespace App\Http\Controllers\Api\V1\Wallet; namespace App\Http\Controllers\Api\V1\Wallet;
use App\Models\WalletTxn; use App\Models\TransferOrder;
use Illuminate\Support\Str;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\TransferOrder;
use App\Support\PaginationTrait; use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Support\CurrencyFormatter; use App\Support\CurrencyFormatter;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Wallet\PlayerLedgerLogsService;
/** /**
* PRD §10.1.1`GET /api/v1/wallet/logs` 钱包流水 * PRD §10.1.1`GET /api/v1/wallet/logs` 钱包/信用流水(按玩家资金模式分表)
* *
* Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal * Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal
*/ */
@@ -21,15 +20,9 @@ final class WalletLogsController extends Controller
{ {
use PaginationTrait; use PaginationTrait;
/** PRD 对外类型 → 本地 biz_type */ public function __construct(
private const TYPE_TO_BIZ = [ private readonly PlayerLedgerLogsService $ledgerLogs,
'transfer_in' => ['transfer_in'], ) {}
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
@@ -38,45 +31,21 @@ final class WalletLogsController extends Controller
$perPage = $this->perPage($request, 'size', 20, 100); $perPage = $this->perPage($request, 'size', 20, 100);
$page = $this->page($request); $page = $this->page($request);
$currencyCode = strtoupper(trim((string) $request->query('currency', ''))); $currencyCode = strtoupper(trim((string) $request->query('currency', '')));
$typeFilter = (string) $request->query('type', '');
$pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode); $pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode);
$bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', '')); $result = $this->ledgerLogs->listForPlayerApi($player, $page, $perPage, $currencyCode, $typeFilter);
if (is_array($bizFilter) && $bizFilter === []) {
return ApiResponse::success([
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
'pending_reconcile' => $pendingPayload,
]);
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()->map(fn (WalletTxn $txn) => $this->formatTxn($txn));
return ApiResponse::success([ return ApiResponse::success([
'items' => $items, 'items' => $result['items'],
'total' => $paginator->total(), 'total' => $result['total'],
'page' => $paginator->currentPage(), 'page' => $result['page'],
'per_page' => $paginator->perPage(), 'per_page' => $result['per_page'],
'ledger_source' => $result['ledger_source'],
'funding_mode' => $result['funding_mode'],
'auth_source' => $result['auth_source'],
'pending_reconcile' => $pendingPayload, 'pending_reconcile' => $pendingPayload,
]); ]);
} }
@@ -97,84 +66,6 @@ final class WalletLogsController extends Controller
->all(); ->all();
} }
/**
* @return list<string>|null null 表示不过滤;空列表表示过滤后无合法 type结果应为空
*/
private function resolveBizTypeFilter(string $raw): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$biz = [];
foreach ($parts as $p) {
$key = Str::lower($p);
if (! isset(self::TYPE_TO_BIZ[$key])) {
continue;
}
foreach (self::TYPE_TO_BIZ[$key] as $b) {
$biz[] = $b;
}
}
return array_values(array_unique($biz));
}
/**
* @return array<string, mixed>
*/
private function formatTxn(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->bizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
];
}
private function bizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@@ -4,12 +4,15 @@ namespace App\Http\Requests\Admin;
use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules; use App\Http\Requests\Admin\Concerns\AgentProfileFieldRules;
use App\Http\Requests\ApiFormRequest; use App\Http\Requests\ApiFormRequest;
use App\Rules\WalletApiUrlRule; use App\Models\AdminSite;
use App\Models\AgentNode;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
final class AdminAgentLineStoreRequest extends ApiFormRequest final class AdminAgentLineStoreRequest extends ApiFormRequest
{ {
use AgentProfileFieldRules; use AgentProfileFieldRules;
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;
@@ -18,29 +21,56 @@ final class AdminAgentLineStoreRequest extends ApiFormRequest
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$this->prepareAgentProfileFieldsForValidation(); $this->prepareAgentProfileFieldsForValidation();
if ($this->has('site_code')) {
$this->merge([
'site_code' => strtolower(trim((string) $this->input('site_code'))),
]);
}
if ($this->has('code')) {
$this->merge([
'code' => strtolower(trim((string) $this->input('code'))),
]);
}
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function rules(): array public function rules(): array
{ {
return [ return [
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('admin_sites', 'code')], 'site_code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::exists('admin_sites', 'code')],
'code' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9][a-z0-9_-]*$/', Rule::unique('agent_nodes', 'code')],
'name' => ['required', 'string', 'max:128'], 'name' => ['required', 'string', 'max:128'],
'username' => ['required', 'string', 'max:64'], 'username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')],
'password' => ['required', 'string', 'min:8', 'max:128'], 'password' => ['required', 'string', 'min:8', 'max:128'],
'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')], 'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')],
'currency_code' => ['sometimes', 'string', 'max:16'],
'status' => ['sometimes', 'integer', 'in:0,1'], '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(), ...$this->agentProfileFieldRules(),
]; ];
} }
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$siteCode = (string) $this->input('site_code');
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return;
}
$hasRoot = AgentNode::query()
->where('admin_site_id', $site->id)
->where('depth', 0)
->exists();
if ($hasRoot) {
$validator->errors()->add('site_code', 'site_root_exists');
}
});
}
} }

View File

@@ -21,8 +21,9 @@ final class AdminPlayerStoreRequest extends ApiFormRequest
{ {
return [ return [
'site_code' => ['required', 'string', 'max:64'], 'site_code' => ['required', 'string', 'max:64'],
'site_player_id' => ['required', 'string', 'max:128'], 'site_player_id' => ['nullable', 'string', 'max:128'],
'username' => ['nullable', 'string', 'max:128'], 'username' => ['nullable', 'string', 'max:128'],
'password' => ['nullable', 'string', 'min:6', 'max:128'],
'nickname' => ['nullable', 'string', 'max:128'], 'nickname' => ['nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')], 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', 'in:0,1,2'], 'status' => ['sometimes', 'integer', 'in:0,1,2'],

View File

@@ -24,6 +24,16 @@ final class AdminPlayerUpdateRequest extends ApiFormRequest
'nickname' => ['sometimes', 'nullable', 'string', 'max:128'], 'nickname' => ['sometimes', 'nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')], 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])], 'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])],
'credit_limit' => ['sometimes', 'integer', 'min:0'],
'rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'rebate_profiles' => ['sometimes', 'array'],
'rebate_profiles.*.game_type' => ['required_with:rebate_profiles', 'string', 'max:32'],
'rebate_profiles.*.rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'rebate_profiles.*.extra_rebate_rate' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'rebate_profiles.*.inherit_from_agent' => ['sometimes', 'boolean'],
'risk_tags' => ['sometimes', 'array'],
'risk_tags.*' => ['string', 'max:64'],
]; ];
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
final class AdminSettlementBillAdjustmentRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'not_in:0'],
'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'],
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
final class AdminSettlementBillBadDebtRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin;
use App\Http\Requests\ApiFormRequest;
final class AdminSettlementBillPaymentRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'method' => ['sometimes', 'nullable', 'string', 'max:32'],
'proof' => ['sometimes', 'nullable', 'string', 'max:2000'],
'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
];
}
}

View File

@@ -22,6 +22,7 @@ final class AdminUserRoleSyncRequest extends ApiFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'],
'role_slugs' => ['required', 'array', 'min:1'], 'role_slugs' => ['required', 'array', 'min:1'],
'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'], 'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'],
]; ];

View File

@@ -52,6 +52,7 @@ final class AdminUserStoreRequest extends ApiFormRequest
'email' => ['nullable', 'string', 'email', 'max:255'], 'email' => ['nullable', 'string', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'max:256'], 'password' => ['required', 'string', 'min:8', 'max:256'],
'status' => ['sometimes', 'integer', 'in:0,1'], 'status' => ['sometimes', 'integer', 'in:0,1'],
'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'],
'role_slugs' => ['required', 'array', 'min:1'], 'role_slugs' => ['required', 'array', 'min:1'],
'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'], 'role_slugs.*' => ['string', 'max:64', 'distinct', 'exists:admin_roles,slug'],
]; ];

View File

@@ -18,6 +18,8 @@ trait AgentProfileFieldRules
'can_grant_extra_rebate' => ['sometimes', 'boolean'], 'can_grant_extra_rebate' => ['sometimes', 'boolean'],
'can_create_child_agent' => ['sometimes', 'boolean'], 'can_create_child_agent' => ['sometimes', 'boolean'],
'can_create_player' => ['sometimes', 'boolean'], 'can_create_player' => ['sometimes', 'boolean'],
'risk_tags' => ['sometimes', 'array'],
'risk_tags.*' => ['string', 'max:64'],
]; ];
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Player;
use App\Http\Requests\ApiFormRequest;
final class PlayerAuthLoginRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_code' => ['required', 'string', 'max:64'],
'username' => ['required', 'string', 'max:128'],
'password' => ['required', 'string', 'min:6', 'max:128'],
];
}
}

View File

@@ -49,6 +49,9 @@ enum ErrorCode: int
/** 幂等键与已有订单冲突(金额/币种/方向不一致) */ /** 幂等键与已有订单冲突(金额/币种/方向不一致) */
case WalletIdempotentConflict = 1010; case WalletIdempotentConflict = 1010;
/** 信用盘玩家不可主站钱包划转 */
case WalletCreditPlayerNoTransfer = 1011;
/* ========== 20002999 下注 / 注单PRD 保留,业务未实现时亦可提前登记) ========== */ /* ========== 20002999 下注 / 注单PRD 保留,业务未实现时亦可提前登记) ========== */
/** PRD当期已封盘 */ /** PRD当期已封盘 */
@@ -112,6 +115,15 @@ enum ErrorCode: int
/** 账号已冻结或禁止登录status ≠ active */ /** 账号已冻结或禁止登录status ≠ active */
case PlayerAccountSuspended = 8005; case PlayerAccountSuspended = 8005;
/** 原生登录:账号或密码错误 */
case PlayerCredentialsInvalid = 8006;
/** 原生登录:失败次数过多已锁定 */
case PlayerLoginLocked = 8007;
/** 原生登录:非彩票账号密码登录类型 */
case PlayerNativeLoginRequired = 8008;
/* ========== 81008199 管理端 API ========== */ /* ========== 81008199 管理端 API ========== */
/** 未登录或 Token 无效 */ /** 未登录或 Token 无效 */

View File

@@ -103,6 +103,24 @@ final class AdminRole extends Model
return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes); return AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes($codes);
} }
/** 授予当前库中全部启用的 menu_action用于超级管理员。 */
public function syncAllActiveMenuActions(): void
{
$ids = DB::table('admin_menu_actions')
->where('status', 1)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
DB::table('admin_role_menu_actions')->where('role_id', $this->id)->delete();
foreach ($ids as $mid) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => $this->id,
'menu_action_id' => $mid,
]);
}
}
/** /**
* @param list<string> $slugs * @param list<string> $slugs
*/ */

View File

@@ -5,7 +5,10 @@ namespace App\Models;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\AdminPermissionBridge; use App\Support\AdminPermissionBridge;
use App\Support\AgentProfileCapabilityFilter;
use App\Models\AdminRole;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -122,6 +125,12 @@ final class AdminUser extends Authenticatable
}); });
} }
/** 经营代理主账号:仅平台角色 slug=agent见 {@see \App\Support\AgentPlatformRole})。 */
public function syncPrimaryPlatformAgentRole(int $agentNodeId): void
{
$this->syncAgentRoleIds($agentNodeId, [\App\Support\AgentPlatformRole::id()]);
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -210,13 +219,22 @@ final class AdminUser extends Authenticatable
} }
/** /**
* 平台账号角色同步:仅允许系统角色,不同步代理角色。 * 平台账号角色同步:仅允许系统角色,不同步代理角色(默认站点,兼容旧调用)
* *
* @param list<string> $slugs * @param list<string> $slugs
*/ */
public function syncSystemRoleSlugs(array $slugs): void public function syncSystemRoleSlugs(array $slugs): void
{ {
$siteId = self::defaultAdminSiteId(); $this->syncSystemRoleSlugsForSite(self::defaultAdminSiteId(), $slugs);
}
/**
* 平台账号在指定站点上的系统角色(全量替换该站点 pivot
*
* @param list<string> $slugs
*/
public function syncSystemRoleSlugsForSite(int $siteId, array $slugs): void
{
$slugs = array_values(array_unique($slugs)); $slugs = array_values(array_unique($slugs));
$roleIds = DB::table('admin_roles') $roleIds = DB::table('admin_roles')
->where('scope_type', AdminRole::SCOPE_SYSTEM) ->where('scope_type', AdminRole::SCOPE_SYSTEM)
@@ -389,7 +407,19 @@ final class AdminUser extends Authenticatable
} }
} }
return array_keys($merged); $codes = array_keys($merged);
return AgentProfileCapabilityFilter::applyToMenuActionCodes($codes, $this->primaryAgentProfile());
}
private function primaryAgentProfile(): ?AgentProfile
{
$agentId = $this->primaryAgentNodeId();
if ($agentId === null) {
return null;
}
return AgentProfile::query()->where('agent_node_id', $agentId)->first();
} }
/** 是否具备指定权限:`prd.*` 走 legacy_map否则按 permission_code 精确匹配。含 `super_admin` 全放行。 */ /** 是否具备指定权限:`prd.*` 走 legacy_map否则按 permission_code 精确匹配。含 `super_admin` 全放行。 */

View File

@@ -18,6 +18,7 @@ final class AgentNode extends Model
'code', 'code',
'name', 'name',
'status', 'status',
'risk_tags',
'created_by', 'created_by',
'extra_json', 'extra_json',
]; ];
@@ -30,6 +31,7 @@ final class AgentNode extends Model
'depth' => 'integer', 'depth' => 'integer',
'status' => 'integer', 'status' => 'integer',
'extra_json' => 'array', 'extra_json' => 'array',
'risk_tags' => 'array',
]; ];
} }

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Support\PlayerAuthSource;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -15,11 +16,21 @@ final class Player extends Model
'site_code', 'site_code',
'agent_node_id', 'agent_node_id',
'site_player_id', 'site_player_id',
'auth_source',
'funding_mode',
'username', 'username',
'password_hash',
'nickname', 'nickname',
'default_currency', 'default_currency',
'status', 'status',
'risk_tags',
'last_login_at', 'last_login_at',
'login_failed_count',
'login_locked_until',
];
protected $hidden = [
'password_hash',
]; ];
protected function casts(): array protected function casts(): array
@@ -27,9 +38,17 @@ final class Player extends Model
return [ return [
'agent_node_id' => 'integer', 'agent_node_id' => 'integer',
'last_login_at' => 'datetime', 'last_login_at' => 'datetime',
'login_failed_count' => 'integer',
'login_locked_until' => 'datetime',
'risk_tags' => 'array',
]; ];
} }
public function isLotteryNative(): bool
{
return (string) $this->auth_source === PlayerAuthSource::LOTTERY_NATIVE;
}
public function wallets(): HasMany public function wallets(): HasMany
{ {
return $this->hasMany(PlayerWallet::class); return $this->hasMany(PlayerWallet::class);

View File

@@ -29,6 +29,7 @@ final class AdminDashboardSnapshotBuilder
public function __construct( public function __construct(
private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly AdminReportQueryService $reportQuery, private readonly AdminReportQueryService $reportQuery,
private readonly AgentDashboardOverviewBuilder $agentOverview,
) {} ) {}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
@@ -55,8 +56,13 @@ final class AdminDashboardSnapshotBuilder
'draw_finance_risk' => $canDraw, 'draw_finance_risk' => $canDraw,
'wallet_transfer_view' => $canWallet, 'wallet_transfer_view' => $canWallet,
], ],
'agent_overview' => null,
]; ];
if ($admin->primaryAgentNode() !== null) {
$out['agent_overview'] = $this->agentOverview->build($admin);
}
if ($canDraw) { if ($canDraw) {
$this->fillPlatformOverview($out, $scope); $this->fillPlatformOverview($out, $scope);
} }

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Models\Player;
use App\Support\AdminAgentSettlementScope;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** 代理账号仪表盘:授信、团队规模、待结账单摘要。 */
final class AgentDashboardOverviewBuilder
{
/**
* @return array<string, mixed>|null
*/
public function build(AdminUser $admin): ?array
{
if (! $admin->hasPermissionCode('dashboard.view')) {
return null;
}
$node = $admin->primaryAgentNode();
if ($node === null) {
return null;
}
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$subtreeIds = AgentNode::query()
->where('path', 'like', $node->path.'%')
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
$directChildCount = AgentNode::query()
->where('parent_id', $node->id)
->count();
$directPlayerCount = 0;
if (Schema::hasColumn('players', 'agent_node_id')) {
$directPlayerCount = Player::query()
->where('agent_node_id', $node->id)
->count();
}
$pendingBillStats = $this->pendingBillStats($admin, $subtreeIds);
return [
'agent_node_id' => (int) $node->id,
'agent_code' => (string) $node->code,
'agent_name' => (string) $node->name,
'depth' => (int) $node->depth,
'credit_limit' => (int) ($profile?->credit_limit ?? 0),
'allocated_credit' => (int) ($profile?->allocated_credit ?? 0),
'used_credit' => (int) ($profile?->used_credit ?? 0),
'available_credit' => max(
0,
(int) ($profile?->credit_limit ?? 0) - (int) ($profile?->allocated_credit ?? 0),
),
'total_share_rate' => (float) ($profile?->total_share_rate ?? 0),
'settlement_cycle' => (string) ($profile?->settlement_cycle ?? 'weekly'),
'can_create_child_agent' => $profile === null || $profile->can_create_child_agent,
'can_create_player' => $profile === null || $profile->can_create_player,
'direct_child_count' => $directChildCount,
'subtree_agent_count' => count($subtreeIds),
'direct_player_count' => $directPlayerCount,
'pending_bill_count' => $pendingBillStats['count'],
'pending_unpaid_minor' => $pendingBillStats['unpaid_minor'],
];
}
/**
* @param list<int> $subtreeIds
* @return array{count: int, unpaid_minor: int}
*/
private function pendingBillStats(AdminUser $admin, array $subtreeIds): array
{
if ($subtreeIds === []) {
return ['count' => 0, 'unpaid_minor' => 0];
}
$query = DB::table('settlement_bills')
->where('bill_type', 'agent')
->where('owner_type', 'agent')
->whereIn('owner_id', $subtreeIds)
->whereIn('status', ['pending', 'pending_confirm', 'partial']);
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
return [
'count' => (int) $query->count(),
'unpaid_minor' => (int) $query->sum('unpaid_amount'),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use Illuminate\Support\Facades\DB;
/**
* 按「下发即占用」真理源重算代理已下发额度:
* 直属玩家 credit_limit 之和 + 直属下级代理 credit_limit 之和。
*/
final class AgentCreditAllocatedSyncService
{
public function syncForAgent(AgentNode $agent): void
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
}
$expected = $this->calculateAllocatedCredit($agent);
if ((int) $profile->allocated_credit === $expected) {
return;
}
$profile->allocated_credit = $expected;
$profile->save();
}
public function syncForAgentId(int $agentNodeId): void
{
$agent = AgentNode::query()->find($agentNodeId);
if ($agent === null) {
return;
}
$this->syncForAgent($agent);
}
public function calculateAllocatedCredit(AgentNode $agent): int
{
$playerTotal = (int) DB::table('player_credit_accounts as pca')
->join('players as p', 'p.id', '=', 'pca.player_id')
->where('p.agent_node_id', $agent->id)
->sum('pca.credit_limit');
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');
$childAgentTotal = $childIds->isEmpty()
? 0
: (int) AgentProfile::query()->whereIn('agent_node_id', $childIds)->sum('credit_limit');
return $playerTotal + $childAgentTotal;
}
}

View File

@@ -7,6 +7,7 @@ use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Support\AdminUserStatus; use App\Support\AdminUserStatus;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -16,25 +17,6 @@ final class AgentNodeService
private readonly AgentProfileService $agentProfileService, 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', 'prd.agent.profile.manage'];
/** @var list<string> */
private const PLAYER_MANAGE_SLUGS = [
'prd.users.manage',
'prd.users.view_finance',
'prd.users.view_cs',
];
/** /**
* @param array{ * @param array{
* parent_id: int, * parent_id: int,
@@ -120,19 +102,7 @@ final class AgentNodeService
$node->path = (string) $parent->path.$node->id.'/'; $node->path = (string) $parent->path.$node->id.'/';
$node->save(); $node->save();
$role = AdminRole::query()->create([ AgentPlatformRole::resolve();
'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([ $user = AdminUser::query()->create([
'username' => $username, 'username' => $username,
@@ -148,9 +118,9 @@ final class AgentNodeService
'is_primary' => true, 'is_primary' => true,
'granted_at' => now(), 'granted_at' => now(),
]); ]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); AgentPlatformRole::assignPrimaryOperator($user, $node);
$profile = $this->agentProfileService->upsertForNode($node, [ $this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0), 'total_share_rate' => (float) ($payload['total_share_rate'] ?? 0),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0), 'credit_limit' => (int) ($payload['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), 'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0),
@@ -161,8 +131,6 @@ final class AgentNodeService
'can_create_player' => (bool) ($payload['can_create_player'] ?? true), 'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
], $parent); ], $parent);
$this->syncPrimaryOwnerRoleFromProfile($node, $profile);
return $node->fresh(['adminSite']); return $node->fresh(['adminSite']);
}); });
} }
@@ -352,32 +320,7 @@ final class AgentNodeService
} }
return DB::transaction(function () use ($node, $username, $password, $email, $status): AdminUser { return DB::transaction(function () use ($node, $username, $password, $email, $status): AdminUser {
$role = AdminRole::query() AgentPlatformRole::resolve();
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($role === null) {
$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,
]);
}
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$role->syncLegacyPermissionSlugs(
$profile !== null
? $this->roleSlugsFromProfile($profile)
: $this->defaultOwnerRoleSlugs(),
);
$user = AdminUser::query()->create([ $user = AdminUser::query()->create([
'username' => $username, 'username' => $username,
@@ -393,93 +336,12 @@ final class AgentNodeService
'is_primary' => true, 'is_primary' => true,
'granted_at' => now(), 'granted_at' => now(),
]); ]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); AgentPlatformRole::assignPrimaryOperator($user, $node);
return $user; return $user;
}); });
} }
/**
* @return list<string>
*/
private function defaultOwnerRoleSlugs(): array
{
return array_values(array_unique(array_merge(
self::BASE_AGENT_ROLE_SLUGS,
self::PLAYER_MANAGE_SLUGS,
)));
}
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 private function resolveCodeForCreate(AgentNode $parent, mixed $rawCode, string $username): string
{ {
$preferred = trim((string) $rawCode); $preferred = trim((string) $rawCode);

View File

@@ -6,6 +6,7 @@ use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Support\AdminAgentScope; use App\Support\AdminAgentScope;
use App\Support\AgentOverdueGuard;
use App\Support\AgentSettlementCycle; use App\Support\AgentSettlementCycle;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -16,6 +17,7 @@ final class AgentProfileService
private readonly ShareRateValidator $shareRateValidator, private readonly ShareRateValidator $shareRateValidator,
private readonly CreditAllocationValidator $creditAllocationValidator, private readonly CreditAllocationValidator $creditAllocationValidator,
private readonly RebateLimitValidator $rebateLimitValidator, private readonly RebateLimitValidator $rebateLimitValidator,
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {} ) {}
/** /**
@@ -39,10 +41,17 @@ final class AgentProfileService
$previousCredit = (int) $profile->credit_limit; $previousCredit = (int) $profile->credit_limit;
$isNew = ! $profile->exists; $isNew = ! $profile->exists;
if (! $isNew && $creditLimit < (int) $profile->allocated_credit) { if ($parent !== null && ! $isNew) {
throw ValidationException::withMessages([ $this->allocatedSync->syncForAgent($parent);
'credit_limit' => ['below_allocated'], }
]);
if (! $isNew) {
$this->allocatedSync->syncForAgent($node);
if ($creditLimit < (int) $profile->allocated_credit) {
throw ValidationException::withMessages([
'credit_limit' => ['below_allocated'],
]);
}
} }
if ($parent !== null) { if ($parent !== null) {
@@ -53,7 +62,7 @@ final class AgentProfileService
} }
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) { if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
throw \Illuminate\Validation\ValidationException::withMessages([ throw ValidationException::withMessages([
'default_player_rebate' => ['exceeds_limit'], 'default_player_rebate' => ['exceeds_limit'],
]); ]);
} }
@@ -77,14 +86,7 @@ final class AgentProfileService
$profile->save(); $profile->save();
if ($parent !== null) { if ($parent !== null) {
$parentProfile = AgentProfile::query()->where('agent_node_id', $parent->id)->first(); $this->allocatedSync->syncForAgent($parent);
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 $profile;
@@ -96,6 +98,9 @@ final class AgentProfileService
*/ */
public function present(AgentProfile $profile): array public function present(AgentProfile $profile): array
{ {
$this->allocatedSync->syncForAgentId((int) $profile->agent_node_id);
$profile->refresh();
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit); $available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
return [ return [
@@ -119,6 +124,65 @@ final class AgentProfileService
return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first(); return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
} }
/**
* @return array<string, mixed>|null
*/
public function parentCapsForNode(?AgentNode $parent): ?array
{
if ($parent === null) {
return null;
}
$this->allocatedSync->syncForAgent($parent);
$profile = $this->profileForNode((int) $parent->id);
if ($profile === null) {
return null;
}
return [
'agent_node_id' => (int) $parent->id,
'total_share_rate' => (float) $profile->total_share_rate,
'rebate_limit' => (float) $profile->rebate_limit,
'available_credit' => max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit),
];
}
/** 玩家授信写入前:校验代理可下发是否足够(按当前库内已占用重算)。 */
public function assertMayIncreasePlayerCredit(AgentNode $agent, int $additionalCredit): void
{
if ($additionalCredit <= 0) {
return;
}
$this->assertAgentProfileExists($agent);
$this->allocatedSync->syncForAgent($agent);
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $additionalCredit);
}
/** 玩家授信变更后:按直属玩家+直属下级代理重算已下发额度(无 profile 时跳过)。 */
public function refreshAllocatedCredit(AgentNode $agent): void
{
$this->allocatedSync->syncForAgent($agent);
}
public function adjustPlayerCreditAllocation(AgentNode $agent, int $previousLimit, int $newLimit, int $playerUsedCredit = 0): void
{
if ($newLimit < $playerUsedCredit) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
}
$delta = $newLimit - $previousLimit;
$this->assertAgentProfileExists($agent);
$this->allocatedSync->syncForAgent($agent);
if ($delta > 0) {
$this->creditAllocationValidator->assertPlayerCreditDeltaWithinAgent($agent, $delta);
}
}
public function assertActorMayCreateChildAgent(AdminUser $admin): void public function assertActorMayCreateChildAgent(AdminUser $admin): void
{ {
if ($admin->isSuperAdmin()) { if ($admin->isSuperAdmin()) {
@@ -135,6 +199,12 @@ final class AgentProfileService
'parent_id' => ['cannot_create_child_agent'], 'parent_id' => ['cannot_create_child_agent'],
]); ]);
} }
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
throw ValidationException::withMessages([
'parent_id' => ['agent_overdue'],
]);
}
} }
public function assertActorMayCreatePlayer(AdminUser $admin): void public function assertActorMayCreatePlayer(AdminUser $admin): void
@@ -153,6 +223,12 @@ final class AgentProfileService
'site_code' => ['cannot_create_player'], 'site_code' => ['cannot_create_player'],
]); ]);
} }
if (AgentOverdueGuard::agentHasOverdueBills((int) $node->id)) {
throw ValidationException::withMessages([
'site_code' => ['agent_overdue'],
]);
}
} }
/** /**
@@ -193,4 +269,15 @@ final class AgentProfileService
return $profile === null || $profile->can_create_player; return $profile === null || $profile->can_create_player;
} }
private function assertAgentProfileExists(AgentNode $agent): void
{
if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) {
return;
}
throw ValidationException::withMessages([
'credit_limit' => ['agent_profile_required'],
]);
}
} }

View File

@@ -2,41 +2,29 @@
namespace App\Services\Agent; namespace App\Services\Agent;
use App\Models\AdminRole;
use App\Models\AdminSite; use App\Models\AdminSite;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Services\Integration\IntegrationSiteService;
use App\Support\AdminUserStatus; use App\Support\AdminUserStatus;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
final class AgentSiteProvisioningService 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( public function __construct(
private readonly IntegrationSiteService $integrationSiteService,
private readonly AgentProfileService $agentProfileService, 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}} *
* @param array<string, mixed> $payload site_code, code, name, username, password, email?, status?, profile fields
* @return array{site: AdminSite, agent_node: AgentNode}
*/ */
public function createRootAgent(AdminUser $actor, array $payload): array public function createRootAgent(AdminUser $actor, array $payload): array
{ {
$siteCode = strtolower(trim((string) ($payload['site_code'] ?? '')));
$code = strtolower(trim((string) ($payload['code'] ?? ''))); $code = strtolower(trim((string) ($payload['code'] ?? '')));
$name = trim((string) ($payload['name'] ?? '')); $name = trim((string) ($payload['name'] ?? ''));
$username = trim((string) ($payload['username'] ?? '')); $username = trim((string) ($payload['username'] ?? ''));
@@ -44,8 +32,9 @@ final class AgentSiteProvisioningService
$email = isset($payload['email']) ? trim((string) $payload['email']) : null; $email = isset($payload['email']) ? trim((string) $payload['email']) : null;
$status = (int) ($payload['status'] ?? 1); $status = (int) ($payload['status'] ?? 1);
if ($code === '' || $name === '' || $username === '' || $password === '') { if ($siteCode === '' || $code === '' || $name === '' || $username === '' || $password === '') {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'site_code' => $siteCode === '' ? ['required'] : [],
'code' => $code === '' ? ['required'] : [], 'code' => $code === '' ? ['required'] : [],
'name' => $name === '' ? ['required'] : [], 'name' => $name === '' ? ['required'] : [],
'username' => $username === '' ? ['required'] : [], 'username' => $username === '' ? ['required'] : [],
@@ -53,6 +42,11 @@ final class AgentSiteProvisioningService
]); ]);
} }
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
throw ValidationException::withMessages(['site_code' => ['exists']]);
}
if (AgentNode::query()->where('code', $code)->exists()) { if (AgentNode::query()->where('code', $code)->exists()) {
throw ValidationException::withMessages(['code' => ['unique']]); throw ValidationException::withMessages(['code' => ['unique']]);
} }
@@ -61,28 +55,18 @@ final class AgentSiteProvisioningService
throw ValidationException::withMessages(['username' => ['unique']]); throw ValidationException::withMessages(['username' => ['unique']]);
} }
$siteData = array_merge($payload, [ $existingRoot = AgentNode::query()
'code' => $code, ->where('admin_site_id', $site->id)
'name' => $name, ->where('depth', 0)
'status' => $status === 0 ? 0 : 1, ->first();
]);
return DB::transaction(function () use ($actor, $siteData, $code, $name, $username, $password, $email, $status): array { if ($existingRoot !== null) {
$created = $this->integrationSiteService->create($siteData); throw ValidationException::withMessages([
$site = $created['site']; 'site_code' => ['site_root_exists'],
$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'],
]);
}
return DB::transaction(function () use ($actor, $site, $code, $name, $username, $password, $email, $status, $payload): array {
$node = AgentNode::query()->create([ $node = AgentNode::query()->create([
'admin_site_id' => $site->id, 'admin_site_id' => $site->id,
'parent_id' => null, 'parent_id' => null,
@@ -97,19 +81,7 @@ final class AgentSiteProvisioningService
$node->path = '/'.$node->id.'/'; $node->path = '/'.$node->id.'/';
$node->save(); $node->save();
$role = AdminRole::query()->create([ AgentPlatformRole::resolve();
'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([ $user = AdminUser::query()->create([
'username' => $username, 'username' => $username,
@@ -125,14 +97,15 @@ final class AgentSiteProvisioningService
'is_primary' => true, 'is_primary' => true,
'granted_at' => now(), 'granted_at' => now(),
]); ]);
$user->syncAgentRoleIds((int) $node->id, [(int) $role->id]); AgentPlatformRole::assignPrimaryOperator($user, $node);
$defaults = config('agent_line_defaults', []);
$this->agentProfileService->upsertForNode($node, [ $this->agentProfileService->upsertForNode($node, [
'total_share_rate' => (float) ($payload['total_share_rate'] ?? 100), 'total_share_rate' => (float) ($payload['total_share_rate'] ?? $defaults['total_share_rate'] ?? 100),
'credit_limit' => (int) ($payload['credit_limit'] ?? 0), 'credit_limit' => (int) ($payload['credit_limit'] ?? $defaults['credit_limit'] ?? 0),
'rebate_limit' => (float) ($payload['rebate_limit'] ?? 0), 'rebate_limit' => (float) ($payload['rebate_limit'] ?? $defaults['rebate_limit'] ?? 0),
'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? 0), 'default_player_rebate' => (float) ($payload['default_player_rebate'] ?? $defaults['default_player_rebate'] ?? 0),
'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? 'weekly'), 'settlement_cycle' => (string) ($payload['settlement_cycle'] ?? $defaults['settlement_cycle'] ?? 'weekly'),
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true), 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? true),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true), 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? true),
'can_create_player' => (bool) ($payload['can_create_player'] ?? true), 'can_create_player' => (bool) ($payload['can_create_player'] ?? true),
@@ -141,7 +114,6 @@ final class AgentSiteProvisioningService
return [ return [
'site' => $site->fresh(), 'site' => $site->fresh(),
'agent_node' => $node->fresh(['adminSite']), 'agent_node' => $node->fresh(['adminSite']),
'secrets' => $secrets,
]; ];
}); });
} }

View File

@@ -4,12 +4,19 @@ namespace App\Services\Agent;
use App\Models\AgentNode; use App\Models\AgentNode;
use App\Models\AgentProfile; use App\Models\AgentProfile;
use App\Support\AgentOverdueGuard;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
final class CreditAllocationValidator final class CreditAllocationValidator
{ {
public function __construct(
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
{ {
$this->allocatedSync->syncForAgent($parent);
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first(); $profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) { if ($profile === null) {
return; return;
@@ -25,15 +32,37 @@ final class CreditAllocationValidator
public function assertPlayerCreditWithinAgent(AgentNode $agent, int $playerCreditLimit): void 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) { if ($playerCreditLimit < 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'credit_limit' => ['invalid'], 'credit_limit' => ['invalid'],
]); ]);
} }
$this->assertPlayerCreditDeltaWithinAgent($agent, $playerCreditLimit);
}
public function assertPlayerCreditDeltaWithinAgent(AgentNode $agent, int $additionalCredit): void
{
if ($additionalCredit <= 0) {
return;
}
AgentOverdueGuard::assertAgentMayGrantCredit((int) $agent->id);
$this->allocatedSync->syncForAgent($agent);
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'credit_limit' => ['agent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
if ($additionalCredit > $available) {
throw ValidationException::withMessages([
'credit_limit' => ['exceeds_available'],
]);
}
} }
} }

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Models\TicketItem;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
/**
* 彩票注单游戏结算侧入账:快照、占成流水、回水计提、玩家已用额度。
*/
final class AgentGameSettlementRecorder
{
public function __construct(
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
private readonly ShareSettlementCalculator $calculator,
private readonly PlayerCreditService $playerCreditService,
) {}
public function shouldRecord(TicketItem $item): bool
{
$player = $item->player;
if ($player === null) {
return false;
}
return PlayerFundingMode::usesCredit($player)
&& (int) ($player->agent_node_id ?? 0) > 0;
}
public function recordForTicketItem(TicketItem $item, int $netWin, string $terminalStatus): void
{
if (! $this->shouldRecord($item)) {
return;
}
$player = $item->player;
if ($player === null) {
return;
}
$gameType = trim((string) ($item->play_code ?? '')) ?: '*';
$snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType);
$gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus);
$validBet = (int) $item->total_bet_amount;
$basicRebate = (int) round($validBet * $snapshot['rebate_rate']);
$extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']);
$extraByCode = [];
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
$leaf = $snapshot['chain_codes'][0];
$extraByCode[$leaf] = $extraRebate;
}
$result = $this->calculator->calculate(
sharedNetWinLoss: 0,
totalSharesByCode: $snapshot['total_shares'],
extraRebateByCode: $extraByCode,
gameWinLoss: $gameWinLoss,
basicRebate: $basicRebate,
chainFromPlayer: $snapshot['chain_codes'],
);
$settledAt = now();
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate): void {
$item->forceFill([
'agent_node_id' => $snapshot['agent_node_id'],
'share_snapshot' => [
'total_shares' => $snapshot['total_shares'],
'actual_shares' => $snapshot['actual_shares'],
'chain_codes' => $snapshot['chain_codes'],
],
'agent_rebate_rate_snapshot' => $snapshot['rebate_rate'],
'agent_settled_at' => $settledAt,
])->save();
DB::table('share_ledger')->insert([
'ticket_item_id' => $item->id,
'player_id' => $player->id,
'agent_node_id' => $snapshot['agent_node_id'],
'agent_path' => json_encode($snapshot['agent_path']),
'share_snapshot' => json_encode($snapshot),
'game_win_loss' => (int) round($gameWinLoss),
'basic_rebate' => $basicRebate,
'shared_net_win_loss' => (int) round($result->sharedNetWinLoss),
'allocations_json' => json_encode($result->finalProfits),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
if ($basicRebate > 0) {
DB::table('rebate_records')->insert([
'player_id' => $player->id,
'ticket_item_id' => $item->id,
'game_type' => $gameType,
'valid_bet_amount' => $validBet,
'rebate_rate' => $snapshot['rebate_rate'],
'rebate_amount' => $basicRebate,
'rebate_type' => 'basic',
'owner_agent_id' => $snapshot['agent_node_id'],
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
}
if ($extraRebate > 0) {
DB::table('rebate_records')->insert([
'player_id' => $player->id,
'ticket_item_id' => $item->id,
'game_type' => $gameType,
'valid_bet_amount' => $validBet,
'rebate_rate' => $snapshot['extra_rebate_rate'],
'rebate_amount' => $extraRebate,
'rebate_type' => 'extra',
'owner_agent_id' => $snapshot['agent_node_id'],
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
}
$holdAmount = (int) $item->actual_deduct_amount;
if ($holdAmount > 0) {
$this->playerCreditService->releaseBetHold($player, $holdAmount, $item->id);
}
if ($gameWinLoss > 0) {
$this->playerCreditService->applySettledLoss($player, (int) round($gameWinLoss), $item->id);
}
});
}
private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float
{
if ($terminalStatus === 'settled_lose') {
return (float) max(0, (int) $item->actual_deduct_amount);
}
if ($netWin > 0) {
return -1 * (float) $netWin;
}
return 0.0;
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
final class AgentPeriodAggregator
{
public function __construct(
private readonly ShareSettlementCalculator $calculator,
private readonly BetSettlementSnapshotBuilder $snapshotBuilder,
) {}
/**
* @return array{
* players: array<int, array<string, mixed>>,
* agent_edges: array<string, int>,
* agent_subtrees: array<int, array<string, mixed>>,
* platform_share_profit: int,
* }
*/
public function aggregate(int $adminSiteId, string $periodStart, string $periodEnd): array
{
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
$codeToId = AgentNode::query()
->where('admin_site_id', $adminSiteId)
->pluck('id', 'code')
->mapWithKeys(fn ($id, $code): array => [(string) $code => (int) $id])
->all();
$rows = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->select([
'sl.player_id',
'sl.ticket_item_id',
'sl.agent_node_id',
'sl.share_snapshot',
'sl.game_win_loss',
'sl.basic_rebate',
])
->orderBy('sl.id')
->get();
$players = [];
$agentEdges = [];
$agentSubtrees = [];
$platformShareProfit = 0;
foreach ($rows as $row) {
$playerId = (int) $row->player_id;
$snapshot = $this->resolveSnapshotFromLedgerRow($row);
if ($snapshot === null) {
$player = Player::query()->find($playerId);
if ($player === null) {
continue;
}
$built = $this->snapshotBuilder->buildForPlayer($player);
$snapshot = [
'total_shares' => $built['total_shares'],
'chain_codes' => $built['chain_codes'],
];
}
$gameWinLoss = (int) $row->game_win_loss;
$basicRebate = (int) $row->basic_rebate;
$extraRebate = $this->extraRebateForTicketItem(
(int) $row->ticket_item_id,
$periodStart,
$periodEnd,
);
$extraByCode = [];
if ($extraRebate > 0 && $snapshot['chain_codes'] !== []) {
$extraByCode[$snapshot['chain_codes'][0]] = $extraRebate;
}
$result = $this->calculator->calculate(
sharedNetWinLoss: 0,
totalSharesByCode: $snapshot['total_shares'],
extraRebateByCode: $extraByCode,
gameWinLoss: $gameWinLoss,
basicRebate: $basicRebate,
chainFromPlayer: $snapshot['chain_codes'],
);
$net = (int) round($result->playerNetSettlement);
if (! isset($players[$playerId])) {
$players[$playerId] = [
'agent_node_id' => (int) $row->agent_node_id,
'game_win_loss' => 0,
'basic_rebate' => 0,
'extra_rebate' => 0,
'net_amount' => 0,
];
}
$players[$playerId]['game_win_loss'] += $gameWinLoss;
$players[$playerId]['basic_rebate'] += $basicRebate;
$players[$playerId]['extra_rebate'] += $extraRebate;
$players[$playerId]['net_amount'] += $net;
foreach ($result->tierSettlements as $edge => $amount) {
$agentEdges[$edge] = ($agentEdges[$edge] ?? 0) + (int) round($amount);
}
$platformShareProfit += (int) round($result->finalProfits['platform'] ?? 0);
$pathIds = $this->resolveAgentPathIds($row, $snapshot, $codeToId);
foreach ($pathIds as $agentId) {
if (! isset($agentSubtrees[$agentId])) {
$agentSubtrees[$agentId] = [
'gross_win_loss' => 0,
'basic_rebate' => 0,
'extra_rebate' => 0,
'share_profit' => 0,
'player_count' => 0,
'_players_seen' => [],
];
}
$agentSubtrees[$agentId]['gross_win_loss'] += $gameWinLoss;
$agentSubtrees[$agentId]['basic_rebate'] += $basicRebate;
$agentSubtrees[$agentId]['extra_rebate'] += $extraRebate;
if (! in_array($playerId, $agentSubtrees[$agentId]['_players_seen'], true)) {
$agentSubtrees[$agentId]['_players_seen'][] = $playerId;
$agentSubtrees[$agentId]['player_count']++;
}
}
foreach ($snapshot['chain_codes'] as $code) {
$agentId = $codeToId[$code] ?? 0;
if ($agentId <= 0) {
continue;
}
$profit = (int) round($result->finalProfits[$code] ?? 0);
$agentSubtrees[$agentId]['share_profit'] = ($agentSubtrees[$agentId]['share_profit'] ?? 0) + $profit;
}
}
foreach ($agentSubtrees as $id => $subtree) {
unset($agentSubtrees[$id]['_players_seen']);
}
return [
'players' => $players,
'agent_edges' => $agentEdges,
'agent_subtrees' => $agentSubtrees,
'platform_share_profit' => $platformShareProfit,
];
}
/**
* @param array{chain_codes: list<string>, total_shares: array<string, float>} $snapshot
* @param array<string, int> $codeToId
* @return list<int>
*/
private function resolveAgentPathIds(object $row, array $snapshot, array $codeToId): array
{
$raw = $row->share_snapshot ?? null;
if ($raw !== null && $raw !== '') {
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (is_array($decoded) && is_array($decoded['agent_path'] ?? null)) {
return array_values(array_map(intval(...), $decoded['agent_path']));
}
}
$ids = [];
foreach ($snapshot['chain_codes'] as $code) {
$id = $codeToId[$code] ?? 0;
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
public function siteIdForPeriod(int $periodId): int
{
return (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
}
private function extraRebateForTicketItem(int $ticketItemId, string $periodStart, string $periodEnd): int
{
if ($ticketItemId <= 0) {
return 0;
}
return (int) DB::table('rebate_records')
->where('ticket_item_id', $ticketItemId)
->where('rebate_type', 'extra')
->whereIn('status', ['accrued', 'reversed'])
->whereBetween('created_at', [$periodStart, $periodEnd])
->sum('rebate_amount');
}
/**
* @return array{total_shares: array<string, float>, chain_codes: list<string>}|null
*/
private function resolveSnapshotFromLedgerRow(object $row): ?array
{
$raw = $row->share_snapshot ?? null;
if ($raw === null || $raw === '') {
return null;
}
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (! is_array($decoded)) {
return null;
}
$totalShares = $decoded['total_shares'] ?? null;
$chainCodes = $decoded['chain_codes'] ?? null;
if (! is_array($totalShares) || ! is_array($chainCodes) || $chainCodes === []) {
return null;
}
$shares = [];
foreach ($totalShares as $code => $rate) {
$shares[(string) $code] = (float) $rate;
}
return [
'total_shares' => $shares,
'chain_codes' => array_values(array_map(strval(...), $chainCodes)),
];
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单§2、§21.1)。 */
final class AgentSettlementBadDebtService
{
public function __construct(
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
{
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$unpaid = (int) $original->unpaid_amount;
if ($unpaid <= 0) {
throw ValidationException::withMessages([
'bill' => ['no_unpaid'],
]);
}
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int {
$now = now();
$periodId = (int) $original->settlement_period_id;
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'bad_debt',
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => -$unpaid,
'platform_rounding_adjustment' => 0,
'net_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'status' => 'settled',
'reversed_bill_id' => $originalBillId,
'meta_json' => json_encode([
'original_bill_id' => $originalBillId,
'written_off_amount' => $unpaid,
'original_net_amount' => (int) $original->net_amount,
]),
'locked_at' => $now,
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $originalBillId,
'adjustment_type' => 'bad_debt',
'amount' => $unpaid,
'reason' => $reason,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$this->decodeMeta($original->meta_json),
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
],
)),
'updated_at' => $now,
]);
$this->periodCompletion->syncIfReady($periodId);
return $archiveBillId;
});
}
/**
* @return array<string, mixed>
*/
private function decodeMeta(mixed $metaJson): array
{
if ($metaJson === null || $metaJson === '') {
return [];
}
if (is_array($metaJson)) {
return $metaJson;
}
$decoded = json_decode((string) $metaJson, true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* 已锁定账单的补差/冲正单§21.1、§21.2)。
*/
final class AgentSettlementBillAdjustmentService
{
public function __construct(
private readonly AgentSettlementBillGuard $billGuard,
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
public function createAdjustment(
int $originalBillId,
int $amount,
string $adjustmentType,
?string $reason,
int $adminUserId,
): int {
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_locked'],
]);
}
if ($amount === 0) {
throw ValidationException::withMessages([
'amount' => ['zero'],
]);
}
$type = in_array($adjustmentType, ['adjustment', 'reversal'], true)
? $adjustmentType
: 'adjustment';
return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId): int {
$now = now();
$newBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => (int) $original->settlement_period_id,
'bill_type' => $type,
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => $amount,
'platform_rounding_adjustment' => 0,
'net_amount' => $amount,
'paid_amount' => 0,
'unpaid_amount' => abs($amount),
'status' => 'pending_confirm',
'reversed_bill_id' => (int) $original->id,
'meta_json' => json_encode([
'original_bill_id' => (int) $original->id,
'original_net_amount' => (int) $original->net_amount,
]),
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => (int) $original->settlement_period_id,
'original_bill_id' => (int) $original->id,
'adjustment_type' => $type,
'amount' => $amount,
'reason' => $reason,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
return $newBillId;
});
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class AgentSettlementBillGuard
{
private const LOCKED_STATUSES = ['confirmed', 'partial_paid', 'settled', 'overdue', 'reversed'];
public function __construct(
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
public function assertPeriodMutable(int $billId): void
{
$periodId = (int) DB::table('settlement_bills')->where('id', $billId)->value('settlement_period_id');
if ($this->periodCompletion->isPeriodReadOnly($periodId)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
}
public function assertNetAmountMutable(int $billId): void
{
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null) {
return;
}
if (in_array((string) $bill->status, self::LOCKED_STATUSES, true) || $bill->locked_at !== null) {
throw ValidationException::withMessages([
'bill' => ['locked'],
]);
}
}
public function markConfirmed(int $billId): void
{
$this->assertPeriodMutable($billId);
DB::table('settlement_bills')->where('id', $billId)->update([
'status' => 'confirmed',
'locked_at' => now(),
'confirmed_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -2,13 +2,20 @@
namespace App\Services\AgentSettlement; namespace App\Services\AgentSettlement;
use App\Support\Settlement\DesignDocExample12; use App\Models\AgentNode;
use App\Services\Agent\AgentCreditAllocatedSyncService;
use App\Support\AgentSettlementProductionGuard;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
final class AgentSettlementPeriodCloseService final class AgentSettlementPeriodCloseService
{ {
public function __construct( public function __construct(
private readonly ShareSettlementCalculator $calculator, private readonly AgentPeriodAggregator $aggregator,
private readonly SettlementBillGenerator $billGenerator,
private readonly PeriodCloseRebateService $periodCloseRebate,
private readonly UnsettledTicketPeriodWarning $unsettledWarning,
private readonly PlatformRoundingAdjuster $platformRounding,
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {} ) {}
/** /**
@@ -16,51 +23,74 @@ final class AgentSettlementPeriodCloseService
*/ */
public function closePeriod(int $periodId): array public function closePeriod(int $periodId): array
{ {
AgentSettlementProductionGuard::assertProductionCloseAllowed();
$period = DB::table('settlement_periods')->where('id', $periodId)->first(); $period = DB::table('settlement_periods')->where('id', $periodId)->first();
if ($period === null) { if ($period === null) {
throw new \InvalidArgumentException('period_not_found'); throw new \InvalidArgumentException('period_not_found');
} }
$result = $this->calculator->calculate( if ((string) $period->status === 'closed') {
sharedNetWinLoss: DesignDocExample12::SHARED_NET_WIN_LOSS, throw new \InvalidArgumentException('period_already_closed');
totalSharesByCode: [ }
'A' => DesignDocExample12::TOTAL_SHARE_A,
'B' => DesignDocExample12::TOTAL_SHARE_B, $adminSiteId = (int) $period->admin_site_id;
'C' => DesignDocExample12::TOTAL_SHARE_C, $aggregate = $this->aggregator->aggregate(
], $adminSiteId,
extraRebateByCode: ['C' => DesignDocExample12::EXTRA_REBATE_BY_C], (string) $period->period_start,
gameWinLoss: DesignDocExample12::GAME_WIN_LOSS, (string) $period->period_end,
basicRebate: DesignDocExample12::BASIC_REBATE,
chainFromPlayer: ['C', 'B', 'A'],
); );
$playerBillId = DB::table('settlement_bills')->insertGetId([ if ($aggregate['players'] === []) {
'settlement_period_id' => $periodId, throw new \InvalidArgumentException('period_no_ledger_rows');
'bill_type' => 'player', }
'owner_type' => 'player',
'owner_id' => 0, $billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate);
'counterparty_type' => 'agent',
'counterparty_id' => 0, $roundingDiff = $this->platformRounding->apply($periodId, $aggregate);
'gross_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
'rebate_amount' => DesignDocExample12::BASIC_REBATE + DesignDocExample12::EXTRA_REBATE_BY_C, $rebateStats = $this->periodCloseRebate->dispatchAndAllocate(
'adjustment_amount' => 0, $periodId,
'net_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, (string) $period->period_start,
'paid_amount' => 0, (string) $period->period_end,
'unpaid_amount' => (int) DesignDocExample12::PLAYER_NET_SETTLEMENT, );
'status' => 'pending',
'created_at' => now(), $unsettled = $this->unsettledWarning->countForSite(
'updated_at' => now(), $adminSiteId,
]); (string) $period->period_start,
(string) $period->period_end,
);
DB::table('settlement_periods')->where('id', $periodId)->update([ DB::table('settlement_periods')->where('id', $periodId)->update([
'status' => 'closed', 'status' => 'closed',
'updated_at' => now(), 'updated_at' => now(),
]); ]);
DB::table('share_ledger')
->whereBetween('settled_at', [$period->period_start, $period->period_end])
->update(['settlement_period_id' => $periodId]);
$this->reconcileAllocatedCreditForSite($adminSiteId);
return [ return [
'period_id' => $periodId, 'period_id' => $periodId,
'settlement' => $result, 'bill_ids' => $billIds,
'player_bill_id' => $playerBillId, 'player_count' => count($aggregate['players']),
'agent_edges' => $aggregate['agent_edges'],
'rebate_dispatched' => $rebateStats['dispatched'],
'rebate_allocations' => $rebateStats['allocations'],
'unsettled_ticket_count' => $unsettled['count'],
'unsettled_ticket_sample' => $unsettled['ticket_item_ids'],
'platform_rounding_adjustment' => $roundingDiff,
]; ];
} }
/** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */
private function reconcileAllocatedCreditForSite(int $adminSiteId): void
{
$nodes = AgentNode::query()->where('admin_site_id', $adminSiteId)->get();
foreach ($nodes as $node) {
$this->allocatedSync->syncForAgent($node);
}
}
} }

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
/** 账期全部账单结清或核销后标记为 completed§4。 */
final class AgentSettlementPeriodCompletionService
{
public function syncIfReady(int $periodId): void
{
if ($periodId <= 0) {
return;
}
$period = DB::table('settlement_periods')->where('id', $periodId)->first();
if ($period === null || (string) $period->status !== 'closed') {
return;
}
if ($this->hasOpenSettlementWork($periodId)) {
return;
}
DB::table('settlement_periods')
->where('id', $periodId)
->update([
'status' => 'completed',
'updated_at' => now(),
]);
}
public function isPeriodReadOnly(int $periodId): bool
{
if ($periodId <= 0) {
return false;
}
$status = DB::table('settlement_periods')->where('id', $periodId)->value('status');
return $status !== null && (string) $status === 'completed';
}
private function hasOpenSettlementWork(int $periodId): bool
{
return DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->whereNotIn('bill_type', ['bad_debt'])
->where(function ($query): void {
$query->where('status', 'pending_confirm')
->orWhere(function ($inner): void {
$inner->whereIn('status', ['confirmed', 'partial_paid', 'overdue'])
->where('unpaid_amount', '>', 0);
});
})
->exists();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\AgentSettlement;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** 账期窗口内信用流水与占成流水笔数(关账前诊断)。 */
final class AgentSettlementPeriodPipelineService
{
/**
* @param Collection<int, object> $periods settlement_periods 行,须含 id、period_start、period_end、admin_site_id
* @return array<int, array{credit_ledger_count: int, share_ledger_count: int}>
*/
public function countsForPeriods(Collection $periods): array
{
if ($periods->isEmpty()) {
return [];
}
$siteIds = $periods->pluck('admin_site_id')->map(static fn ($id): int => (int) $id)->unique()->all();
$siteCodes = DB::table('admin_sites')
->whereIn('id', $siteIds)
->pluck('code', 'id');
$out = [];
foreach ($periods as $period) {
$periodId = (int) $period->id;
$siteCode = (string) ($siteCodes[(int) $period->admin_site_id] ?? '');
if ($siteCode === '') {
$out[$periodId] = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
continue;
}
$start = Carbon::parse($period->period_start)->startOfDay();
$end = Carbon::parse($period->period_end)->endOfDay();
$creditCount = (int) DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->whereBetween('cl.created_at', [$start, $end])
->count();
$shareCount = (int) DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$start, $end])
->count();
$out[$periodId] = [
'credit_ledger_count' => $creditCount,
'share_ledger_count' => $shareCount,
];
}
return $out;
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/** 账期维度账单笔数与待办汇总(结算中心看板)。 */
final class AgentSettlementPeriodSummaryService
{
public function __construct(
private readonly AgentSettlementPeriodPipelineService $pipelineService,
) {}
/**
* @param list<int> $periodIds
* @return array<int, array<string, int>>
*/
public function summariesForPeriodIds(array $periodIds): array
{
if ($periodIds === []) {
return [];
}
$rows = DB::table('settlement_bills')
->whereIn('settlement_period_id', $periodIds)
->groupBy('settlement_period_id')
->selectRaw('settlement_period_id')
->selectRaw("SUM(CASE WHEN bill_type = 'player' THEN 1 ELSE 0 END) as player_bills")
->selectRaw("SUM(CASE WHEN bill_type = 'agent' THEN 1 ELSE 0 END) as agent_bills")
->selectRaw("SUM(CASE WHEN bill_type IN ('adjustment', 'reversal') THEN 1 ELSE 0 END) as adjustment_bills")
->selectRaw("SUM(CASE WHEN status = 'pending_confirm' THEN 1 ELSE 0 END) as pending_confirm")
->selectRaw("SUM(CASE WHEN status IN ('confirmed', 'partial_paid', 'overdue') AND unpaid_amount > 0 THEN 1 ELSE 0 END) as awaiting_payment")
->selectRaw("SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled")
->selectRaw('COALESCE(SUM(unpaid_amount), 0) as total_unpaid')
->get();
$out = [];
foreach ($rows as $row) {
$periodId = (int) $row->settlement_period_id;
$out[$periodId] = [
'player_bills' => (int) $row->player_bills,
'agent_bills' => (int) $row->agent_bills,
'adjustment_bills' => (int) $row->adjustment_bills,
'pending_confirm' => (int) $row->pending_confirm,
'awaiting_payment' => (int) $row->awaiting_payment,
'settled' => (int) $row->settled,
'total_unpaid' => (int) $row->total_unpaid,
];
}
return $out;
}
/**
* @param Collection<int, object> $periods
* @return list<array<string, mixed>>
*/
public function attachToPeriodRows(Collection $periods): array
{
$ids = $periods->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$summaries = $this->summariesForPeriodIds($ids);
$pipelines = $this->pipelineService->countsForPeriods($periods);
$empty = [
'player_bills' => 0,
'agent_bills' => 0,
'adjustment_bills' => 0,
'pending_confirm' => 0,
'awaiting_payment' => 0,
'settled' => 0,
'total_unpaid' => 0,
];
$emptyPipeline = ['credit_ledger_count' => 0, 'share_ledger_count' => 0];
$items = [];
foreach ($periods as $period) {
$row = (array) $period;
$row['summary'] = $summaries[(int) $period->id] ?? $empty;
$row['pipeline'] = $pipelines[(int) $period->id] ?? $emptyPipeline;
$items[] = $row;
}
return $items;
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminAgentSettlementScope;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/** §21.12 信用占成盘报表最低集。 */
final class AgentSettlementReportQueryService
{
/**
* @return array<string, mixed>
*/
public function summary(AdminUser $admin, int $periodId = 0): array
{
$query = DB::table('settlement_bills');
AdminAgentSettlementScope::applyToBillsQuery($query, $admin);
if ($periodId > 0) {
$query->where('settlement_period_id', $periodId);
}
$rows = $query->get();
return [
'bill_count' => $rows->count(),
'total_net' => (int) $rows->sum('net_amount'),
'total_unpaid' => (int) $rows->sum('unpaid_amount'),
'overdue_count' => $rows->where('status', 'overdue')->count(),
'platform_rounding_total' => (int) $rows->sum('platform_rounding_adjustment'),
];
}
/**
* @return list<array<string, mixed>>
*/
public function playerWinLoss(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
{
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
return DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->leftJoin('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->groupBy('sl.player_id', 'p.username', 'p.agent_node_id', 'ti.play_code')
->selectRaw('sl.player_id, p.username, p.agent_node_id, COALESCE(ti.play_code, ?) as game_type', ['*'])
->selectRaw('SUM(sl.game_win_loss) as game_win_loss')
->selectRaw('SUM(sl.basic_rebate) as basic_rebate')
->orderByDesc('game_win_loss')
->get()
->map(static fn (object $r): array => [
'player_id' => (int) $r->player_id,
'username' => (string) ($r->username ?? ''),
'agent_node_id' => (int) $r->agent_node_id,
'game_type' => (string) $r->game_type,
'game_win_loss' => (int) $r->game_win_loss,
'basic_rebate' => (int) $r->basic_rebate,
])
->all();
}
/**
* @return list<array<string, mixed>>
*/
public function agentShare(AdminUser $admin, string $periodStart, string $periodEnd): array
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
return DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->groupBy('sl.agent_node_id')
->selectRaw('sl.agent_node_id, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as entry_count')
->orderByDesc('game_win_loss')
->get()
->map(static fn (object $r): array => [
'agent_node_id' => (int) $r->agent_node_id,
'game_win_loss' => (int) $r->game_win_loss,
'basic_rebate' => (int) $r->basic_rebate,
'entry_count' => (int) $r->entry_count,
])
->all();
}
/**
* @return array<string, mixed>
*/
public function rebate(AdminUser $admin, int $periodId, string $periodStart, string $periodEnd): array
{
$siteCode = $this->siteCodeForAdmin($admin, $periodId);
$base = DB::table('rebate_records as rr')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode);
$accrued = (clone $base)->where('rr.status', 'accrued')->sum('rr.rebate_amount');
$inBill = (clone $base)->where('rr.status', 'in_bill')->sum('rr.rebate_amount');
$settled = (clone $base)->where('rr.status', 'settled')->sum('rr.rebate_amount');
$allocated = (int) DB::table('rebate_allocations as ra')
->join('rebate_records as rr', 'rr.id', '=', 'ra.rebate_record_id')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->when($periodId > 0, fn (Builder $q) => $q->where('rr.settlement_period_id', $periodId))
->sum('ra.allocated_amount');
return [
'accrued_total' => (int) $accrued,
'in_bill_total' => (int) $inBill,
'settled_total' => (int) $settled,
'allocated_total' => $allocated,
'by_type' => DB::table('rebate_records as rr')
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->whereBetween('rr.created_at', [$periodStart, $periodEnd])
->groupBy('rr.rebate_type', 'rr.status')
->selectRaw('rr.rebate_type, rr.status, SUM(rr.rebate_amount) as total, COUNT(*) as cnt')
->get()
->map(static fn (object $r): array => [
'rebate_type' => (string) $r->rebate_type,
'status' => (string) $r->status,
'total' => (int) $r->total,
'count' => (int) $r->cnt,
])
->all(),
];
}
/**
* @return array{agents: list<array<string, mixed>>, players: list<array<string, mixed>>}
*/
public function credit(AdminUser $admin): array
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
$agents = DB::table('agent_profiles as ap')
->join('agent_nodes as an', 'an.id', '=', 'ap.agent_node_id')
->join('admin_sites as s', 's.id', '=', 'an.admin_site_id')
->where('s.code', $siteCode)
->selectRaw('ap.agent_node_id, an.code, an.name, ap.credit_limit, ap.allocated_credit, (ap.credit_limit - ap.allocated_credit) as available_credit')
->orderBy('an.depth')
->get()
->map(static fn (object $r): array => [
'agent_node_id' => (int) $r->agent_node_id,
'code' => (string) $r->code,
'name' => (string) $r->name,
'credit_limit' => (int) $r->credit_limit,
'allocated_credit' => (int) $r->allocated_credit,
'available_credit' => (int) $r->available_credit,
])
->all();
$players = DB::table('player_credit_accounts as pc')
->join('players as p', 'p.id', '=', 'pc.player_id')
->where('p.site_code', $siteCode)
->selectRaw('pc.player_id, p.username, pc.credit_limit, pc.used_credit, pc.frozen_credit, (pc.credit_limit - pc.used_credit - pc.frozen_credit) as available_credit')
->orderByDesc('pc.used_credit')
->limit(500)
->get()
->map(static fn (object $r): array => [
'player_id' => (int) $r->player_id,
'username' => (string) ($r->username ?? ''),
'credit_limit' => (int) $r->credit_limit,
'used_credit' => (int) $r->used_credit,
'frozen_credit' => (int) $r->frozen_credit,
'available_credit' => max(0, (int) $r->available_credit),
])
->all();
return ['agents' => $agents, 'players' => $players];
}
/**
* @return list<array<string, mixed>>
*/
public function unpaidBills(AdminUser $admin, int $periodId = 0): array
{
$query = DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sb.unpaid_amount', '>', 0)
->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']);
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
if ($periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
return $query
->select([
'sb.id',
'sb.bill_type',
'sb.owner_type',
'sb.owner_id',
'sb.counterparty_type',
'sb.counterparty_id',
'sb.net_amount',
'sb.unpaid_amount',
'sb.status',
'sp.period_start',
'sp.period_end',
])
->orderByDesc('sb.unpaid_amount')
->get()
->map(static fn (object $r): array => [
'bill_id' => (int) $r->id,
'bill_type' => (string) $r->bill_type,
'owner_type' => (string) $r->owner_type,
'owner_id' => (int) $r->owner_id,
'counterparty_type' => (string) $r->counterparty_type,
'counterparty_id' => (int) $r->counterparty_id,
'net_amount' => (int) $r->net_amount,
'unpaid_amount' => (int) $r->unpaid_amount,
'status' => (string) $r->status,
'period_start' => (string) $r->period_start,
'period_end' => (string) $r->period_end,
])
->all();
}
/**
* @return list<array<string, mixed>>
*/
public function overdue(AdminUser $admin): array
{
$query = DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->where('sb.status', 'overdue')
->where('sb.unpaid_amount', '>', 0);
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
return $query
->select([
'sb.id',
'sb.bill_type',
'sb.owner_type',
'sb.owner_id',
'sb.unpaid_amount',
'sb.updated_at',
'sp.period_end',
])
->orderBy('sb.updated_at')
->get()
->map(static function (object $r): array {
$days = Carbon::parse((string) $r->updated_at)->diffInDays(now());
return [
'bill_id' => (int) $r->id,
'bill_type' => (string) $r->bill_type,
'owner_type' => (string) $r->owner_type,
'owner_id' => (int) $r->owner_id,
'unpaid_amount' => (int) $r->unpaid_amount,
'overdue_days' => $days,
'period_end' => (string) $r->period_end,
];
})
->all();
}
/**
* @return array<string, mixed>
*/
public function platformPnl(AdminUser $admin, int $periodId): array
{
$query = DB::table('settlement_bills as sb')
->where('sb.settlement_period_id', $periodId)
->where(function (Builder $q): void {
$q->where('sb.counterparty_type', 'platform')
->orWhere('sb.owner_type', 'platform');
});
AdminAgentSettlementScope::applyToBillsQuery($query, $admin, 'sb');
$rows = $query->get();
return [
'platform_bill_net' => (int) $rows->sum('net_amount'),
'platform_rounding_adjustment' => (int) $rows->sum('platform_rounding_adjustment'),
'share_profit_meta' => (int) $rows->sum(fn (object $r): int => (int) (json_decode((string) ($r->meta_json ?? '{}'), true)['platform_share_profit'] ?? 0)),
];
}
/**
* @return list<array<string, mixed>>
*/
public function drawPeriod(AdminUser $admin, string $periodStart, string $periodEnd): array
{
$siteCode = $this->siteCodeForAdmin($admin, 0);
return DB::table('share_ledger as sl')
->join('ticket_items as ti', 'ti.id', '=', 'sl.ticket_item_id')
->join('players as p', 'p.id', '=', 'sl.player_id')
->join('draws as d', 'd.id', '=', 'ti.draw_id')
->where('p.site_code', $siteCode)
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->whereNull('sl.reversal_of_id')
->groupBy('ti.draw_id', 'd.draw_no')
->selectRaw('ti.draw_id, d.draw_no, SUM(sl.game_win_loss) as game_win_loss, SUM(sl.basic_rebate) as basic_rebate, COUNT(*) as ticket_count')
->orderBy('d.draw_no')
->get()
->map(static fn (object $r): array => [
'draw_id' => (int) $r->draw_id,
'draw_no' => (string) $r->draw_no,
'game_win_loss' => (int) $r->game_win_loss,
'basic_rebate' => (int) $r->basic_rebate,
'ticket_count' => (int) $r->ticket_count,
])
->all();
}
private function siteCodeForAdmin(AdminUser $admin, int $periodId): string
{
if ($periodId > 0) {
$siteId = (int) DB::table('settlement_periods')->where('id', $periodId)->value('admin_site_id');
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
}
$siteId = (int) ($admin->admin_site_id ?? 0);
if ($siteId > 0) {
return (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
}
return (string) DB::table('admin_sites')->where('is_default', true)->value('code');
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
final class BetSettlementSnapshotBuilder
{
/**
* @return array{
* agent_node_id: int,
* agent_path: list<int>,
* chain_codes: list<string>,
* total_shares: array<string, float>,
* actual_shares: array<string, float>,
* rebate_rate: float,
* extra_rebate_rate: float,
* }
*/
public function buildForPlayer(Player $player, string $gameType = '*'): array
{
$agentNodeId = (int) $player->agent_node_id;
if ($agentNodeId <= 0) {
throw new \InvalidArgumentException('player_missing_agent');
}
$pathIds = [];
$chainCodes = [];
$totalShares = [];
$nodeId = $agentNodeId;
while ($nodeId > 0) {
$node = AgentNode::query()->find($nodeId);
if ($node === null) {
break;
}
array_unshift($pathIds, (int) $node->id);
$profile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$code = (string) $node->code;
$chainCodes[] = $code;
$totalShares[$code] = (float) ($profile?->total_share_rate ?? 0);
$nodeId = (int) ($node->parent_id ?? 0);
}
$orderedBottomUp = $chainCodes;
$actual = $this->resolveActualShares($totalShares, $orderedBottomUp);
$rebate = $this->resolvePlayerRebateRate((int) $player->id, $agentNodeId, $gameType);
return [
'agent_node_id' => $agentNodeId,
'agent_path' => $pathIds,
'chain_codes' => $orderedBottomUp,
'total_shares' => $totalShares,
'actual_shares' => $actual,
'rebate_rate' => $rebate['rebate_rate'],
'extra_rebate_rate' => $rebate['extra_rebate_rate'],
];
}
/**
* @param array<string, float> $totalShares
* @param list<string> $orderedBottomUp
* @return array<string, float>
*/
private function resolveActualShares(array $totalShares, array $orderedBottomUp): array
{
$actual = [];
$prev = 0.0;
foreach ($orderedBottomUp as $code) {
$total = (float) ($totalShares[$code] ?? 0);
$actual[$code] = max(0, $total - $prev);
$prev = $total;
}
$actual['platform'] = max(0, 100 - $prev);
return $actual;
}
/**
* @return array{rebate_rate: float, extra_rebate_rate: float}
*/
private function resolvePlayerRebateRate(int $playerId, int $agentNodeId, string $gameType = '*'): array
{
$gameType = trim($gameType) !== '' ? trim($gameType) : '*';
$row = DB::table('player_rebate_profiles')
->where('player_id', $playerId)
->where('game_type', $gameType)
->first();
if ($row === null && $gameType !== '*') {
$row = DB::table('player_rebate_profiles')
->where('player_id', $playerId)
->where('game_type', '*')
->first();
}
if ($row !== null && ! (bool) $row->inherit_from_agent) {
return [
'rebate_rate' => (float) $row->rebate_rate,
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
];
}
$profile = AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();
return [
'rebate_rate' => (float) ($profile?->default_player_rebate ?? 0),
'extra_rebate_rate' => 0.0,
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Support\CreditAmountScale;
use App\Models\TicketItem;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
final class GameSettlementReversalService
{
public function reverseTicketItem(TicketItem $item): void
{
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
if ($ledger === null) {
return;
}
$settledAt = now();
DB::transaction(function () use ($item, $ledger, $settledAt): void {
DB::table('share_ledger')->insert([
'ticket_item_id' => $item->id,
'player_id' => $ledger->player_id,
'agent_node_id' => $ledger->agent_node_id,
'agent_path' => $ledger->agent_path,
'share_snapshot' => $ledger->share_snapshot,
'game_win_loss' => -1 * (int) $ledger->game_win_loss,
'basic_rebate' => -1 * (int) $ledger->basic_rebate,
'shared_net_win_loss' => -1 * (int) $ledger->shared_net_win_loss,
'allocations_json' => $ledger->allocations_json,
'reversal_of_id' => $ledger->id,
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$rebates = DB::table('rebate_records')
->where('ticket_item_id', $item->id)
->where('status', 'accrued')
->get();
foreach ($rebates as $rebate) {
DB::table('rebate_records')->insert([
'player_id' => $rebate->player_id,
'ticket_item_id' => $item->id,
'game_type' => $rebate->game_type,
'valid_bet_amount' => $rebate->valid_bet_amount,
'rebate_rate' => $rebate->rebate_rate,
'rebate_amount' => -1 * (int) $rebate->rebate_amount,
'rebate_type' => $rebate->rebate_type,
'owner_agent_id' => $rebate->owner_agent_id,
'status' => 'reversed',
'reversal_of_id' => $rebate->id,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
DB::table('rebate_records')->where('id', $rebate->id)->update(['status' => 'reversed']);
}
$player = Player::query()->find((int) $ledger->player_id);
if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) {
$playerId = (int) $ledger->player_id;
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
if ($row !== null) {
$deltaMinor = (int) $ledger->game_win_loss;
$deltaMajor = CreditAmountScale::minorToMajor(
$deltaMinor,
(string) $player->default_currency,
);
DB::table('player_credit_accounts')
->where('player_id', $playerId)
->update([
'used_credit' => max(0, (int) $row->used_credit - $deltaMajor),
'updated_at' => $settledAt,
]);
}
}
});
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
/**
* 账期关账:回水计提 in_bill并写入 rebate_allocations§7、§9.3)。
*/
final class PeriodCloseRebateService
{
/**
* @return array{dispatched: int, allocations: int}
*/
public function dispatchAndAllocate(
int $periodId,
string $periodStart,
string $periodEnd,
): array {
$rebateIds = $this->dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd);
$allocationCount = $this->buildAllocations($periodId, $rebateIds);
return [
'dispatched' => count($rebateIds),
'allocations' => $allocationCount,
];
}
/**
* @return list<int>
*/
private function dispatchAccruedToPeriod(int $periodId, string $periodStart, string $periodEnd): array
{
$ids = DB::table('rebate_records as rr')
->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id')
->join('share_ledger as sl', function ($join): void {
$join->on('sl.ticket_item_id', '=', 'ti.id')
->whereNull('sl.reversal_of_id');
})
->where('rr.status', 'accrued')
->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->pluck('rr.id')
->map(fn ($id): int => (int) $id)
->all();
if ($ids === []) {
return [];
}
DB::table('rebate_records')
->whereIn('id', $ids)
->update([
'settlement_period_id' => $periodId,
'status' => 'in_bill',
'updated_at' => now(),
]);
return $ids;
}
/**
* @param list<int> $rebateIds
*/
private function buildAllocations(int $periodId, array $rebateIds): int
{
if ($rebateIds === []) {
return 0;
}
$playerBills = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'player')
->get()
->keyBy('owner_id');
$count = 0;
$rebates = DB::table('rebate_records')
->whereIn('id', $rebateIds)
->where('status', 'in_bill')
->get();
foreach ($rebates as $rebate) {
$playerId = (int) $rebate->player_id;
$bill = $playerBills->get($playerId);
$billId = $bill !== null ? (int) $bill->id : null;
if ((string) $rebate->rebate_type === 'extra') {
$count += $this->insertExtraAllocation($rebate, $billId);
continue;
}
$count += $this->insertBasicShareAllocations($rebate, $billId);
}
return $count;
}
private function insertExtraAllocation(object $rebate, ?int $billId): int
{
$agentId = (int) ($rebate->owner_agent_id ?? 0);
if ($agentId <= 0) {
return 0;
}
DB::table('rebate_allocations')->insert([
'rebate_record_id' => (int) $rebate->id,
'settlement_bill_id' => $billId,
'participant_type' => 'agent',
'participant_id' => $agentId,
'actual_share_rate' => 0,
'allocated_amount' => (int) $rebate->rebate_amount,
'allocation_rule' => 'owner',
'created_at' => now(),
'updated_at' => now(),
]);
return 1;
}
private function insertBasicShareAllocations(object $rebate, ?int $billId): int
{
$ticketItemId = (int) ($rebate->ticket_item_id ?? 0);
if ($ticketItemId <= 0) {
return 0;
}
$ledger = DB::table('share_ledger')
->where('ticket_item_id', $ticketItemId)
->whereNull('reversal_of_id')
->orderByDesc('id')
->first();
if ($ledger === null) {
return 0;
}
$snapshot = $this->decodeSnapshot($ledger->share_snapshot);
if ($snapshot === null) {
return 0;
}
$amount = (int) $rebate->rebate_amount;
if ($amount <= 0) {
return 0;
}
$shares = $snapshot['actual_shares'];
$rows = [];
$allocatedSum = 0;
$participants = [];
foreach ($shares as $code => $rate) {
if ($code === 'platform') {
$participants[] = ['type' => 'platform', 'id' => 0, 'rate' => (float) $rate];
continue;
}
$node = AgentNode::query()->where('code', (string) $code)->first();
if ($node === null) {
continue;
}
$participants[] = ['type' => 'agent', 'id' => (int) $node->id, 'rate' => (float) $rate];
}
foreach ($participants as $index => $p) {
$isLast = $index === count($participants) - 1;
$slice = $isLast
? $amount - $allocatedSum
: (int) round($amount * ($p['rate'] / 100), 0, PHP_ROUND_HALF_UP);
$allocatedSum += $slice;
$rows[] = [
'rebate_record_id' => (int) $rebate->id,
'settlement_bill_id' => $billId,
'participant_type' => (string) $p['type'],
'participant_id' => (int) $p['id'],
'actual_share_rate' => $p['rate'],
'allocated_amount' => $slice,
'allocation_rule' => 'share',
'created_at' => now(),
'updated_at' => now(),
];
}
if ($rows !== []) {
DB::table('rebate_allocations')->insert($rows);
}
return count($rows);
}
/**
* @return array{actual_shares: array<string, float>}|null
*/
private function decodeSnapshot(mixed $raw): ?array
{
if ($raw === null || $raw === '') {
return null;
}
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
if (! is_array($decoded)) {
return null;
}
$actual = $decoded['actual_shares'] ?? null;
if (! is_array($actual) || $actual === []) {
return null;
}
$shares = [];
foreach ($actual as $code => $rate) {
$shares[(string) $code] = (float) $rate;
}
return ['actual_shares' => $shares];
}
public function markRebatesSettledForBill(int $billId): void
{
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null || (string) $bill->bill_type !== 'player') {
return;
}
DB::table('rebate_records')
->where('player_id', (int) $bill->owner_id)
->where('settlement_period_id', (int) $bill->settlement_period_id)
->where('status', 'in_bill')
->update([
'status' => 'settled',
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
/**
* 账期汇总尾差归平台§21.8)。
*/
final class PlatformRoundingAdjuster
{
/**
* @param array{players: array<int, array<string, mixed>>, agent_edges: array<string, int>, agent_subtrees: array<int, array<string, mixed>>} $aggregate
*/
public function apply(int $periodId, array $aggregate): int
{
$playerNet = 0;
foreach ($aggregate['players'] as $row) {
$playerNet += (int) $row['net_amount'];
}
$shareProfitTotal = 0;
foreach ($aggregate['agent_subtrees'] as $subtree) {
$shareProfitTotal += (int) ($subtree['share_profit'] ?? 0);
}
$shareProfitTotal += (int) ($aggregate['platform_share_profit'] ?? 0);
$diff = $playerNet - $shareProfitTotal;
if ($diff === 0) {
return 0;
}
$platformBill = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'agent')
->where('counterparty_type', 'platform')
->orderBy('id')
->first();
if ($platformBill === null) {
return 0;
}
$net = (int) $platformBill->net_amount + $diff;
DB::table('settlement_bills')->where('id', (int) $platformBill->id)->update([
'platform_rounding_adjustment' => $diff,
'net_amount' => $net,
'unpaid_amount' => abs($net),
'meta_json' => json_encode(array_merge(
$this->decodeMeta($platformBill->meta_json),
['platform_rounding_adjustment' => $diff],
)),
'updated_at' => now(),
]);
return $diff;
}
/**
* @return array<string, mixed>
*/
private function decodeMeta(mixed $raw): array
{
if ($raw === null || $raw === '') {
return [];
}
$decoded = is_string($raw) ? json_decode($raw, true) : $raw;
return is_array($decoded) ? $decoded : [];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AgentNode;
use Illuminate\Support\Facades\DB;
final class SettlementBillGenerator
{
/**
* @param array{
* players: array<int, array<string, mixed>>,
* agent_edges: array<string, int>,
* agent_subtrees: array<int, array<string, mixed>>,
* platform_share_profit?: int,
* } $aggregate
* @return list<int> bill ids
*/
public function generate(int $periodId, int $adminSiteId, array $aggregate): array
{
$billIds = [];
$now = now();
$subtrees = $aggregate['agent_subtrees'] ?? [];
foreach ($aggregate['players'] as $playerId => $row) {
$net = (int) $row['net_amount'];
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $playerId,
'counterparty_type' => 'agent',
'counterparty_id' => (int) $row['agent_node_id'],
'gross_win_loss' => (int) $row['game_win_loss'],
'rebate_amount' => (int) $row['basic_rebate'] + (int) $row['extra_rebate'],
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => $net,
'paid_amount' => 0,
'unpaid_amount' => abs($net),
'status' => 'pending_confirm',
'created_at' => $now,
'updated_at' => $now,
]);
}
foreach ($aggregate['agent_edges'] as $edge => $amount) {
if ($amount === 0 || str_starts_with($edge, 'P_to_')) {
continue;
}
$parsed = $this->parseEdge($edge);
if ($parsed === null) {
continue;
}
[$fromType, $fromId, $toType, $toId] = $parsed;
$subtree = $subtrees[$fromId] ?? null;
$billIds[] = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => $fromType,
'owner_id' => $fromId,
'counterparty_type' => $toType,
'counterparty_id' => $toId,
'gross_win_loss' => (int) ($subtree['gross_win_loss'] ?? 0),
'rebate_amount' => (int) ($subtree['basic_rebate'] ?? 0) + (int) ($subtree['extra_rebate'] ?? 0),
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => $amount,
'paid_amount' => 0,
'unpaid_amount' => $amount,
'status' => 'pending_confirm',
'meta_json' => json_encode([
'edge' => $edge,
'share_profit' => (int) ($subtree['share_profit'] ?? 0),
'player_count' => (int) ($subtree['player_count'] ?? 0),
'platform_share_profit' => $toType === 'platform' ? (int) ($aggregate['platform_share_profit'] ?? 0) : null,
]),
'created_at' => $now,
'updated_at' => $now,
]);
}
return $billIds;
}
/**
* @return array{0: string, 1: int, 2: string, 3: int}|null
*/
private function parseEdge(string $edge): ?array
{
if (preg_match('/^P_to_(.+)$/', $edge, $m)) {
$agent = AgentNode::query()->where('code', $m[1])->first();
if ($agent === null) {
return null;
}
return ['agent', (int) $agent->id, 'player', 0];
}
if (preg_match('/^(.+)_to_platform$/', $edge, $m)) {
$agent = AgentNode::query()->where('code', $m[1])->first();
if ($agent === null) {
return null;
}
return ['agent', (int) $agent->id, 'platform', 0];
}
if (preg_match('/^(.+)_to_(.+)$/', $edge, $m)) {
$from = AgentNode::query()->where('code', $m[1])->first();
$to = AgentNode::query()->where('code', $m[2])->first();
if ($from === null || $to === null) {
return null;
}
return ['agent', (int) $from->id, 'agent', (int) $to->id];
}
return null;
}
}

View File

@@ -0,0 +1,598 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\AdminUser;
use App\Support\AdminDataScope;
use App\Support\AdminAgentSettlementScope;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 结算中心统一账务流水credit_ledger + 收付 + 调账)。 */
final class SettlementCenterLedgerService
{
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listUnified(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
SettlementLedgerListFilters $filters = new SettlementLedgerListFilters,
): array {
$periodId = $filters->settlementPeriodId;
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
$items = [];
$includeCredit = $this->includeEntryKind($filters, 'credit');
$includePayment = $this->includeEntryKind($filters, 'payment');
$includeAdjustment = $this->includeEntryKind($filters, 'adjustment');
if ($includeCredit) {
$creditRows = $this->fetchCreditRows($admin, $siteCode, $range, $filters->playerId);
foreach ($creditRows as $row) {
$pid = (int) $row->player_id;
$bill = $playerBills[$pid] ?? null;
$items[] = $this->formatCreditEntry($row, $bill);
}
}
if ($includePayment) {
foreach ($this->fetchPaymentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
$items[] = $this->formatPaymentEntry($row);
}
}
if ($includeAdjustment) {
foreach ($this->fetchAdjustmentRows($admin, $siteCode, $periodId, $filters->playerId) as $row) {
if ($filters->badDebtOnly && (string) $row->adjustment_type !== 'bad_debt') {
continue;
}
$items[] = $this->formatAdjustmentEntry($row);
}
}
$items = $this->applyFilters($items, $filters);
usort($items, static function (array $a, array $b): int {
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
});
$total = count($items);
$offset = max(0, ($page - 1) * $perPage);
$pageItems = array_slice($items, $offset, $perPage);
return [
'items' => array_values($pageItems),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'ledger_source' => 'settlement_ledger',
];
}
/**
* @return array{0: Carbon|null, 1: Carbon|null}
*/
private function includeEntryKind(SettlementLedgerListFilters $filters, string $kind): bool
{
if ($filters->badDebtOnly) {
return $kind === 'adjustment';
}
$selected = $filters->entryKind;
if ($selected === null || $selected === '' || $selected === 'all') {
return true;
}
return $selected === $kind;
}
/**
* @param list<array<string, mixed>> $items
* @return list<array<string, mixed>>
*/
private function applyFilters(array $items, SettlementLedgerListFilters $filters): array
{
return array_values(array_filter($items, function (array $row) use ($filters): bool {
if ($filters->badDebtOnly) {
if (($row['entry_kind'] ?? '') !== 'adjustment' || ($row['biz_type'] ?? '') !== 'bad_debt') {
return false;
}
} elseif ($filters->entryKind === 'adjustment') {
if (($row['entry_kind'] ?? '') === 'adjustment' && ($row['biz_type'] ?? '') === 'bad_debt') {
return false;
}
}
if ($filters->txnNo !== null) {
$needle = strtolower($filters->txnNo);
$hay = strtolower((string) ($row['txn_no'] ?? ''));
if (! str_contains($hay, $needle)) {
return false;
}
}
if ($filters->playerAccount !== null) {
$needle = strtolower($filters->playerAccount);
$haystack = strtolower(implode(' ', array_filter([
(string) ($row['username'] ?? ''),
(string) ($row['nickname'] ?? ''),
(string) ($row['site_player_id'] ?? ''),
])));
if (! str_contains($haystack, $needle)) {
return false;
}
}
if ($filters->bizType !== null && ($row['biz_type'] ?? '') !== $filters->bizType) {
return false;
}
if ($filters->billStatus !== null && ($row['bill_status'] ?? '') !== $filters->billStatus) {
return false;
}
if ($filters->actionableOnly) {
$actions = $row['available_actions'] ?? [];
$operational = array_filter(
$actions,
static fn (string $a): bool => ! in_array($a, ['view_player', 'view_bill'], true),
);
if ($operational === []) {
return false;
}
}
return true;
}));
}
private function resolveCreatedRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
if ($period === null) {
return null;
}
return [
Carbon::parse($period->period_start)->startOfDay(),
Carbon::parse($period->period_end)->endOfDay(),
];
}
$from = $createdFrom !== null && $createdFrom !== ''
? Carbon::parse($createdFrom)->startOfDay()
: null;
$to = $createdTo !== null && $createdTo !== ''
? Carbon::parse($createdTo)->endOfDay()
: null;
if ($from === null && $to === null) {
return null;
}
return [
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
$to ?? Carbon::now()->endOfDay(),
];
}
/**
* @return array<int, object>
*/
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
{
$query = DB::table('settlement_bills as sb')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('sb.bill_type', 'player')
->select([
'sb.id',
'sb.owner_id as player_id',
'sb.status',
'sb.bill_type',
'sb.net_amount',
'sb.unpaid_amount',
'sb.paid_amount',
'sb.settlement_period_id',
])
->orderByDesc('sb.id');
if ($periodId !== null && $periodId > 0) {
abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403);
$query->where('sb.settlement_period_id', $periodId);
}
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$map = [];
foreach ($query->limit(500)->get() as $bill) {
$pid = (int) $bill->player_id;
if (! isset($map[$pid])) {
$map[$pid] = $bill;
continue;
}
$existing = $map[$pid];
if ((string) $bill->status === 'pending_confirm') {
$map[$pid] = $bill;
} elseif ((string) $existing->status !== 'pending_confirm'
&& (int) $bill->unpaid_amount > 0
&& (int) $existing->unpaid_amount <= 0) {
$map[$pid] = $bill;
}
}
return $map;
}
/**
* @param array{0: Carbon, 1: Carbon}|null $range
* @return list<object>
*/
private function fetchCreditRows(
AdminUser $admin,
string $siteCode,
?array $range,
?int $playerId,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
])
->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
return $query->limit(500)->get()->all();
}
/**
* @return list<object>
*/
private function fetchPaymentRows(
AdminUser $admin,
string $siteCode,
?int $periodId,
?int $playerId,
): array {
$query = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->select([
'pr.id',
'pr.amount',
'pr.method',
'pr.status',
'pr.created_at',
'pr.settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
])
->orderByDesc('pr.id');
if ($periodId !== null && $periodId > 0) {
$query->where('sb.settlement_period_id', $periodId);
}
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
return [];
}
$query->whereIn('sp.admin_site_id', $siteIds);
}
return $query->limit(300)->get()->all();
}
/**
* @return list<object>
*/
private function fetchAdjustmentRows(
AdminUser $admin,
string $siteCode,
?int $periodId,
?int $playerId,
): array {
$query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id')
->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->select([
'sa.id',
'sa.amount',
'sa.adjustment_type',
'sa.reason',
'sa.created_at',
'sa.original_bill_id as settlement_bill_id',
'sb.status as bill_status',
'sb.bill_type',
'sb.unpaid_amount',
'p.id as player_id',
'p.site_player_id',
'p.username',
'p.nickname',
'p.auth_source',
'p.funding_mode',
'p.default_currency',
])
->orderByDesc('sa.id');
if ($periodId !== null && $periodId > 0) {
$query->where('sa.settlement_period_id', $periodId);
}
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds !== null) {
if ($siteIds === []) {
return [];
}
$query->whereIn('sp.admin_site_id', $siteIds);
}
return $query->limit(300)->get()->all();
}
/**
* @return array<string, mixed>
*/
private function formatCreditEntry(object $row, ?object $bill): array
{
$amount = (int) $row->amount;
$billId = $bill !== null ? (int) $bill->id : null;
return $this->baseRow(
entryKind: 'credit',
entryId: (int) $row->id,
txnPrefix: 'CL',
playerId: (int) $row->player_id,
row: $row,
bizType: (string) $row->reason,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'credit_ledger',
settlementBillId: $billId,
billStatus: $bill !== null ? (string) $bill->status : null,
billType: $bill !== null ? (string) $bill->bill_type : null,
billUnpaid: $bill !== null ? (int) $bill->unpaid_amount : null,
);
}
/**
* @return array<string, mixed>
*/
private function formatPaymentEntry(object $row): array
{
$amount = (int) $row->amount;
return $this->baseRow(
entryKind: 'payment',
entryId: (int) $row->id,
txnPrefix: 'PAY',
playerId: (int) $row->player_id,
row: $row,
bizType: 'payment_record',
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'payment_record',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: 'bill#'.$row->settlement_bill_id.($row->method ? ' · '.$row->method : ''),
);
}
/**
* @return array<string, mixed>
*/
private function formatAdjustmentEntry(object $row): array
{
$amount = (int) $row->amount;
$type = (string) $row->adjustment_type;
return $this->baseRow(
entryKind: 'adjustment',
entryId: (int) $row->id,
txnPrefix: 'ADJ',
playerId: (int) $row->player_id,
row: $row,
bizType: $type,
signedAmount: $amount,
createdAt: $row->created_at,
ledgerSource: 'settlement_adjustment',
settlementBillId: (int) $row->settlement_bill_id,
billStatus: (string) ($row->bill_status ?? ''),
billType: (string) ($row->bill_type ?? ''),
billUnpaid: isset($row->unpaid_amount) ? (int) $row->unpaid_amount : null,
refLabel: $row->reason !== null && $row->reason !== ''
? (string) $row->reason
: 'bill#'.$row->settlement_bill_id,
);
}
/**
* @return array<string, mixed>
*/
private function baseRow(
string $entryKind,
int $entryId,
string $txnPrefix,
int $playerId,
object $row,
string $bizType,
int $signedAmount,
mixed $createdAt,
string $ledgerSource,
?int $settlementBillId,
?string $billStatus,
?string $billType,
?int $billUnpaid,
?string $refLabel = null,
): array {
$amountAbs = abs($signedAmount);
$currency = (string) ($row->default_currency ?? '');
return [
'entry_kind' => $entryKind,
'id' => $entryId,
'row_key' => $entryKind.'-'.$entryId,
'txn_no' => $txnPrefix.'-'.$entryId,
'player_id' => $playerId,
'site_code' => $row->site_code ?? null,
'site_player_id' => $row->site_player_id ?? null,
'username' => $row->username ?? null,
'nickname' => $row->nickname ?? null,
'biz_type' => $bizType,
'biz_no' => $refLabel ?? $this->creditRefLabel($row),
'direction' => $signedAmount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $signedAmount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $createdAt !== null ? Carbon::parse($createdAt)->toIso8601String() : null,
'ledger_source' => $ledgerSource,
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source ?? null,
'settlement_bill_id' => $settlementBillId,
'bill_status' => $billStatus,
'bill_type' => $billType,
'bill_unpaid_amount' => $billUnpaid,
'available_actions' => $this->resolveActions(
$entryKind,
$settlementBillId,
$billStatus,
$billType,
$billUnpaid,
),
];
}
/**
* @return list<string>
*/
private function resolveActions(
string $entryKind,
?int $billId,
?string $billStatus,
?string $billType,
?int $billUnpaid,
): array {
$actions = ['view_player'];
if ($billId === null || $billId <= 0) {
return $actions;
}
$actions[] = 'view_bill';
if ($billStatus === 'pending_confirm') {
$actions[] = 'confirm';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
&& ($billUnpaid ?? 0) > 0) {
$actions[] = 'payment';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
$actions[] = 'adjustment';
$actions[] = 'reversal';
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
&& ($billUnpaid ?? 0) > 0
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
$actions[] = 'bad_debt';
}
return array_values(array_unique($actions));
}
private function creditRefLabel(object $row): ?string
{
if (! isset($row->ref_type) || $row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Services\AgentSettlement;
/** 结算中心账务流水列表筛选。 */
final class SettlementLedgerListFilters
{
public function __construct(
public readonly ?int $settlementPeriodId = null,
public readonly ?int $playerId = null,
public readonly ?string $entryKind = null,
public readonly ?string $txnNo = null,
public readonly ?string $playerAccount = null,
public readonly ?string $bizType = null,
public readonly ?string $billStatus = null,
public readonly bool $actionableOnly = false,
public readonly ?string $createdFrom = null,
public readonly ?string $createdTo = null,
public readonly bool $badDebtOnly = false,
) {}
public static function fromQuery(array $query): self
{
$playerId = (int) ($query['player_id'] ?? 0);
return new self(
settlementPeriodId: self::positiveInt($query['settlement_period_id'] ?? null),
playerId: $playerId > 0 ? $playerId : null,
entryKind: self::nonEmptyString($query['entry_kind'] ?? null),
txnNo: self::nonEmptyString($query['txn_no'] ?? null),
playerAccount: self::nonEmptyString($query['player_account'] ?? null),
bizType: self::nonEmptyString($query['reason'] ?? $query['biz_type'] ?? null),
billStatus: self::nonEmptyString($query['bill_status'] ?? null),
actionableOnly: filter_var($query['actionable_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
createdFrom: self::nonEmptyString($query['created_from'] ?? null),
createdTo: self::nonEmptyString($query['created_to'] ?? null),
badDebtOnly: filter_var($query['bad_debt_only'] ?? false, FILTER_VALIDATE_BOOLEAN),
);
}
private static function positiveInt(mixed $value): ?int
{
$id = (int) $value;
return $id > 0 ? $id : null;
}
private static function nonEmptyString(mixed $value): ?string
{
$s = trim((string) $value);
return $s !== '' ? $s : null;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Support\Facades\DB;
final class SettlementPaymentService
{
public function __construct(
private readonly AgentSettlementBillGuard $billGuard,
private readonly PlayerCreditService $playerCreditService,
private readonly PeriodCloseRebateService $periodCloseRebate,
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
public function confirmBill(int $billId): void
{
$this->billGuard->markConfirmed($billId);
}
/**
* @param array{method?: string|null, proof?: string|null, remark?: string|null} $meta
*/
public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void
{
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$this->billGuard->assertPeriodMutable($billId);
$amount = min($amount, (int) $bill->unpaid_amount);
if ($amount <= 0) {
return;
}
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => (string) $bill->owner_type,
'payer_id' => (int) $bill->owner_id,
'payee_type' => (string) $bill->counterparty_type,
'payee_id' => (int) $bill->counterparty_id,
'amount' => $amount,
'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null,
'remark' => $meta['remark'] ?? null,
'status' => 'confirmed',
'created_by' => $adminUserId,
'confirmed_by' => $adminUserId,
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$newPaid = (int) $bill->paid_amount + $amount;
$newUnpaid = max(0, (int) $bill->unpaid_amount - $amount);
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
DB::table('settlement_bills')->where('id', $billId)->update([
'paid_amount' => $newPaid,
'unpaid_amount' => $newUnpaid,
'status' => $status,
'updated_at' => now(),
]);
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
if ($status === 'settled') {
$this->periodCloseRebate->markRebatesSettledForBill($billId);
}
}
}
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services\AgentSettlement;
use Illuminate\Support\Facades\DB;
final class UnsettledTicketPeriodWarning
{
/**
* @return array{count: int, ticket_item_ids: list<int>}
*/
public function countForSite(int $adminSiteId, string $periodStart, string $periodEnd): array
{
$siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
$rows = DB::table('ticket_items as ti')
->join('players as p', 'p.id', '=', 'ti.player_id')
->where('p.site_code', $siteCode)
->whereIn('ti.status', ['pending_draw', 'pending_confirm', 'pending_payout'])
->whereBetween('ti.created_at', [$periodStart, $periodEnd])
->pluck('ti.id')
->map(fn ($id): int => (int) $id)
->all();
return [
'count' => count($rows),
'ticket_item_ids' => array_slice($rows, 0, 20),
];
}
}

View File

@@ -3,6 +3,9 @@
namespace App\Services\Player; namespace App\Services\Player;
use App\Models\Player; use App\Models\Player;
use App\Support\AgentOverdueGuard;
use App\Support\CreditAmountScale;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,19 +17,33 @@ final class PlayerCreditService
public function upsertAccount(Player $player, array $payload): void public function upsertAccount(Player $player, array $payload): void
{ {
$limit = max(0, (int) ($payload['credit_limit'] ?? 0)); $limit = max(0, (int) ($payload['credit_limit'] ?? 0));
$now = now();
$exists = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->exists();
DB::table('player_credit_accounts')->updateOrInsert( if ($exists) {
['player_id' => $player->id], DB::table('player_credit_accounts')
[ ->where('player_id', $player->id)
'credit_limit' => $limit, ->update([
'used_credit' => DB::raw('COALESCE(used_credit, 0)'), 'credit_limit' => $limit,
'frozen_credit' => DB::raw('COALESCE(frozen_credit, 0)'), 'updated_at' => $now,
'updated_at' => now(), ]);
'created_at' => now(),
], return;
); }
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => $limit,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
} }
/** 可用授信(主货币整数,与后台「授信额度」一致)。 */
public function availableCredit(Player $player): int public function availableCredit(Player $player): int
{ {
$row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first(); $row = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
@@ -37,34 +54,45 @@ final class PlayerCreditService
return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit); return max(0, (int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit);
} }
public function holdForBet(Player $player, int $amount): void /** 可用授信(最小货币单位,供玩家端钱包/下注与钱包余额 API 对齐)。 */
public function availableCreditMinor(Player $player, ?string $currencyCode = null): int
{ {
if ($amount <= 0) { $currency = $currencyCode ?? (string) $player->default_currency;
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
}
public function holdForBet(Player $player, int $amountMinor): void
{
if ($amountMinor <= 0) {
return; return;
} }
if (! \App\Support\CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { if (! PlayerFundingMode::usesCredit($player)) {
return; return;
} }
$available = $this->availableCredit($player); $currency = (string) $player->default_currency;
if ($amount > $available) { $availableMinor = $this->availableCreditMinor($player, $currency);
if ($amountMinor > $availableMinor) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'credit' => ['insufficient'], 'credit' => ['insufficient'],
]); ]);
} }
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
DB::table('player_credit_accounts') DB::table('player_credit_accounts')
->where('player_id', $player->id) ->where('player_id', $player->id)
->update([ ->update([
'used_credit' => DB::raw('used_credit + '.$amount), 'used_credit' => DB::raw('used_credit + '.$majorDelta),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
DB::table('credit_ledger')->insert([ DB::table('credit_ledger')->insert([
'owner_type' => 'player', 'owner_type' => 'player',
'owner_id' => $player->id, 'owner_id' => $player->id,
'amount' => -$amount, 'amount' => -$amountMinor,
'reason' => 'bet_hold', 'reason' => 'bet_hold',
'ref_type' => 'bet', 'ref_type' => 'bet',
'ref_id' => null, 'ref_id' => null,
@@ -73,23 +101,97 @@ final class PlayerCreditService
]); ]);
} }
public function releaseFromSettlement(Player $player, int $amount, int $billId): void public function applySettledLoss(Player $player, int $amountMinor, int $ticketItemId): void
{ {
if ($amount <= 0) { if ($amountMinor <= 0) {
return; return;
} }
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
DB::table('player_credit_accounts') DB::table('player_credit_accounts')
->where('player_id', $player->id) ->where('player_id', $player->id)
->update([ ->update([
'used_credit' => DB::raw('GREATEST(0, used_credit - '.$amount.')'), 'used_credit' => DB::raw('used_credit + '.$majorDelta),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
DB::table('credit_ledger')->insert([ DB::table('credit_ledger')->insert([
'owner_type' => 'player', 'owner_type' => 'player',
'owner_id' => $player->id, 'owner_id' => $player->id,
'amount' => $amount, 'amount' => -$amountMinor,
'reason' => 'game_settlement_loss',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function assertMayPlaceBet(Player $player, int $amountMinor): void
{
if (! PlayerFundingMode::usesCredit($player)) {
return;
}
$overdue = DB::table('settlement_bills')
->where('owner_type', 'player')
->where('owner_id', $player->id)
->where('status', 'overdue')
->where('unpaid_amount', '>', 0)
->exists();
if ($overdue) {
throw ValidationException::withMessages([
'credit' => ['overdue'],
]);
}
$agentNodeId = (int) ($player->agent_node_id ?? 0);
if ($agentNodeId > 0) {
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
}
$this->holdForBet($player, $amountMinor);
}
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
{
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'bet_hold_release',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
{
if ($amountMinor <= 0) {
return;
}
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'settlement_confirm', 'reason' => 'settlement_confirm',
'ref_type' => 'settlement_bill', 'ref_type' => 'settlement_bill',
'ref_id' => $billId, 'ref_id' => $billId,
@@ -97,4 +199,26 @@ final class PlayerCreditService
'updated_at' => now(), 'updated_at' => now(),
]); ]);
} }
private function decreaseUsedCredit(Player $player, int $amountMinor): void
{
if ($amountMinor <= 0) {
return;
}
$playerId = (int) $player->id;
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
if ($row === null) {
return;
}
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
$next = max(0, (int) $row->used_credit - $majorDelta);
DB::table('player_credit_accounts')
->where('player_id', $playerId)
->update([
'used_credit' => $next,
'updated_at' => now(),
]);
}
} }

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Services\Player;
use App\Lottery\ErrorCode;
use App\Models\Player;
use App\Support\PlayerAuthSource;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Hash;
use App\Exceptions\PlayerAuthenticationException;
final class PlayerNativeAuthService
{
/**
* @return array{access_token: string, expires_in: int, token_type: string, player: array<string, mixed>}
*/
public function login(string $siteCode, string $username, string $password): array
{
$username = trim($username);
$siteCode = trim($siteCode);
if ($siteCode === '' || $username === '' || $password === '') {
throw new PlayerAuthenticationException(
'账号或密码错误',
ErrorCode::PlayerCredentialsInvalid->value,
);
}
$player = Player::query()
->where('site_code', $siteCode)
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->first();
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
throw new PlayerAuthenticationException(
'账号或密码错误',
ErrorCode::PlayerCredentialsInvalid->value,
);
}
if ($player->login_locked_until !== null && $player->login_locked_until->isFuture()) {
throw new PlayerAuthenticationException(
'登录已锁定',
ErrorCode::PlayerLoginLocked->value,
403,
);
}
if ((int) $player->status !== 0) {
throw new PlayerAuthenticationException(
'账号已冻结',
ErrorCode::PlayerAccountSuspended->value,
403,
);
}
if (! Hash::check($password, $player->password_hash)) {
$this->recordFailedLogin($player);
throw new PlayerAuthenticationException(
'账号或密码错误',
ErrorCode::PlayerCredentialsInvalid->value,
);
}
$player->forceFill([
'login_failed_count' => 0,
'login_locked_until' => null,
'last_login_at' => now(),
])->save();
$ttl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
$token = $this->issueToken($player, $ttl);
return [
'access_token' => $token,
'expires_in' => $ttl,
'token_type' => 'Bearer',
'player' => [
'id' => (int) $player->id,
'site_code' => $player->site_code,
'username' => $player->username,
'nickname' => $player->nickname,
'funding_mode' => $player->funding_mode,
'auth_source' => $player->auth_source,
],
];
}
public function issueToken(Player $player, ?int $ttlSeconds = null): string
{
$secret = (string) config('lottery.player_auth.native.secret', '');
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$ttl = $ttlSeconds ?? (int) config('lottery.player_auth.native.ttl_seconds', 28800);
$now = time();
$playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id');
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
$payload = [
$playerIdKey => (int) $player->id,
$authKey => PlayerAuthSource::LOTTERY_NATIVE,
'site_code' => (string) $player->site_code,
'iat' => $now,
'exp' => $now + $ttl,
];
return JWT::encode($payload, $secret, (string) config('lottery.player_auth.jwt.algorithm', 'HS256'));
}
private function recordFailedLogin(Player $player): void
{
$max = (int) config('lottery.player_auth.native.max_login_attempts', 8);
$lockMinutes = (int) config('lottery.player_auth.native.lock_minutes', 15);
$count = (int) $player->login_failed_count + 1;
$updates = ['login_failed_count' => $count];
if ($count >= $max) {
$updates['login_locked_until'] = now()->addMinutes($lockMinutes);
$updates['login_failed_count'] = 0;
}
$player->forceFill($updates)->save();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\Player;
use App\Models\AgentNode;
use App\Services\Agent\RebateLimitValidator;
use Illuminate\Support\Facades\DB;
final class PlayerRebateProfileService
{
public function __construct(
private readonly RebateLimitValidator $rebateLimitValidator,
) {}
/**
* @param list<array{game_type: string, rebate_rate?: float, extra_rebate_rate?: float, inherit_from_agent?: bool}> $profiles
*/
public function syncProfiles(int $playerId, AgentNode $agent, array $profiles): void
{
if ($profiles === []) {
return;
}
$now = now();
foreach ($profiles as $row) {
$gameType = trim((string) ($row['game_type'] ?? '*')) ?: '*';
$inherit = (bool) ($row['inherit_from_agent'] ?? false);
$rebateRate = (float) ($row['rebate_rate'] ?? 0);
$extraRate = (float) ($row['extra_rebate_rate'] ?? 0);
if (! $inherit) {
$this->rebateLimitValidator->assertPlayerRebateWithinAgent($agent, $rebateRate, $extraRate);
}
DB::table('player_rebate_profiles')->updateOrInsert(
['player_id' => $playerId, 'game_type' => $gameType],
[
'inherit_from_agent' => $inherit,
'rebate_rate' => $inherit ? 0 : $rebateRate,
'extra_rebate_rate' => $inherit ? 0 : $extraRate,
'updated_at' => $now,
'created_at' => $now,
],
);
}
}
/**
* @return list<array{game_type: string, rebate_rate: float, extra_rebate_rate: float, inherit_from_agent: bool}>
*/
public function listForPlayer(int $playerId): array
{
return DB::table('player_rebate_profiles')
->where('player_id', $playerId)
->orderBy('game_type')
->get()
->map(static fn (object $row): array => [
'game_type' => (string) $row->game_type,
'rebate_rate' => (float) $row->rebate_rate,
'extra_rebate_rate' => (float) $row->extra_rebate_rate,
'inherit_from_agent' => (bool) $row->inherit_from_agent,
])
->all();
}
}

View File

@@ -7,7 +7,9 @@ use Firebase\JWT\Key;
use App\Models\Player; use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\PlayerAuthSource;
use App\Support\PlayerAutoRegistrationDefaults; use App\Support\PlayerAutoRegistrationDefaults;
use App\Support\PlayerFundingMode;
use App\Support\PlayerTokenAesUnwrap; use App\Support\PlayerTokenAesUnwrap;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use App\Exceptions\PlayerAuthenticationException; use App\Exceptions\PlayerAuthenticationException;
@@ -63,26 +65,30 @@ final class PlayerTokenResolver
$player = $this->resolveDevToken($token); $player = $this->resolveDevToken($token);
} else { } else {
$jwtPlain = $this->unwrapOpaqueToJwtString($token); $jwtPlain = $this->unwrapOpaqueToJwtString($token);
$siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain); if ($this->peekAuthSourceFromJwt($jwtPlain) === PlayerAuthSource::LOTTERY_NATIVE) {
if ($siteCode === null) { $player = $this->resolveNativeJwt($jwtPlain);
throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value); } else {
} $siteCode = $this->partnerSiteConfigResolver->peekSiteCodeFromJwt($jwtPlain);
if ($siteCode === null) {
throw new PlayerAuthenticationException('JWT 缺少站点标识', ErrorCode::PlayerTokenInvalid->value);
}
$siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode); $siteConfig = $this->partnerSiteConfigResolver->resolveBySiteCode($siteCode);
if (! $siteConfig->enabled) { if (! $siteConfig->enabled) {
throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403); throw new PlayerAuthenticationException('站点已停用', ErrorCode::PlayerAccountSuspended->value, 403);
} }
$secret = $siteConfig->ssoJwtSecret; $secret = $siteConfig->ssoJwtSecret;
if (! is_string($secret) || $secret === '') { if (! is_string($secret) || $secret === '') {
throw new PlayerAuthenticationException( throw new PlayerAuthenticationException(
'SSO 未配置(站点 '.$siteCode.'', 'SSO 未配置(站点 '.$siteCode.'',
ErrorCode::PlayerSsoSecretNotConfigured->value, ErrorCode::PlayerSsoSecretNotConfigured->value,
503, 503,
); );
} }
$player = $this->resolveJwt($jwtPlain, $secret); $player = $this->resolveSsoJwt($jwtPlain, $secret);
}
} }
$this->assertPlayerActive($player); $this->assertPlayerActive($player);
@@ -127,7 +133,7 @@ final class PlayerTokenResolver
{ {
$jwtPlain = $this->unwrapOpaqueToJwtString($opaque); $jwtPlain = $this->unwrapOpaqueToJwtString($opaque);
return $this->resolveJwt($jwtPlain, $secret); return $this->resolveSsoJwt($jwtPlain, $secret);
} }
/** /**
@@ -150,7 +156,48 @@ final class PlayerTokenResolver
return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token) === 1; return preg_match('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $token) === 1;
} }
private function resolveJwt(string $jwt, string $secret): Player private function resolveNativeJwt(string $jwt): Player
{
$secret = (string) config('lottery.player_auth.native.secret', '');
if ($secret === '') {
throw new PlayerAuthenticationException(
'原生登录未配置',
ErrorCode::PlayerSsoSecretNotConfigured->value,
503,
);
}
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
try {
/** @var object $claims */
$claims = JWT::decode($jwt, new Key($secret, $alg));
} catch (\Throwable) {
throw new PlayerAuthenticationException('Token 无效或已过期', ErrorCode::PlayerTokenInvalid->value);
}
$this->assertNativeJwtTemporalPolicy($claims);
$playerIdKey = (string) config('lottery.player_auth.native.claim_player_id', 'player_id');
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
$playerId = (int) data_get($claims, $playerIdKey, 0);
$authSource = data_get($claims, $authKey);
if ($playerId <= 0 || $authSource !== PlayerAuthSource::LOTTERY_NATIVE) {
throw new PlayerAuthenticationException('JWT 缺少玩家标识', ErrorCode::PlayerTokenInvalid->value);
}
$player = Player::query()->find($playerId);
if ($player === null || ! $player->isLotteryNative()) {
throw new PlayerAuthenticationException('玩家不存在', ErrorCode::PlayerNotRegistered->value);
}
$player->forceFill(['last_login_at' => now()])->save();
return $player->refresh();
}
private function resolveSsoJwt(string $jwt, string $secret): Player
{ {
$alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256'); $alg = (string) config('lottery.player_auth.jwt.algorithm', 'HS256');
@@ -178,6 +225,8 @@ final class PlayerTokenResolver
$now = now(); $now = now();
$defaults = [ $defaults = [
...PlayerAutoRegistrationDefaults::profileFields(), ...PlayerAutoRegistrationDefaults::profileFields(),
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'default_currency' => LotterySettings::defaultCurrency(), 'default_currency' => LotterySettings::defaultCurrency(),
'status' => self::PLAYER_STATUS_ACTIVE, 'status' => self::PLAYER_STATUS_ACTIVE,
'last_login_at' => $now, 'last_login_at' => $now,
@@ -209,6 +258,58 @@ final class PlayerTokenResolver
return $player->refresh(); return $player->refresh();
} }
private function peekAuthSourceFromJwt(string $jwt): ?string
{
$parts = explode('.', trim($jwt));
if (count($parts) !== 3) {
return null;
}
$payload = json_decode($this->base64UrlDecode($parts[1]), true);
if (! is_array($payload)) {
return null;
}
$authKey = (string) config('lottery.player_auth.native.claim_auth_source', 'auth_source');
$value = $payload[$authKey] ?? null;
return is_string($value) ? $value : null;
}
private function base64UrlDecode(string $segment): string
{
$remainder = strlen($segment) % 4;
if ($remainder > 0) {
$segment .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($segment, '-_', '+/'), true);
return is_string($decoded) ? $decoded : '';
}
/**
* @param object $claims
*/
private function assertNativeJwtTemporalPolicy(object $claims): void
{
if (! isset($claims->exp) || ! is_numeric($claims->exp)) {
throw new PlayerAuthenticationException('JWT 缺少过期时间', ErrorCode::PlayerTokenInvalid->value);
}
$maxTtl = (int) config('lottery.player_auth.native.ttl_seconds', 28800);
if (isset($claims->iat) && is_numeric($claims->iat)) {
$iat = (int) $claims->iat;
$exp = (int) $claims->exp;
if ($exp - $iat > $maxTtl) {
throw new PlayerAuthenticationException(
'JWT 有效期超过允许的 '.(string) $maxTtl.' 秒',
ErrorCode::PlayerTokenInvalid->value,
);
}
}
}
/** /**
* 短效 SSOJWT 须有 exp decode 校验),可选要求 iat exp-iat 不得超过配置秒数。 * 短效 SSOJWT 须有 exp decode 校验),可选要求 iat exp-iat 不得超过配置秒数。
* *

View File

@@ -17,6 +17,7 @@ use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Services\Draw\LotteryHallRealtimeBroadcaster;
use App\Services\Ticket\RiskPoolService; use App\Services\Ticket\RiskPoolService;
use App\Services\Jackpot\JackpotBurstAllocator; use App\Services\Jackpot\JackpotBurstAllocator;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
/** /**
* 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 回水派彩调整 Jackpot 爆池分配 明细 风险池释放 待审核)。 * 阶段 6:对已发布开奖、处于 `settling` 的期号执行结算(匹配 回水派彩调整 Jackpot 爆池分配 明细 风险池释放 待审核)。
@@ -32,6 +33,7 @@ final class SettlementOrchestrator
private readonly RiskPoolService $riskPool, private readonly RiskPoolService $riskPool,
private readonly LotteryHallRealtimeBroadcaster $hallRealtime, private readonly LotteryHallRealtimeBroadcaster $hallRealtime,
private readonly DrawHallSnapshotBuilder $hallSnapshot, private readonly DrawHallSnapshotBuilder $hallSnapshot,
private readonly AgentGameSettlementRecorder $agentGameSettlement,
) {} ) {}
/** /**
@@ -183,13 +185,16 @@ final class SettlementOrchestrator
'match_detail_json' => $p['match_detail'], 'match_detail_json' => $p['match_detail'],
]); ]);
$terminalStatus = $finalCredit > 0 ? 'pending_payout' : 'settled_lose';
$item->forceFill([ $item->forceFill([
'win_amount' => $net, 'win_amount' => $net,
'jackpot_win_amount' => $jackpotShare, 'jackpot_win_amount' => $jackpotShare,
'settled_at' => null, 'settled_at' => null,
'status' => $finalCredit > 0 ? 'pending_payout' : 'settled_lose', 'status' => $terminalStatus,
])->save(); ])->save();
$this->agentGameSettlement->recordForTicketItem($item, $net, $terminalStatus);
if ($finalCredit > 0) { if ($finalCredit > 0) {
$winCount++; $winCount++;
} }

View File

@@ -16,7 +16,7 @@ use App\Exceptions\IdempotentTicketReplayException;
use App\Exceptions\TicketOperationException; use App\Exceptions\TicketOperationException;
use App\Services\Jackpot\JackpotContributionService; use App\Services\Jackpot\JackpotContributionService;
use App\Services\Draw\DrawHallSnapshotBuilder; use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Support\CreditLineMode; use App\Support\PlayerFundingMode;
use App\Services\Player\PlayerCreditService; use App\Services\Player\PlayerCreditService;
final class TicketPlacementService final class TicketPlacementService
@@ -42,8 +42,10 @@ final class TicketPlacementService
? (string) $payload['client_trace_id'] ? (string) $payload['client_trace_id']
: null; : null;
$drawNo = (string) $payload['draw_id']; $drawNo = trim((string) ($payload['draw_id'] ?? ''));
$drawIdForIdempotency = Draw::query()->where('draw_no', $drawNo)->value('id'); $drawIdForIdempotency = $drawNo === ''
? null
: Draw::query()->where('draw_no', $drawNo)->value('id');
if ($clientTraceId !== null && $drawIdForIdempotency !== null) { if ($clientTraceId !== null && $drawIdForIdempotency !== null) {
$existing = TicketOrder::query() $existing = TicketOrder::query()
@@ -75,9 +77,10 @@ final class TicketPlacementService
$payload, $payload,
$expectedVersions, $expectedVersions,
$clientTraceId, $clientTraceId,
$drawNo,
): array { ): array {
$draw = Draw::query() $draw = Draw::query()
->where('draw_no', (string) $payload['draw_id']) ->where('draw_no', $drawNo)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($draw === null) { if ($draw === null) {
@@ -156,20 +159,24 @@ final class TicketPlacementService
); );
} }
$wallet = PlayerWallet::query() $creditLine = PlayerFundingMode::usesCredit($player);
->where('player_id', $player->id)
->where('wallet_type', 'lottery')
->where('currency_code', $currencyCode)
->lockForUpdate()
->first();
if ($wallet !== null && (int) $wallet->status !== 0) {
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value);
}
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0; if (! $creditLine) {
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0); $wallet = PlayerWallet::query()
if ($walletAvailable < $totalActualDeduct) { ->where('player_id', $player->id)
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); ->where('wallet_type', 'lottery')
->where('currency_code', $currencyCode)
->lockForUpdate()
->first();
if ($wallet !== null && (int) $wallet->status !== 0) {
throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value);
}
$walletBalance = $wallet !== null ? (int) $wallet->balance : 0;
$walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0);
if ($walletAvailable < $totalActualDeduct) {
throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value);
}
} }
try { try {
@@ -301,17 +308,17 @@ final class TicketPlacementService
'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm',
])->save(); ])->save();
if (CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { if ($creditLine) {
$this->playerCreditService->holdForBet($player, $successTotalActualDeduct); $this->playerCreditService->assertMayPlaceBet($player, $successTotalActualDeduct);
} else {
$this->ticketWalletService->reserveBetDeduct(
$player,
$currencyCode,
$successTotalActualDeduct,
$order,
);
} }
$this->ticketWalletService->reserveBetDeduct(
$player,
$currencyCode,
$successTotalActualDeduct,
$order,
);
return [ return [
'order' => $order, 'order' => $order,
'draw_id' => (int) $draw->id, 'draw_id' => (int) $draw->id,
@@ -331,13 +338,20 @@ final class TicketPlacementService
$order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail(); $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail();
$draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail();
$creditLine = PlayerFundingMode::usesCredit($player);
try { try {
$balanceAfter = $this->ticketWalletService->finalizeReservedBetDeduct( $balanceAfter = $creditLine
$player, ? $this->playerCreditService->availableCreditMinor(
(string) $placement['currency_code'], $player,
(int) $placement['success_total_actual_deduct'], (string) $placement['currency_code'],
$order, )
); : $this->ticketWalletService->finalizeReservedBetDeduct(
$player,
(string) $placement['currency_code'],
(int) $placement['success_total_actual_deduct'],
$order,
);
DB::transaction(function () use ($order, $draw, $placement): void { DB::transaction(function () use ($order, $draw, $placement): void {
$successfulItems = TicketItem::query() $successfulItems = TicketItem::query()
@@ -381,7 +395,9 @@ final class TicketPlacementService
} }
$order->forceFill(['status' => 'refunded'])->save(); $order->forceFill(['status' => 'refunded'])->save();
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release'); if (! PlayerFundingMode::usesCredit($player)) {
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
}
$this->ticketWalletService->reverseBetDeduct($order); $this->ticketWalletService->reverseBetDeduct($order);
}); });
@@ -516,7 +532,7 @@ final class TicketPlacementService
*/ */
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
{ {
if (! CreditLineMode::isEnabledForSiteCode((string) $player->site_code)) { if (! PlayerFundingMode::usesCredit($player)) {
return $evaluated; return $evaluated;
} }

View File

@@ -22,7 +22,10 @@ final class TicketPreviewService
*/ */
public function preview(array $payload): array public function preview(array $payload): array
{ {
$draw = Draw::query()->where('draw_no', (string) $payload['draw_id'])->first(); $drawNo = trim((string) ($payload['draw_id'] ?? ''));
$draw = $drawNo === ''
? null
: Draw::query()->where('draw_no', $drawNo)->first();
if ($draw === null) { if ($draw === null) {
throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value);
} }

View File

@@ -13,6 +13,7 @@ use App\Services\LotterySettings;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use App\Exceptions\WalletOperationException; use App\Exceptions\WalletOperationException;
use App\Support\PlayerFundingMode;
/** /**
* 主站 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。 * 主站 彩票钱包:转入 / 转出(幂等键 + 流水 + 订单)。
@@ -68,6 +69,7 @@ final class LotteryTransferService
*/ */
public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array public function transferIn(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
{ {
$this->assertWalletFundingMode($player);
$this->assertPositiveAmount($amountMinor); $this->assertPositiveAmount($amountMinor);
$currencyCode = $this->normalizeCurrency($currencyCode); $currencyCode = $this->normalizeCurrency($currencyCode);
$this->assertCurrencyEnabled($currencyCode); $this->assertCurrencyEnabled($currencyCode);
@@ -190,6 +192,7 @@ final class LotteryTransferService
*/ */
public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array public function transferOut(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): array
{ {
$this->assertWalletFundingMode($player);
$this->assertPositiveAmount($amountMinor); $this->assertPositiveAmount($amountMinor);
$currencyCode = $this->normalizeCurrency($currencyCode); $currencyCode = $this->normalizeCurrency($currencyCode);
$this->assertCurrencyEnabled($currencyCode); $this->assertCurrencyEnabled($currencyCode);
@@ -732,6 +735,17 @@ final class LotteryTransferService
} }
} }
private function assertWalletFundingMode(Player $player): void
{
if (PlayerFundingMode::usesCredit($player)) {
throw new WalletOperationException(
'credit_player_no_wallet_transfer',
ErrorCode::WalletCreditPlayerNoTransfer->value,
422,
);
}
}
private function assertPositiveAmount(int $amountMinor): void private function assertPositiveAmount(int $amountMinor): void
{ {
if ($amountMinor < 1) { if ($amountMinor < 1) {

View File

@@ -0,0 +1,578 @@
<?php
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}
*/
final class PlayerLedgerLogsService
{
/** PRD 对外类型 → wallet_txns.biz_type */
private const WALLET_TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
/** PRD 对外类型 → credit_ledger.reason */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold', 'game_settlement_loss'],
'reversal' => ['bet_hold_release'],
'refund' => ['settlement_confirm'],
'prize' => [],
'transfer_in' => [],
'transfer_out' => [],
];
public function __construct(
private readonly PlayerCreditService $playerCreditService,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* funding_mode: string,
* auth_source: string|null,
* }
*/
public function listForPlayerApi(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$meta = [
'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn',
'funding_mode' => (string) ($player->funding_mode ?? ''),
'auth_source' => $player->auth_source,
];
if (PlayerFundingMode::usesCredit($player)) {
$result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw);
return array_merge($result, $meta);
}
$result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw);
return array_merge($result, $meta);
}
/**
* 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger字段形状对齐 wallet_txns 列表。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function listForAdminPlayer(
Player $player,
int $page,
int $perPage,
?string $bizType = null,
): array {
if (! PlayerFundingMode::usesCredit($player)) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$reasonFilter = $bizType !== null && $bizType !== ''
? [trim($bizType)]
: null;
$paginator = $this->creditLedgerQuery($player->id, $reasonFilter)
->paginate($perPage, ['*'], 'page', $page);
$currency = (string) $player->default_currency;
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$amount = (int) $row->amount;
$formatted = $this->formatAdminCreditRow($row, $player, $currency, $runningMinor);
$runningMinor -= $amount;
return $formatted;
})
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listForAdminCreditIndex(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
?int $settlementPeriodId = null,
?int $playerId = null,
?string $reason = null,
?string $createdFrom = null,
?string $createdTo = null,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'cl.updated_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
])
->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
if ($reason !== null && $reason !== '') {
$query->where('cl.reason', $reason);
}
$range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'ledger_source' => 'credit_ledger',
];
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolveCreatedRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
if ($period === null) {
return null;
}
return [
Carbon::parse($period->period_start)->startOfDay(),
Carbon::parse($period->period_end)->endOfDay(),
];
}
$from = $createdFrom !== null && $createdFrom !== ''
? Carbon::parse($createdFrom)->startOfDay()
: null;
$to = $createdTo !== null && $createdTo !== ''
? Carbon::parse($createdTo)->endOfDay()
: null;
if ($from === null && $to === null) {
return null;
}
return [
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
$to ?? Carbon::now()->endOfDay(),
];
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditIndexRow(object $row): array
{
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$currency = (string) ($row->default_currency ?? '');
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $row->player_id,
'site_code' => (string) $row->site_code,
'site_player_id' => $row->site_player_id,
'username' => $row->username,
'nickname' => $row->nickname,
'biz_type' => (string) $row->reason,
'type' => $this->creditReasonToPublicType((string) $row->reason),
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $amount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source,
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateWalletTxns(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$bizFilter = $this->resolveWalletBizFilter($typeFilterRaw);
if (is_array($bizFilter) && $bizFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
if (is_array($reasonFilter) && $reasonFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$paginator = $this->creditLedgerQuery((int) $player->id, $reasonFilter)
->paginate($perPage, ['*'], 'page', $page);
$currency = (string) $player->default_currency;
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$amount = (int) $row->amount;
$formatted = $this->formatPlayerCreditRow($row, $player, $currency, $runningMinor);
$runningMinor -= $amount;
return $formatted;
})
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @param list<string>|null $reasonFilter
*/
private function creditLedgerQuery(int $playerId, ?array $reasonFilter)
{
$query = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->orderByDesc('id');
if ($reasonFilter !== null) {
$query->whereIn('reason', $reasonFilter);
}
return $query;
}
/**
* @return list<string>|null
*/
private function resolveWalletBizFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ);
}
/**
* @return list<string>|null
*/
private function resolveCreditReasonFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
}
/**
* @param array<string, list<string>> $map
* @return list<string>|null
*/
private function resolveTypeFilterMap(string $raw, array $map): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$resolved = [];
foreach ($parts as $part) {
$key = Str::lower($part);
if (! isset($map[$key])) {
continue;
}
foreach ($map[$key] as $value) {
$resolved[] = $value;
}
}
return array_values(array_unique($resolved));
}
/**
* @return array<string, mixed>
*/
private function formatWalletTxnRow(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->walletBizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedWalletAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
];
}
/**
* @return array<string, mixed>
*/
private function formatPlayerCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$publicType = $this->creditReasonToPublicType((string) $row->reason);
return [
'log_id' => 'CL-'.$row->id,
'type' => $publicType,
'biz_type' => (string) $row->reason,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'amount_abs' => $amountAbs,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'ref_id' => $this->creditRefLabel($row),
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$balanceBefore = $amount >= 0
? max(0, $balanceAfterMinor - $amount)
: $balanceAfterMinor + $amountAbs;
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'wallet_id' => null,
'biz_type' => (string) $row->reason,
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'balance_before' => $balanceBefore,
'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore),
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => null,
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
private function creditReasonToPublicType(string $reason): string
{
return match ($reason) {
'bet_hold', 'game_settlement_loss' => 'bet',
'bet_hold_release' => 'reversal',
'settlement_confirm' => 'refund',
default => $reason,
};
}
private function walletBizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedWalletAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
private function creditRefLabel(object $row): ?string
{
if ($row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse($value)->toIso8601String();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,13 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->api(prepend: [ $middleware->api(prepend: [
NegotiateLotteryLocale::class, NegotiateLotteryLocale::class,
]); ]);
$middleware->redirectGuestsTo(static function (Request $request): ?string {
if ($request->is('api/*')) {
return null;
}
return '/login';
});
$middleware->convertEmptyStringsToNull([ $middleware->convertEmptyStringsToNull([
static fn (Request $request): bool => $request->is('api/v1/admin/settings') static fn (Request $request): bool => $request->is('api/v1/admin/settings')
|| $request->is('api/v1/admin/settings/*'), || $request->is('api/v1/admin/settings/*'),
@@ -192,6 +199,10 @@ return Application::configure(basePath: dirname(__DIR__))
->everyMinute() ->everyMinute()
->withoutOverlapping() ->withoutOverlapping()
->onOneServer(); ->onOneServer();
$schedule->command('settlement:mark-overdue-bills --days=7')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer();
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */ /** @see docs/01-界面文档.md §2.1 `draw.countdown` */
if (config('lottery.realtime_hall_countdown', true)) { if (config('lottery.realtime_hall_countdown', true)) {
$schedule->command('lottery:hall-countdown') $schedule->command('lottery:hall-countdown')

View File

@@ -0,0 +1,9 @@
<?php
return [
'credit_limit' => (int) env('AGENT_LINE_DEFAULT_CREDIT_LIMIT', 0),
'total_share_rate' => (float) env('AGENT_LINE_DEFAULT_TOTAL_SHARE_RATE', 100),
'rebate_limit' => (float) env('AGENT_LINE_DEFAULT_REBATE_LIMIT', 0.005),
'default_player_rebate' => (float) env('AGENT_LINE_DEFAULT_PLAYER_REBATE', 0.005),
'settlement_cycle' => env('AGENT_LINE_DEFAULT_SETTLEMENT_CYCLE', 'weekly'),
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'allow_demo_close' => (bool) env('AGENT_SETTLEMENT_ALLOW_DEMO_CLOSE', false),
];

View File

@@ -72,6 +72,14 @@ return [
'aes' => [ 'aes' => [
'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'), 'key_base64' => env('LOTTERY_PLAYER_TOKEN_AES_KEY'),
], ],
'native' => [
'secret' => env('LOTTERY_NATIVE_JWT_SECRET', env('MAIN_SITE_SSO_JWT_SECRET', '')),
'ttl_seconds' => max(300, min(86400, (int) env('LOTTERY_NATIVE_JWT_TTL_SECONDS', 28800))),
'claim_player_id' => 'player_id',
'claim_auth_source' => 'auth_source',
'max_login_attempts' => max(3, (int) env('LOTTERY_NATIVE_LOGIN_MAX_ATTEMPTS', 8)),
'lock_minutes' => max(1, (int) env('LOTTERY_NATIVE_LOGIN_LOCK_MINUTES', 15)),
],
], ],
/* /*

View File

@@ -16,6 +16,10 @@ return new class extends Migration
private const RESOURCE_CODE_PREFIXES = [ private const RESOURCE_CODE_PREFIXES = [
'admin.settlement-bills.', 'admin.settlement-bills.',
'admin.settlement-periods.', 'admin.settlement-periods.',
'admin.settlement-payments.',
'admin.settlement-adjustments.',
'admin.settlement-reports.',
'admin.credit-ledger.',
'admin.agent-lines.', 'admin.agent-lines.',
'admin.agent-nodes.profile.', 'admin.agent-nodes.profile.',
]; ];

View File

@@ -18,10 +18,15 @@ return new class extends Migration
'can_create_player' => true, 'can_create_player' => true,
]); ]);
$nodeService = app(\App\Services\Agent\AgentNodeService::class); \App\Support\AgentDefaultRolePermissions::ensurePlatformAgentRole();
\App\Models\AgentNode::query()->each(static function (\App\Models\AgentNode $node) use ($nodeService): void { \App\Models\AdminUser::query()
$nodeService->syncPrimaryOwnerRoleFromProfile($node); ->whereIn('id', \Illuminate\Support\Facades\DB::table('admin_user_agents')->pluck('admin_user_id'))
}); ->each(static function (\App\Models\AdminUser $user): void {
$agentNodeId = $user->primaryAgentNodeId();
if ($agentNodeId !== null) {
$user->syncPrimaryPlatformAgentRole($agentNodeId);
}
});
} }
public function down(): void public function down(): void

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ticket_items', function (Blueprint $table): void {
$table->foreignId('agent_node_id')->nullable()->after('player_id')->constrained('agent_nodes')->nullOnDelete();
$table->json('share_snapshot')->nullable()->after('rule_snapshot_json');
$table->decimal('agent_rebate_rate_snapshot', 8, 4)->nullable()->after('share_snapshot');
$table->timestamp('agent_settled_at')->nullable()->after('settled_at');
$table->foreignId('agent_settlement_reversal_of_id')->nullable()->after('agent_settled_at')
->constrained('ticket_items')->nullOnDelete();
});
Schema::create('share_ledger', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_item_id')->constrained('ticket_items')->cascadeOnDelete();
$table->foreignId('player_id')->constrained('players')->cascadeOnDelete();
$table->foreignId('agent_node_id')->nullable()->constrained('agent_nodes')->nullOnDelete();
$table->json('agent_path')->nullable();
$table->json('share_snapshot')->nullable();
$table->bigInteger('game_win_loss')->default(0);
$table->bigInteger('basic_rebate')->default(0);
$table->bigInteger('shared_net_win_loss')->default(0);
$table->json('allocations_json')->nullable();
$table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete();
$table->unsignedBigInteger('reversal_of_id')->nullable();
$table->timestamp('settled_at');
$table->timestamps();
$table->index(['settled_at', 'player_id']);
$table->index(['settlement_period_id']);
});
Schema::table('share_ledger', function (Blueprint $table): void {
$table->foreign('reversal_of_id')->references('id')->on('share_ledger')->nullOnDelete();
});
Schema::table('rebate_records', function (Blueprint $table): void {
$table->foreignId('ticket_item_id')->nullable()->after('player_id')->constrained('ticket_items')->nullOnDelete();
$table->foreignId('reversal_of_id')->nullable()->after('ticket_item_id')->constrained('rebate_records')->nullOnDelete();
});
Schema::table('settlement_bills', function (Blueprint $table): void {
$table->timestamp('locked_at')->nullable()->after('confirmed_at');
$table->foreignId('reversed_bill_id')->nullable()->after('locked_at')->constrained('settlement_bills')->nullOnDelete();
$table->json('meta_json')->nullable()->after('reversed_bill_id');
});
Schema::create('settlement_adjustments', function (Blueprint $table): void {
$table->id();
$table->foreignId('settlement_period_id')->nullable()->constrained('settlement_periods')->nullOnDelete();
$table->foreignId('original_bill_id')->nullable()->constrained('settlement_bills')->nullOnDelete();
$table->string('adjustment_type', 32);
$table->bigInteger('amount');
$table->string('reason', 255)->nullable();
$table->foreignId('created_by')->nullable()->constrained('admin_users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settlement_adjustments');
Schema::table('settlement_bills', function (Blueprint $table): void {
$table->dropConstrainedForeignId('reversed_bill_id');
$table->dropColumn(['locked_at', 'meta_json']);
});
Schema::table('rebate_records', function (Blueprint $table): void {
$table->dropConstrainedForeignId('reversal_of_id');
$table->dropConstrainedForeignId('ticket_item_id');
});
Schema::dropIfExists('share_ledger');
Schema::table('ticket_items', function (Blueprint $table): void {
$table->dropConstrainedForeignId('agent_settlement_reversal_of_id');
$table->dropConstrainedForeignId('agent_node_id');
$table->dropColumn(['share_snapshot', 'agent_rebate_rate_snapshot', 'agent_settled_at']);
});
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->string('auth_source', 16)->default('main_site_sso')->after('site_player_id');
$table->string('funding_mode', 16)->default('wallet')->after('auth_source');
$table->string('password_hash', 255)->nullable()->after('username');
$table->unsignedSmallInteger('login_failed_count')->default(0)->after('last_login_at');
$table->timestamp('login_locked_until')->nullable()->after('login_failed_count');
});
DB::table('players')->update([
'auth_source' => 'main_site_sso',
'funding_mode' => 'wallet',
]);
Schema::table('players', function (Blueprint $table): void {
$table->index(['site_code', 'auth_source', 'username'], 'idx_players_site_auth_username');
});
}
public function down(): void
{
Schema::table('players', function (Blueprint $table): void {
$table->dropIndex('idx_players_site_auth_username');
$table->dropColumn([
'auth_source',
'funding_mode',
'password_hash',
'login_failed_count',
'login_locked_until',
]);
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_records', function (Blueprint $table): void {
$table->text('proof')->nullable()->after('method');
$table->string('remark', 255)->nullable()->after('proof');
});
}
public function down(): void
{
Schema::table('payment_records', function (Blueprint $table): void {
$table->dropColumn(['proof', 'remark']);
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use App\Models\AdminRole;
use App\Models\AgentNode;
use App\Support\AgentDefaultRolePermissions;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
AgentNode::query()->each(static function (AgentNode $node): void {
$role = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($role === null) {
return;
}
$role->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
});
}
public function down(): void
{
// 权限包为产品策略,回滚不恢复旧 slug 集合。
}
};

View File

@@ -0,0 +1,63 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\AgentDefaultRolePermissions;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$platformRole = AgentDefaultRolePermissions::ensurePlatformAgentRole();
AgentNode::query()->each(static function (AgentNode $node): void {
$ownerRole = AdminRole::query()
->where('owner_agent_id', $node->id)
->where('slug', 'agent_owner_'.$node->id)
->first();
if ($ownerRole !== null) {
$ownerRole->syncLegacyPermissionSlugs(AgentDefaultRolePermissions::ownerSlugsForNode($node));
}
});
$bindings = DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']);
foreach ($bindings as $binding) {
$adminUserId = (int) $binding->admin_user_id;
$agentNodeId = (int) $binding->agent_node_id;
$user = AdminUser::query()->find($adminUserId);
if ($user === null) {
continue;
}
$agentRoleIds = DB::table('admin_user_agent_roles')
->where('admin_user_id', $adminUserId)
->where('agent_node_id', $agentNodeId)
->pluck('role_id')
->map(static fn ($id): int => (int) $id)
->all();
if ($agentRoleIds === []) {
$ownerId = (int) (AdminRole::query()
->where('owner_agent_id', $agentNodeId)
->where('slug', 'agent_owner_'.$agentNodeId)
->value('id') ?? 0);
if ($ownerId > 0) {
$agentRoleIds = [$ownerId];
}
}
if ($agentRoleIds !== []) {
$user->syncAgentRoleIds($agentNodeId, $agentRoleIds);
}
}
}
public function down(): void
{
// 不回滚权限与 pivot避免经营账号失权。
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settlement_bills', function (Blueprint $table): void {
$table->bigInteger('platform_rounding_adjustment')->default(0)->after('adjustment_amount');
});
Schema::table('players', function (Blueprint $table): void {
$table->json('risk_tags')->nullable()->after('status');
});
Schema::table('agent_nodes', function (Blueprint $table): void {
$table->json('risk_tags')->nullable()->after('status');
});
}
public function down(): void
{
Schema::table('agent_nodes', function (Blueprint $table): void {
$table->dropColumn('risk_tags');
});
Schema::table('players', function (Blueprint $table): void {
$table->dropColumn('risk_tags');
});
Schema::table('settlement_bills', function (Blueprint $table): void {
$table->dropColumn('platform_rounding_adjustment');
});
}
};

View File

@@ -0,0 +1,44 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Support\AgentDefaultRolePermissions;
use App\Support\AgentPlatformRole;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
AgentDefaultRolePermissions::ensurePlatformAgentRole();
$platformRoleId = AgentPlatformRole::id();
foreach (DB::table('admin_user_agents')->get(['admin_user_id', 'agent_node_id']) as $binding) {
$user = AdminUser::query()->find((int) $binding->admin_user_id);
if ($user === null) {
continue;
}
$user->syncPrimaryPlatformAgentRole((int) $binding->agent_node_id);
}
$ownerRoleIds = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_AGENT)
->where('slug', 'like', 'agent_owner_%')
->pluck('id')
->all();
if ($ownerRoleIds !== []) {
DB::table('admin_user_agent_roles')->whereIn('role_id', $ownerRoleIds)->delete();
DB::table('admin_user_site_roles')->whereIn('role_id', $ownerRoleIds)->delete();
DB::table('admin_role_menu_actions')->whereIn('role_id', $ownerRoleIds)->delete();
AdminRole::query()->whereIn('id', $ownerRoleIds)->delete();
}
}
public function down(): void
{
// 不回滚:避免经营账号失权。
}
};

View File

@@ -0,0 +1,17 @@
<?php
use App\Support\PlatformSystemRoles;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
PlatformSystemRoles::ensureAll();
}
public function down(): void
{
// 不回滚内置角色与权限,避免平台/代理账号失权。
}
};

View File

@@ -0,0 +1,94 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
/**
* 信用流水 API 注册到 admin_api_resources已有库增量同步避免 500 api_resource_not_configured
*/
return new class extends Migration
{
/** @var list<string> */
private const RESOURCE_CODES = [
'admin.credit-ledger.index',
'admin.settlement-payments.index',
'admin.settlement-adjustments.index',
'admin.settlement-reports.summary',
'admin.settlement-reports.show',
];
public function up(): void
{
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$byCode = collect(AdminAuthorizationRegistry::resources())->keyBy('code');
foreach (self::RESOURCE_CODES as $code) {
$resource = $byCode->get($code);
if (! is_array($resource)) {
continue;
}
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
foreach (self::RESOURCE_CODES as $code) {
$resourceId = DB::table('admin_api_resources')->where('code', $code)->value('id');
if ($resourceId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->where('api_resource_id', (int) $resourceId)->delete();
DB::table('admin_api_resources')->where('id', (int) $resourceId)->delete();
}
}
};

View File

@@ -2,98 +2,27 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use App\Support\AdminAgentPermissionMenuActionSync; use App\Support\AdminAgentPermissionMenuActionSync;
use App\Support\AdminDrawPermissionMenuActionSync; use App\Support\AdminDrawPermissionMenuActionSync;
use App\Support\AdminPermissionBridge; use App\Support\PlatformSystemRoles;
/** /**
* 后台 RBAC {@see AdminUser::ROLE_SUPER_ADMIN} `config/admin_permissions.php` 对齐 * 后台 RBAC平台固定角色 super_admin / agent
* *
* 演示账号 **admin** / **123456**(仅限非 production * 演示账号 **admin** / **123456**(仅限非 production
*/ */
final class AdminRbacAndUserSeeder extends Seeder final class AdminRbacAndUserSeeder extends Seeder
{ {
/** @param list<string> $legacySlugs */
private function syncRolePermissions(AdminRole $role, array $legacySlugs): void
{
$role->syncLegacyPermissionSlugs($legacySlugs);
}
/** @return list<string> */
private function allCatalogSlugs(): array
{
return AdminPermissionBridge::allLegacySlugs();
}
public function run(): void public function run(): void
{ {
AdminAgentPermissionMenuActionSync::syncMissing(); AdminAgentPermissionMenuActionSync::syncMissing();
AdminDrawPermissionMenuActionSync::syncMissing(); AdminDrawPermissionMenuActionSync::syncMissing();
$super = AdminRole::query()->updateOrCreate( PlatformSystemRoles::ensureAll();
['slug' => AdminUser::ROLE_SUPER_ADMIN],
['code' => AdminUser::ROLE_SUPER_ADMIN, 'name' => '超级管理员'],
);
$this->syncRolePermissions($super, $this->allCatalogSlugs());
$risk = AdminRole::query()->updateOrCreate( $super = PlatformSystemRoles::ensureSuperAdminRole();
['slug' => 'risk_operator'],
['code' => 'risk_operator', 'name' => '风控运营员'],
);
$this->syncRolePermissions($risk, [
'prd.dashboard.view',
'prd.play_switch.manage',
'prd.odds.manage',
'prd.risk_cap.manage',
'prd.rebate.manage',
'prd.jackpot.manage',
'prd.draw_result.manage',
'prd.risk.view',
'prd.risk.manage',
'prd.payout.review',
'prd.tickets.view',
'prd.wallet_reconcile.view',
'prd.audit.view',
'prd.player_freeze.manage',
'prd.report.view',
'prd.report.export',
]);
$finance = AdminRole::query()->updateOrCreate(
['slug' => 'finance'],
['code' => 'finance', 'name' => '财务/对账员'],
);
$this->syncRolePermissions($finance, [
'prd.dashboard.view',
'prd.users.view_finance',
'prd.risk_cap.view',
'prd.rebate.view',
'prd.jackpot.view',
'prd.draw_result.view',
'prd.payout.view',
'prd.tickets.view',
'prd.wallet_reconcile.manage',
'prd.wallet_adjust.manage',
'prd.audit.view',
'prd.report.view',
'prd.report.export',
]);
$cs = AdminRole::query()->updateOrCreate(
['slug' => 'customer_service'],
['code' => 'customer_service', 'name' => '客服人员'],
);
$this->syncRolePermissions($cs, [
'prd.dashboard.view',
'prd.users.view_cs',
'prd.tickets.view',
'prd.draw_result.view',
'prd.wallet_reconcile.view_cs',
'prd.report.view',
]);
$username = 'admin'; $username = 'admin';
AdminUser::query()->updateOrCreate( AdminUser::query()->updateOrCreate(

View File

@@ -46,3 +46,42 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
|---------|--------| |---------|--------|
| `prd.audit.all` / `prd.audit.self` / `prd.audit.finance` | `prd.audit.view` | | `prd.audit.all` / `prd.audit.self` / `prd.audit.finance` | `prd.audit.view` |
| `prd.report.all` / `prd.report.risk` / `prd.report.finance` / `prd.report.player` | `prd.report.view` | | `prd.report.all` / `prd.report.risk` / `prd.report.finance` / `prd.report.player` | `prd.report.view` |
## 按站点开通后台(数据隔离)
所有人登录**同一套** lotteryadmin`POST /api/v1/admin/auth/login`)。登录后 API 按账号绑定自动过滤站点数据,**无需**为每个站点单独部署后台域名。
| 给谁 | 账号形态 | 数据范围来源 | 推荐入口 |
|------|----------|--------------|----------|
| 整条代理线负责人 | **代理经营账号**`admin_user_agents` 主绑定) | `agent_nodes.admin_site_id` + 代理子树 `path` | 超管:**代理线路 → 开通线路** |
| 平台侧站点运营 | **平台账号**(无代理绑定) | `admin_user_site_roles.site_id` | 超管:**系统 → 平台账号**,创建时选择 `admin_site_id` 与角色 |
**菜单权限**(能进哪些页)由角色的 `admin_role_menu_actions` / 反推 `prd.*` 决定;**数据范围**(能看哪些站点的行)由 `admin_user_site_roles.site_id`(平台)或代理绑定(线路)决定,二者独立。
### 路径 A开通线路代理主账号
1. 侧栏 **代理线路 → 开通线路**`prd.agent-line.provision`)。
2. 填写站点 `code`、名称、线路主 **用户名 / 密码**
3. 后端一次性创建:`admin_sites`、根 `agent_nodes``admin_users``admin_user_agents`、平台代理经营角色。
4. 将账号密码交给对方;对方用同一 lotteryadmin 地址登录。
5. 登录后 `auth/me.agent``admin_site_id``site_code`;侧栏隐藏「开通线路」等平台-only 菜单;列表经 `AdminScopePolicy` 收敛到该站点 + 代理子树。
**子账号**:在代理树下 **代理节点 → 管理员**`POST /api/v1/admin/agent-nodes/{id}/admin-users`),站点不变、子树更窄。勿在「系统 → 平台账号」为线路主建号。
### 路径 B平台运营账号单站
1. 平台 **角色管理** 仅有两个内置角色:**超级管理员**(自动拥有全部 `prd.*`,随 `lottery:admin-auth-sync` 补齐)与 **代理**(经营主账号默认模板,可在此调整 `prd.*`)。若需更细的平台运营分工,请使用不同平台账号并绑定 **代理** 角色后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**`admin_site_id`),勾选上一步角色。
3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。
改角色绑定时须带上同一 `admin_site_id``PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot不影响其他站点绑定。
### 运行时过滤(已实现)
- 可访问站点:`AdminUser::accessibleAdminSiteIds()`(超管 `null` = 不限)。
- 查询:`AdminSiteScope` + `AdminScopePolicy``site_scope ∩ agent_subtree_scope`)。
- 操作者授权站点:`AdminIntegrationSiteAccess::canAccess()`(创建/改绑平台用户时校验)。
### 临时手工绑站(迁移前)
若 UI 未就绪,超管建用户后可在库表 `admin_user_site_roles` 插入 `(admin_user_id, site_id, role_id)`,参考 `tests/Feature/AdminIntegrationSiteApiTest.php`

View File

@@ -20,6 +20,9 @@ return [
'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.', 'player_wallet_balance_blocks_delete' => 'Player wallet still has balance. Clear it before deletion.',
'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.', 'player_has_tickets_blocks_delete' => 'Player has ticket records and cannot be deleted.',
'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.', 'role_cannot_delete_super_admin' => 'Cannot delete the super admin role.',
'platform_roles_fixed' => 'Only the built-in Super Admin and Agent platform roles are supported; creating new roles is disabled.',
'role_super_admin_permissions_fixed' => 'Super Admin always has full permissions. Run lottery:admin-auth-sync after the permission catalog changes.',
'role_super_admin_metadata_fixed' => 'Super Admin is a built-in role; name and status cannot be changed.',
'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.', 'role_builtin_cannot_delete' => 'Built-in roles cannot be deleted.',
'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.', 'role_has_users_cannot_delete' => 'This role still has assigned admins and cannot be deleted.',
'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.', 'agent_root_delete_denied' => 'Root agent nodes cannot be deleted.',

View File

@@ -12,4 +12,7 @@ return [
'8003' => 'Player not registered', // 库中无对应玩家 '8003' => 'Player not registered', // 库中无对应玩家
'8004' => 'SSO secret not configured', // 未配置 MAIN_SITE_SSO_JWT_SECRET通常返回 503 '8004' => 'SSO secret not configured', // 未配置 MAIN_SITE_SSO_JWT_SECRET通常返回 503
'8005' => 'Account suspended or login disabled', '8005' => 'Account suspended or login disabled',
'8006' => 'Invalid username or password',
'8007' => 'Too many failed attempts. Try again later',
'8008' => 'Please sign in through the main site',
]; ];

View File

@@ -59,4 +59,14 @@ return [
'items.*.odds_value' => 'odds', 'items.*.odds_value' => 'odds',
'items.*.display_name' => 'display name', 'items.*.display_name' => 'display name',
'report_type' => 'report type', 'report_type' => 'report type',
'total_share_rate' => 'share rate',
'credit_limit' => 'credit limit',
'rebate_limit' => 'rebate ceiling',
'default_player_rebate' => 'default player rebate',
'rebate_rate' => 'rebate rate',
'extra_rebate_rate' => 'extra rebate rate',
'settlement_cycle' => 'settlement cycle',
'can_grant_extra_rebate' => 'allow extra rebate',
'can_create_child_agent' => 'allow child agents',
'can_create_player' => 'allow players',
]; ];

View File

@@ -15,6 +15,7 @@ return [
'1008' => 'Invalid amount; enter a positive integer in minor units', '1008' => 'Invalid amount; enter a positive integer in minor units',
'1009' => 'Main wallet operation failed; please try again later', '1009' => 'Main wallet operation failed; please try again later',
'1010' => 'Do not reuse an idempotency key with different transfer parameters', '1010' => 'Do not reuse an idempotency key with different transfer parameters',
'1011' => 'Wallet transfers are not available for credit-line players',
'2001' => 'The current draw is already closed', '2001' => 'The current draw is already closed',
'2002' => 'This play is closed', '2002' => 'This play is closed',
'2003' => 'Insufficient balance. Please transfer in before betting', '2003' => 'Insufficient balance. Please transfer in before betting',

View File

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

View File

@@ -12,7 +12,7 @@ return [
'site_rotate_denied' => '无权操作该站点。', 'site_rotate_denied' => '无权操作该站点。',
'site_update_denied' => '无权修改该站点。', 'site_update_denied' => '无权修改该站点。',
'site_player_access_denied' => '无权访问该站点下的玩家。', 'site_player_access_denied' => '无权访问该站点下的玩家。',
'integration_site_store_deprecated' => '请使用「开通代理线路」创建站点,不再支持单独创建接入站点。', 'integration_site_store_deprecated' => '请先在「平台配置 → 接入站点」创建站点,再在「代理配置 → 创建一级代理」绑定一级代理。',
'player_create_site_forbidden' => '无权在该站点下创建玩家。', 'player_create_site_forbidden' => '无权在该站点下创建玩家。',
'player_create_agent_required' => '创建玩家须归属代理节点:请选择有效主站(须已配置代理根节点),或由代理账号操作。', 'player_create_agent_required' => '创建玩家须归属代理节点:请选择有效主站(须已配置代理根节点),或由代理账号操作。',
'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。', 'player_create_agent_forbidden' => '无权将玩家归属到该代理节点。',
@@ -21,6 +21,9 @@ return [
'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。', 'player_wallet_balance_blocks_delete' => '该玩家钱包仍有余额,请先清空后再删除。',
'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。', 'player_has_tickets_blocks_delete' => '该玩家存在注单记录,无法删除。',
'role_cannot_delete_super_admin' => '不能删除超级管理员角色。', 'role_cannot_delete_super_admin' => '不能删除超级管理员角色。',
'platform_roles_fixed' => '平台仅保留「超级管理员」与「代理」两个内置角色,不支持新增。',
'role_super_admin_permissions_fixed' => '超级管理员拥有全部权限,请在权限目录变更后执行 lottery:admin-auth-sync。',
'role_super_admin_metadata_fixed' => '超级管理员为内置角色,不支持修改名称或状态。',
'role_builtin_cannot_delete' => '系统内置角色不允许删除。', 'role_builtin_cannot_delete' => '系统内置角色不允许删除。',
'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。', 'role_has_users_cannot_delete' => '该角色下仍有关联管理员,不能删除。',
'agent_root_delete_denied' => '根节点不允许删除。', 'agent_root_delete_denied' => '根节点不允许删除。',

View File

@@ -9,4 +9,7 @@ return [
'8003' => '玩家未建档', '8003' => '玩家未建档',
'8004' => '未配置 SSO 密钥', '8004' => '未配置 SSO 密钥',
'8005' => '账号已冻结或暂时无法登录', '8005' => '账号已冻结或暂时无法登录',
'8006' => '账号或密码错误',
'8007' => '登录失败次数过多,请稍后再试',
'8008' => '请使用主站登录进入彩票',
]; ];

View File

@@ -159,4 +159,14 @@ return [
'supports_multi_number' => '是否支持多号', 'supports_multi_number' => '是否支持多号',
'reserved_rule_json' => '预留规则', 'reserved_rule_json' => '预留规则',
'extra_config_json' => '扩展配置', 'extra_config_json' => '扩展配置',
'total_share_rate' => '占成比例',
'credit_limit' => '授信额度',
'rebate_limit' => '回水上限',
'default_player_rebate' => '默认玩家回水',
'rebate_rate' => '回水比例',
'extra_rebate_rate' => '额外回水比例',
'settlement_cycle' => '结算周期',
'can_grant_extra_rebate' => '允许额外回水',
'can_create_child_agent' => '允许创建下级代理',
'can_create_player' => '允许创建玩家',
]; ];

View File

@@ -15,12 +15,15 @@ return [
'permission_exceeds_actor' => '下列权限超出您可分配的范围::detail', 'permission_exceeds_actor' => '下列权限超出您可分配的范围::detail',
'permission_catalog_incomplete' => '权限目录不完整,缺少::detail。请联系管理员执行 migrate 与 admin-auth-sync。', 'permission_catalog_incomplete' => '权限目录不完整,缺少::detail。请联系管理员执行 migrate 与 admin-auth-sync。',
'exceeds_parent' => '占成比例不能超过上级代理。', 'exceeds_parent' => '占成比例不能超过上级代理。',
'exceeds_available' => '授信额度超出上级可下发额度。', 'exceeds_available' => '超出代理可下发额度:请提高该代理授信,或减少其他下级/玩家已占用的额度。',
'agent_profile_required' => '该代理尚未配置占成与授信,请先在「占成与授信」保存代理档案。',
'exceeds_limit' => '默认玩家回水不能超过回水上限。', 'exceeds_limit' => '默认玩家回水不能超过回水上限。',
'invalid_range' => '占成比例必须在 0100 之间。', 'invalid_range' => '占成比例必须在 0100 之间。',
'below_allocated' => '授信额度不能低于已下发给下级的额度。', 'below_allocated' => '代理授信额度不能低于已下发给下级代理与玩家的总额。',
'below_player_used' => '玩家授信额度不能低于该玩家已占用(含冻结)的额度。',
'parent_cannot_delegate' => '上级未开放该能力,无法下放。', 'parent_cannot_delegate' => '上级未开放该能力,无法下放。',
'cannot_create_child_agent' => '当前账号无权创建下级代理。', 'cannot_create_child_agent' => '当前账号无权创建下级代理。',
'cannot_create_player' => '当前账号无权创建玩家。', 'cannot_create_player' => '当前账号无权创建玩家。',
'primary_account_missing' => '该代理尚未绑定登录账号,无法修改登录名。', 'primary_account_missing' => '该代理尚未绑定登录账号,无法修改登录名。',
'site_root_exists' => '该接入站点已绑定一级代理,请选择其他站点。',
]; ];

View File

@@ -57,4 +57,24 @@ return [
'role_ids' => [ 'role_ids' => [
'required' => '请选择角色。', 'required' => '请选择角色。',
], ],
'rebate_rate' => [
'max' => '回水比例不能超过 1100% 记为 1。',
'min' => '回水比例不能小于 0。',
],
'extra_rebate_rate' => [
'max' => '额外回水比例不能超过 1100% 记为 1。',
'min' => '额外回水比例不能小于 0。',
],
'rebate_limit' => [
'max' => '回水上限不能超过 1100% 记为 1。',
'min' => '回水上限不能小于 0。',
],
'default_player_rebate' => [
'max' => '默认玩家回水不能超过 1100% 记为 1。',
'min' => '默认玩家回水不能小于 0。',
],
'total_share_rate' => [
'max' => '占成比例不能超过 100。',
'min' => '占成比例不能小于 0。',
],
]; ];

View File

@@ -14,12 +14,13 @@ return [
'1008' => '金额无效,请输入正整数(最小货币单位)', '1008' => '金额无效,请输入正整数(最小货币单位)',
'1009' => '主站钱包处理失败,请稍后重试', '1009' => '主站钱包处理失败,请稍后重试',
'1010' => '请勿重复使用幂等键发起不同金额的转账', '1010' => '请勿重复使用幂等键发起不同金额的转账',
'1011' => '信用盘玩家不支持主站钱包划转',
'2001' => '当前期已封盘,暂不可下注', '2001' => '当前期已封盘,暂不可下注',
'2002' => '玩法已关闭', '2002' => '玩法已关闭',
'2003' => '余额不足,请先转入后再下注', '2003' => '余额不足,请先转入后再下注',
'2004' => '号码格式不正确', '2004' => '号码格式不正确',
'2005' => '玩法参数不完整或不合法', '2005' => '玩法参数不完整或不合法',
'2006' => '当前期号不可下注', '2006' => '期号无效或已切换,请刷新大厅后重试',
'2007' => '该玩法暂不支持下注', '2007' => '该玩法暂不支持下注',
'2008' => '赔率或玩法配置已变更,请重新预览后再提交', '2008' => '赔率或玩法配置已变更,请重新预览后再提交',
'2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注', '2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注',

View File

@@ -1,19 +1,49 @@
<?php <?php
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AdminCreditLedgerIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementAdjustmentIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillAdjustmentController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillBadDebtWriteOffController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPaymentIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillConfirmController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillConfirmController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController; use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportShowController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('admin.api-resource') Route::middleware('admin.api-resource')
->group(function (): void { ->group(function (): void {
Route::get('settlement-periods', AgentSettlementPeriodIndexController::class)
->name('api.v1.admin.settlement-periods.index');
Route::post('settlement-periods', AgentSettlementPeriodStoreController::class) Route::post('settlement-periods', AgentSettlementPeriodStoreController::class)
->name('api.v1.admin.settlement-periods.store'); ->name('api.v1.admin.settlement-periods.store');
Route::post('settlement-periods/{settlement_period}/close', AgentSettlementPeriodCloseController::class) Route::post('settlement-periods/{settlement_period}/close', AgentSettlementPeriodCloseController::class)
->name('api.v1.admin.settlement-periods.close'); ->name('api.v1.admin.settlement-periods.close');
Route::get('credit-ledger', AdminCreditLedgerIndexController::class)
->name('api.v1.admin.credit-ledger.index');
Route::get('settlement-bills', AgentSettlementBillIndexController::class) Route::get('settlement-bills', AgentSettlementBillIndexController::class)
->name('api.v1.admin.settlement-bills.index'); ->name('api.v1.admin.settlement-bills.index');
Route::get('settlement-payments', AgentSettlementPaymentIndexController::class)
->name('api.v1.admin.settlement-payments.index');
Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class)
->name('api.v1.admin.settlement-adjustments.index');
Route::get('settlement-bills/{settlement_bill}', AgentSettlementBillShowController::class)
->name('api.v1.admin.settlement-bills.show');
Route::post('settlement-bills/{settlement_bill}/confirm', AgentSettlementBillConfirmController::class) Route::post('settlement-bills/{settlement_bill}/confirm', AgentSettlementBillConfirmController::class)
->name('api.v1.admin.settlement-bills.confirm'); ->name('api.v1.admin.settlement-bills.confirm');
Route::post('settlement-bills/{settlement_bill}/payments', AgentSettlementBillPaymentController::class)
->name('api.v1.admin.settlement-bills.payments');
Route::post('settlement-bills/{settlement_bill}/adjustments', AgentSettlementBillAdjustmentController::class)
->name('api.v1.admin.settlement-bills.adjustments');
Route::post('settlement-bills/{settlement_bill}/bad-debt-write-off', AgentSettlementBillBadDebtWriteOffController::class)
->name('api.v1.admin.settlement-bills.bad-debt-write-off');
Route::get('settlement-reports/summary', AgentSettlementReportIndexController::class)
->name('api.v1.admin.settlement-reports.summary');
Route::get('settlement-reports', AgentSettlementReportShowController::class)
->name('api.v1.admin.settlement-reports.show');
}); });

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteUpdateCont
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteRotateSecretsController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteConnectivityTestController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController; use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteExportController;
use App\Http\Controllers\Api\V1\Admin\Integration\AdminIntegrationSiteSecretsController;
Route::middleware('admin.api-resource') Route::middleware('admin.api-resource')
->group(function (): void { ->group(function (): void {
@@ -25,4 +26,6 @@ Route::middleware('admin.api-resource')
->name('api.v1.admin.integration-sites.connectivity-test'); ->name('api.v1.admin.integration-sites.connectivity-test');
Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class) Route::get('integration-sites/{admin_site}/export', AdminIntegrationSiteExportController::class)
->name('api.v1.admin.integration-sites.export'); ->name('api.v1.admin.integration-sites.export');
Route::get('integration-sites/{admin_site}/secrets', AdminIntegrationSiteSecretsController::class)
->name('api.v1.admin.integration-sites.secrets');
}); });

View File

@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
use App\Http\Controllers\Api\V1\Setting\SettingIndexController; use App\Http\Controllers\Api\V1\Setting\SettingIndexController;
use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController;
@@ -40,6 +41,7 @@ Route::prefix('player')
->name('api.v1.player.') ->name('api.v1.player.')
->group(function (): void { ->group(function (): void {
Route::get('ping', PlayerPingController::class)->name('ping'); Route::get('ping', PlayerPingController::class)->name('ping');
Route::post('auth/login', PlayerAuthLoginController::class)->name('auth.login');
}); });
// 系统公共配置(如前端规则等) // 系统公共配置(如前端规则等)

View File

@@ -0,0 +1,67 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('agent dashboard returns agent overview for operator with dashboard permission', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'super_dash_agent',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'dash-branch',
'name' => 'Dash Branch',
'can_create_player' => true,
]);
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
if ($operator === null) {
$operator = AdminUser::query()
->whereIn('id', DB::table('admin_user_agents')->where('agent_node_id', $branch->id)->pluck('admin_user_id'))
->first();
}
expect($operator)->not->toBeNull();
$platformRoleId = AgentPlatformRole::id();
$boundRoleId = (int) DB::table('admin_user_agent_roles')
->where('admin_user_id', $operator->id)
->where('agent_node_id', $branch->id)
->value('role_id');
expect($boundRoleId)->toBe($platformRoleId);
$slugs = DB::table('admin_role_menu_actions as rma')
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
->where('rma.role_id', $platformRoleId)
->pluck('ma.permission_code')
->all();
expect(in_array('dashboard.view', $slugs, true))->toBeTrue();
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
});

View File

@@ -11,7 +11,7 @@ beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
}); });
test('super admin can provision agent line with aligned root code', function (): void { test('super admin can provision root agent on existing integration site', function (): void {
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'line_super', 'username' => 'line_super',
'name' => 'Line Super', 'name' => 'Line Super',
@@ -23,21 +23,29 @@ test('super admin can provision agent line with aligned root code', function ():
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'line-alpha',
'name' => 'Line Alpha Site',
'status' => 1,
])
->assertCreated()
->assertJsonPath('data.code', 'line-alpha');
$response = $this->withHeader('Authorization', 'Bearer '.$token) $response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [ ->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-alpha',
'code' => 'line-alpha', 'code' => 'line-alpha',
'name' => 'Line Alpha', 'name' => 'Line Alpha',
'username' => 'line_alpha_owner', 'username' => 'line_alpha_owner',
'password' => 'secret-strong', 'password' => 'secret-strong',
'currency_code' => 'NPR',
'status' => 1, 'status' => 1,
]) ])
->assertCreated() ->assertCreated()
->assertJsonPath('data.code', 'line-alpha') ->assertJsonPath('data.code', 'line-alpha')
->assertJsonPath('data.agent_node.code', 'line-alpha') ->assertJsonPath('data.agent_node.code', 'line-alpha')
->assertJsonPath('data.line_root.site_code', 'line-alpha') ->assertJsonPath('data.line_root.site_code', 'line-alpha')
->assertJsonPath('data.secrets.sso_jwt_secret', fn ($v) => is_string($v) && $v !== '') ->assertJsonMissingPath('data.secrets');
->assertJsonPath('data.secrets.wallet_api_key', fn ($v) => is_string($v) && $v !== '');
$siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id'); $siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id');
expect($siteId)->toBeGreaterThan(0); expect($siteId)->toBeGreaterThan(0);
@@ -55,7 +63,48 @@ test('super admin can provision agent line with aligned root code', function ():
)->toBe(1); )->toBe(1);
}); });
test('non super admin cannot create integration site directly', function (): void { test('agent line provision rejects site that already has root', function (): void {
$admin = AdminUser::query()->create([
'username' => 'line_super2',
'name' => 'Line Super 2',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'line-beta',
'name' => 'Line Beta Site',
])
->assertCreated();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-beta',
'code' => 'line-beta',
'name' => 'Line Beta',
'username' => 'line_beta_owner',
'password' => 'secret-strong',
])
->assertCreated();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-beta',
'code' => 'line-beta-2',
'name' => 'Line Beta 2',
'username' => 'line_beta_owner2',
'password' => 'secret-strong',
])
->assertStatus(422)
->assertJsonPath('data.errors.site_code.0', 'site_root_exists');
});
test('integration manager with site.manage can create integration site', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
'username' => 'line_ops', 'username' => 'line_ops',
@@ -98,8 +147,9 @@ test('non super admin cannot create integration site directly', function (): voi
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [ ->postJson('/api/v1/admin/integration-sites', [
'code' => 'blocked-site', 'code' => 'ops-site',
'name' => 'Blocked', 'name' => 'Ops Site',
]) ])
->assertForbidden(); ->assertCreated()
->assertJsonPath('data.code', 'ops-site');
}); });

View File

@@ -161,6 +161,43 @@ test('agent profile update normalizes empty settlement cycle', function (): void
->assertJsonPath('data.settlement_cycle', 'weekly'); ->assertJsonPath('data.settlement_cycle', 'weekly');
}); });
test('bound agent cannot update own profile share and credit', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(\App\Services\Agent\AgentNodeService::class);
$super = AdminUser::query()->create([
'username' => 'self_profile_super',
'name' => 'Self Profile Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$agentNode = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'self-profile-agent',
'name' => 'Self Profile Agent',
'username' => 'self_profile_agent',
'total_share_rate' => 20,
'credit_limit' => 4000,
]));
$agentUser = AdminUser::query()->where('username', 'self_profile_agent')->firstOrFail();
bindAdminUserToAgent($agentUser, $agentNode->id);
$agentUser->syncPrimaryPlatformAgentRole($agentNode->id);
$token = $agentUser->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [
'total_share_rate' => 99,
'credit_limit' => 999_999,
])
->assertForbidden();
});
test('agent profile update rejects default rebate above limit', function (): void { test('agent profile update rejects default rebate above limit', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');

View File

@@ -0,0 +1,129 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAuthProfile;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
});
test('agent profile switches strip create player and child manage from effective permissions', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$child = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'path' => '/',
'depth' => 1,
'code' => 'cap-child',
'name' => 'Cap Child',
'status' => 1,
]);
$child->path = "/{$rootId}/{$child->id}/";
$child->save();
AgentProfile::query()->create([
'agent_node_id' => $child->id,
'total_share_rate' => 10,
'credit_limit' => 0,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0,
'default_player_rebate' => 0,
'settlement_cycle' => 'weekly',
'can_grant_extra_rebate' => false,
'can_create_child_agent' => false,
'can_create_player' => false,
]);
$admin = AdminUser::query()->create([
'username' => 'cap_child_agent',
'name' => 'Cap Child Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $child->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($child->id);
$fresh = $admin->fresh();
$profile = AdminAuthProfile::fromAdmin($fresh);
$perms = $profile['permissions'];
expect($perms)->toContain('prd.agent.view')
->not->toContain('prd.agent.manage')
->not->toContain('prd.users.manage');
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
expect($profile['agent']['can_create_child_agent'])->toBeFalse();
expect($profile['agent']['can_create_player'])->toBeFalse();
});
test('agent profile switches on grant create capabilities even when platform agent role omits manage', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$child = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'path' => '/',
'depth' => 1,
'code' => 'cap-child-on',
'name' => 'Cap Child On',
'status' => 1,
]);
$child->path = "/{$rootId}/{$child->id}/";
$child->save();
AgentProfile::query()->create([
'agent_node_id' => $child->id,
'total_share_rate' => 10,
'credit_limit' => 0,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0,
'default_player_rebate' => 0,
'settlement_cycle' => 'weekly',
'can_grant_extra_rebate' => false,
'can_create_child_agent' => true,
'can_create_player' => true,
]);
$admin = AdminUser::query()->create([
'username' => 'cap_child_on_agent',
'name' => 'Cap Child On Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $child->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($child->id);
$fresh = $admin->fresh();
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage');
});

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('admin credit ledger index returns credit player ledger rows', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDays(3),
'period_end' => now()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:credit-ledger-admin',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_admin_flow',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -500,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'credit_ledger_super',
'name' => 'Credit Ledger',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
->assertOk()
->assertJsonPath('data.ledger_source', 'settlement_ledger')
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.entry_kind', 'credit')
->assertJsonPath('data.items.0.player_id', $player->id)
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger')
->assertJsonPath('data.items.0.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.items.0.available_actions', ['view_player']);
});
test('settlement periods include pipeline credit and share counts', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:pipeline-1',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'pipe_user',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -100,
'reason' => 'bet_hold',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'pipeline_super',
'name' => 'Pipeline',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.id', $periodId)
->assertJsonPath('data.items.0.pipeline.credit_ledger_count', 1)
->assertJsonPath('data.items.0.pipeline.share_ledger_count', 0);
});

View File

@@ -0,0 +1,175 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Support\AdminAuthProfile;
use App\Support\AdminPermissionBridge;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function drawViewOnlyToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'draw_view_only_admin',
'name' => 'Draw View',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'draw_view_only_role',
'name' => 'Draw view only role',
]);
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
return $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function drawViewOnlyFixtureDraw(): Draw
{
return Draw::query()->create([
'draw_no' => '20260604-099',
'business_date' => '2026-06-04',
'sequence_no' => 99,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(1),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
}
test('partial draw review codes do not infer manage slug', function (): void {
$granted = AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes(['draw.review.publish']);
expect($granted)->not->toContain('prd.draw_result.manage');
});
test('draw view only admin profile excludes manage and cannot store draw', function (): void {
$admin = AdminUser::query()->create([
'username' => 'draw_view_only_profile',
'name' => 'Draw View',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'draw_view_only_role_profile',
'name' => 'Draw view only role',
]);
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
$profile = AdminAuthProfile::fromAdmin($admin->fresh());
expect($profile['permissions'])->toContain('prd.draw_result.view')
->not->toContain('prd.draw_result.manage');
$token = $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/draws', [
'admin_site_id' => $siteId,
'draw_no' => 'test-view-only-001',
'start_time' => now()->toIso8601String(),
'close_time' => now()->addHour()->toIso8601String(),
'draw_time' => now()->addHours(2)->toIso8601String(),
])
->assertForbidden();
});
test('draw view only list and show omit finance and operational fields', function (): void {
$token = drawViewOnlyToken();
$draw = drawViewOnlyFixtureDraw();
$listPayload = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws')
->assertOk()
->json('data');
$listRow = collect($listPayload['items'])->firstWhere('id', $draw->id);
expect($listRow)->not->toBeNull()
->and($listRow)->not->toHaveKey('total_bet_minor')
->and($listRow)->not->toHaveKey('result_source')
->and($listPayload['capabilities']['can_manage_draw_results'])->toBeFalse()
->and($listPayload['capabilities']['can_view_draw_finance'])->toBeFalse();
$show = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id)
->assertOk()
->json('data');
expect($show)->not->toHaveKeys([
'result_source',
'current_result_version',
'settle_version',
'is_reopened',
'created_at',
'updated_at',
])
->and($show['result_batch_counts'])->not->toHaveKey('pending_review')
->and($show['result_batch_counts'])->not->toHaveKey('total')
->and($show['capabilities']['can_manage_draw_results'])->toBeFalse();
});
test('draw view only cannot read finance summary and result batches hide ops metadata', function (): void {
$token = drawViewOnlyToken();
$draw = drawViewOnlyFixtureDraw();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/finance-summary')
->assertForbidden();
$payload = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches')
->assertOk()
->json('data');
expect($payload['capabilities']['can_manage_draw_results'])->toBeFalse();
foreach ($payload['batches'] as $batch) {
expect($batch)->not->toHaveKeys([
'source_type',
'rng_seed_hash',
'created_by',
'confirmed_by',
'created_at',
'updated_at',
]);
}
$hasPending = DB::table('draw_result_batches')
->where('draw_id', $draw->id)
->where('status', 'pending_review')
->exists();
if ($hasPending) {
$statuses = collect($payload['batches'])->pluck('status')->unique()->all();
expect($statuses)->not->toContain('pending_review');
}
});

View File

@@ -59,6 +59,34 @@ test('super admin can create integration site and receive secrets once', functio
expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue(); expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue();
}); });
test('super admin can reveal integration site secrets for copy', function (): void {
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'partner-secrets',
'name' => 'Partner Secrets',
])
->assertCreated();
$id = (int) $create->json('data.id');
$plainSso = (string) $create->json('data.secrets.sso_jwt_secret');
$plainWallet = (string) $create->json('data.secrets.wallet_api_key');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/integration-sites/'.$id.'/secrets')
->assertOk()
->assertJsonPath('data.sso_jwt_secret', $plainSso)
->assertJsonPath('data.wallet_api_key', $plainWallet);
expect(
AuditLog::query()
->where('module_code', 'integration')
->where('action_code', 'reveal_secrets')
->exists()
)->toBeTrue();
});
test('integration site code cannot be changed on update', function (): void { test('integration site code cannot be changed on update', function (): void {
$token = integrationAdminToken(); $token = integrationAdminToken();

View File

@@ -1,10 +1,13 @@
<?php <?php
use App\Models\AgentProfile;
use App\Models\Player; use App\Models\Player;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Models\AdminUser; use App\Models\AdminUser;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder; use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -263,3 +266,58 @@ test('admin can update player default currency and validation rejects unknown co
]) ])
->assertStatus(422); ->assertStatus(422);
}); });
test('admin can set player credit limit without clobbering used credit', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
AgentProfile::query()->updateOrCreate(
['agent_node_id' => $rootId],
[
'total_share_rate' => 60,
'credit_limit' => 50_000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
],
);
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'credit-limit-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_limit_user',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 120,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'credit_limit' => 2000,
])
->assertOk()
->assertJsonPath('data.credit_limit', 2000)
->assertJsonPath('data.available_credit', 1880);
$this->assertDatabaseHas('player_credit_accounts', [
'player_id' => $player->id,
'credit_limit' => 2000,
'used_credit' => 120,
]);
});

View File

@@ -120,6 +120,7 @@ test('admin can sync user roles for default site', function (): void {
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [ ->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => ['role_sync_b', 'role_sync_a'], 'role_slugs' => ['role_sync_b', 'role_sync_a'],
]) ])
->assertOk() ->assertOk()
@@ -247,7 +248,6 @@ test('permission catalog groups permissions by admin navigation order', function
'rules_odds', 'rules_odds',
'jackpot', 'jackpot',
'risk_cap', 'risk_cap',
'integration',
'currencies', 'currencies',
'admin_users', 'admin_users',
'admin_roles', 'admin_roles',
@@ -257,12 +257,12 @@ test('permission catalog groups permissions by admin navigation order', function
]); ]);
expect($groups[1]['key'])->toBe('agents'); expect($groups[1]['key'])->toBe('agents');
expect($groups[2]['key'])->toBe('draws'); expect($groups[2]['key'])->toBe('draws');
expect($groups[15]['label'])->toBe('管理列表');
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[16]['label'])->toBe('角色管理');
expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
$groupsByKey = collect($groups)->keyBy('key'); $groupsByKey = collect($groups)->keyBy('key');
expect($groupsByKey['admin_users']['label'])->toBe('管理列表');
expect(array_column($groupsByKey['admin_users']['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groupsByKey['admin_roles']['label'])->toBe('角色管理');
expect(array_column($groupsByKey['admin_roles']['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([ expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
'prd.tickets.view', 'prd.tickets.view',
]); ]);
@@ -280,7 +280,7 @@ test('permission catalog groups permissions by admin navigation order', function
]); ]);
}); });
test('admin can repair role permissions from the full catalog after role creation', function (): void { test('admin can adjust platform agent role permissions from the catalog', function (): void {
$token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']); $token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']);
$catalog = $this->withHeader('Authorization', 'Bearer '.$token) $catalog = $this->withHeader('Authorization', 'Bearer '.$token)
@@ -300,39 +300,37 @@ test('admin can repair role permissions from the full catalog after role creatio
->toContain('prd.report.view') ->toContain('prd.report.view')
->toContain('prd.wallet_reconcile.manage'); ->toContain('prd.wallet_reconcile.manage');
$role = $this->withHeader('Authorization', 'Bearer '.$token) $agentRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [ ->getJson('/api/v1/admin/admin-roles')
'slug' => 'repairable_role',
'name' => 'Repairable Role',
'permission_slugs' => [],
])
->assertOk() ->assertOk()
->assertJsonPath('data.permission_slugs', []) ->json('data.items'))
->json('data'); ->firstWhere('slug', 'agent');
expect($agentRole)->not->toBeNull();
$repairResponse = $this->withHeader('Authorization', 'Bearer '.$token) $repairResponse = $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ ->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'], 'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'],
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.slug', 'repairable_role'); ->assertJsonPath('data.slug', 'agent');
expect($repairResponse->json('data.permission_slugs')) expect($repairResponse->json('data.permission_slugs'))
->toContain('prd.report.view', 'prd.wallet_reconcile.manage'); ->toContain('prd.report.view', 'prd.wallet_reconcile.manage');
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [ ->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
'permission_slugs' => ['prd.admin_role.manage'], 'permission_slugs' => ['prd.admin_role.manage'],
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']); ->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']);
$persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token) $persistedRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles') ->getJson('/api/v1/admin/admin-roles')
->assertOk() ->assertOk()
->json('data.items'); ->json('data.items'))
->firstWhere('slug', 'agent');
$persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role');
expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']); expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']);
}); });
@@ -348,6 +346,7 @@ test('admin can create update and delete users with crud rules', function (): vo
'email' => 'newuser@example.com', 'email' => 'newuser@example.com',
'password' => 'secret-long', 'password' => 'secret-long',
'status' => 0, 'status' => 0,
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => ['crud_new_user_role'], 'role_slugs' => ['crud_new_user_role'],
]) ])
->assertOk() ->assertOk()
@@ -364,6 +363,7 @@ test('admin can create update and delete users with crud rules', function (): vo
'nickname' => 'dup', 'nickname' => 'dup',
'email' => null, 'email' => null,
'password' => 'secret-long', 'password' => 'secret-long',
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => [$crudRole->slug], 'role_slugs' => [$crudRole->slug],
]) ])
->assertStatus(422) ->assertStatus(422)
@@ -407,6 +407,7 @@ test('admin user create requires at least one role slug', function (): void {
'nickname' => 'NR', 'nickname' => 'NR',
'email' => null, 'email' => null,
'password' => 'secret-long', 'password' => 'secret-long',
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => [], 'role_slugs' => [],
]) ])
->assertStatus(422) ->assertStatus(422)

View File

@@ -0,0 +1,243 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\Player;
use App\Support\AdminSiteScope;
use App\Lottery\ErrorCode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function siteRoleBindingAdmin(string $username, array $permissionSlugs, ?int $boundSiteId = null): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'role_'.$username,
'name' => 'Role '.$username,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);
$siteId = $boundSiteId ?? AdminUser::defaultAdminSiteId();
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('platform user created with admin_site_id only sees that site players', function (): void {
$this->seed(CurrencySeeder::class);
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
Player::query()->create([
'site_code' => 'site-a',
'site_player_id' => 'pa-bind-1',
'username' => 'pa_bind_1',
'nickname' => 'PA',
'default_currency' => 'NPR',
'status' => 0,
]);
Player::query()->create([
'site_code' => 'site-b',
'site_player_id' => 'pb-bind-1',
'username' => 'pb_bind_1',
'nickname' => 'PB',
'default_currency' => 'NPR',
'status' => 0,
]);
$opsRole = AdminRole::query()->create([
'slug' => 'site_b_ops',
'name' => 'Site B Ops',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$opsRole->syncLegacyPermissionSlugs(['prd.users.view_finance', 'prd.admin_user.manage']);
$creator = AdminUser::query()->create([
'username' => 'super_creator',
'name' => 'Creator',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($creator);
$token = $creator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-users', [
'username' => 'site_b_ops_user',
'nickname' => 'Site B Ops User',
'email' => null,
'password' => 'secret-long',
'admin_site_id' => $siteBId,
'role_slugs' => ['site_b_ops'],
])
->assertOk()
->assertJsonPath('data.site_bindings.0.site_id', $siteBId)
->assertJsonPath('data.site_bindings.0.site_code', 'site-b');
$created = AdminUser::query()->where('username', 'site_b_ops_user')->firstOrFail();
expect($created->isSuperAdmin())->toBeFalse();
expect($created->accessibleAdminSiteIds())->toEqual([$siteBId]);
expect(AdminSiteScope::accessibleSiteCodes($created))->toBe(['site-b']);
$scopedQuery = Player::query();
AdminSiteScope::applyToPlayerQuery($scopedQuery, $created);
expect($scopedQuery->pluck('site_code')->unique()->values()->all())->toBe(['site-b']);
$createdToken = $created->createToken('test', ['*'], now()->addDay())->plainTextToken;
expect($creator->id)->not->toBe($created->id);
$boundRoleSlugs = DB::table('admin_user_site_roles as usr')
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
->where('usr.admin_user_id', $created->id)
->orderBy('r.slug')
->pluck('r.slug')
->all();
expect($boundRoleSlugs)->toBe(['site_b_ops']);
expect($created->fresh()->isSuperAdmin())->toBeFalse();
app('auth')->forgetGuards();
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.id', $created->id)
->assertJsonPath('data.admin.username', 'site_b_ops_user')
->assertJsonCount(1, 'data.admin.accessible_sites');
$codes = collect(
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/players')
->assertOk()
->json('data.items'),
)->pluck('site_code')->unique()->values()->all();
expect($codes)->toBe(['site-b']);
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.accessible_sites.0.code', 'site-b')
->assertJsonPath('data.admin.agent', null);
});
test('scoped operator cannot assign roles on site outside their binding', function (): void {
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
$target = AdminUser::query()->create([
'username' => 'bind_target',
'name' => 'Target',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
AdminRole::query()->create([
'slug' => 'scoped_assign_role',
'name' => 'Scoped Assign',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$token = siteRoleBindingAdmin('site_a_only_mgr', ['prd.admin_user.manage'], $siteAId);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteBId,
'role_slugs' => ['scoped_assign_role'],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('role sync replaces roles only for requested site', function (): void {
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
$rA = AdminRole::query()->create([
'slug' => 'multi_a',
'name' => 'A',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$rB = AdminRole::query()->create([
'slug' => 'multi_b',
'name' => 'B',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$target = AdminUser::query()->create([
'username' => 'multi_site_user',
'name' => 'Multi',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
['admin_user_id' => $target->id, 'site_id' => $siteAId, 'role_id' => $rA->id, 'granted_at' => now()],
['admin_user_id' => $target->id, 'site_id' => $siteBId, 'role_id' => $rB->id, 'granted_at' => now()],
]);
$actor = AdminUser::query()->create([
'username' => 'multi_sync_actor',
'name' => 'Actor',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($actor);
$token = $actor->createToken('test', ['*'], now()->addDay())->plainTextToken;
$rC = AdminRole::query()->create([
'slug' => 'multi_c',
'name' => 'C',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteAId,
'role_slugs' => ['multi_c'],
])
->assertOk();
$siteARoles = DB::table('admin_user_site_roles')
->where('admin_user_id', $target->id)
->where('site_id', $siteAId)
->pluck('role_id')
->map(static fn ($id): int => (int) $id)
->all();
$siteBRoles = DB::table('admin_user_site_roles')
->where('admin_user_id', $target->id)
->where('site_id', $siteBId)
->pluck('role_id')
->map(static fn ($id): int => (int) $id)
->all();
expect($siteARoles)->toBe([(int) $rC->id]);
expect($siteBRoles)->toBe([(int) $rB->id]);
});

View File

@@ -0,0 +1,171 @@
<?php
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Services\Agent\AgentProfileService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function createAgentLineForAllocation(string $code, int $creditLimit): AgentNode
{
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => $code,
'name' => $code,
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/'.$code,
'code' => $code,
'name' => 'Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AgentProfile::query()->create([
'agent_node_id' => $rootId,
'total_share_rate' => 60,
'credit_limit' => $creditLimit,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
]);
return AgentNode::query()->findOrFail($rootId);
}
test('player credit account syncs agent allocated credit', function (): void {
$root = createAgentLineForAllocation('line-alloc', 10000);
$service = app(AgentProfileService::class);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-alloc',
'agent_node_id' => $root->id,
'site_player_id' => 'p-alloc-1',
'username' => 'alloc1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 2000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(2000);
expect($service->present($profile)['available_credit'])->toBe(8000);
});
test('player credit allocation exceeds available throws', function (): void {
$root = createAgentLineForAllocation('line-over', 5000);
$service = app(AgentProfileService::class);
expect(fn () => $service->assertMayIncreasePlayerCredit($root, 6000))
->toThrow(\Illuminate\Validation\ValidationException::class);
});
test('win loss does not change agent allocated credit', function (): void {
$root = createAgentLineForAllocation('line-hold', 10000);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-hold',
'agent_node_id' => $root->id,
'site_player_id' => 'p-hold-1',
'username' => 'hold1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 2000,
'used_credit' => 200,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
app(AgentProfileService::class)->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(2000);
});
test('raising player credit limit succeeds when agent allocated credit includes other subordinates', function (): void {
$root = createAgentLineForAllocation('line-player-raise', 10000);
$service = app(AgentProfileService::class);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-player-raise',
'agent_node_id' => $root->id,
'site_player_id' => 'p-other',
'username' => 'other',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 3000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->refreshAllocatedCredit($root);
$newPlayerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-player-raise',
'agent_node_id' => $root->id,
'site_player_id' => 'p-raise-1',
'username' => 'raise1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $newPlayerId,
'credit_limit' => 0,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->adjustPlayerCreditAllocation($root, 0, 2000);
DB::table('player_credit_accounts')->where('player_id', $newPlayerId)->update(['credit_limit' => 2000]);
$service->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(5000);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Services\Agent\AgentNodeService;
use App\Services\Agent\AgentProfileService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('agent with overdue bill cannot create player', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$super = AdminUser::query()->create([
'username' => 'od_super',
'name' => 'OD',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$agent = app(AgentNodeService::class)->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'OD1',
'name' => 'OD1',
'username' => 'od_agent_user',
'total_share_rate' => 50,
'credit_limit' => 10000,
'can_create_player' => true,
]));
$admin = AdminUser::query()->where('username', 'od_agent_user')->first();
expect($admin)->not->toBeNull();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now()->subDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $agent->id,
'counterparty_type' => 'agent',
'counterparty_id' => $rootId,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'net_amount' => 500,
'paid_amount' => 0,
'unpaid_amount' => 500,
'status' => 'overdue',
'created_at' => now(),
'updated_at' => now(),
]);
expect(fn () => app(AgentProfileService::class)->assertActorMayCreatePlayer($admin))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,212 @@
<?php
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Models\Player;
use App\Services\Agent\AgentNodeService;
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
use App\Support\Settlement\DesignDocExample12;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('period close from share ledger matches design doc example 12', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(AgentNodeService::class);
$super = \App\Models\AdminUser::query()->create([
'username' => 'e2e_super',
'name' => 'E2E',
'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$a = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'A',
'name' => 'A',
'username' => 'e2e_a',
'total_share_rate' => 60,
'credit_limit' => 500000,
]));
$b = $service->createChild($super, agentChildPayload([
'parent_id' => $a->id,
'code' => 'B',
'name' => 'B',
'username' => 'e2e_b',
'total_share_rate' => 40,
'credit_limit' => 200000,
]));
$c = $service->createChild($super, agentChildPayload([
'parent_id' => $b->id,
'code' => 'C',
'name' => 'C',
'username' => 'e2e_c',
'total_share_rate' => 25,
'credit_limit' => 100000,
]));
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $c->id,
'site_player_id' => 'e2e-p1',
'username' => 'e2eplayer',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = \App\Models\Draw::query()->create([
'draw_no' => 'E2E-AG-001',
'business_date' => now()->toDateString(),
'sequence_no' => 99,
'status' => \App\Lottery\DrawStatus::Open->value,
'start_time' => null,
'close_time' => null,
'draw_time' => null,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-E2E-AG-1',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10000,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-E2E-AG-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10000,
'total_bet_amount' => 10000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$settledAt = now();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => $settledAt->copy()->subDay(),
'period_end' => $settledAt->copy()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => $c->id,
'agent_path' => json_encode([$a->id, $b->id, $c->id]),
'share_snapshot' => json_encode([
'total_shares' => ['C' => 25, 'B' => 40, 'A' => 60],
'actual_shares' => ['C' => 25, 'B' => 15, 'A' => 20, 'platform' => 40],
'chain_codes' => ['C', 'B', 'A'],
'agent_path' => [$a->id, $b->id, $c->id],
]),
'game_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
'basic_rebate' => DesignDocExample12::BASIC_REBATE,
'shared_net_win_loss' => DesignDocExample12::SHARED_NET_WIN_LOSS,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
DB::table('rebate_records')->insert([
[
'player_id' => $player->id,
'ticket_item_id' => $ticketItemId,
'game_type' => '*',
'valid_bet_amount' => 10000,
'rebate_rate' => 0.005,
'rebate_amount' => DesignDocExample12::BASIC_REBATE,
'rebate_type' => 'basic',
'owner_agent_id' => $c->id,
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
],
[
'player_id' => $player->id,
'ticket_item_id' => $ticketItemId,
'game_type' => '*',
'valid_bet_amount' => 10000,
'rebate_rate' => 0.002,
'rebate_amount' => DesignDocExample12::EXTRA_REBATE_BY_C,
'rebate_type' => 'extra',
'owner_agent_id' => $c->id,
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
],
]);
$close = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
$playerBill = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'player')
->where('owner_id', $player->id)
->first();
expect($playerBill)->not->toBeNull();
expect((int) $playerBill->net_amount)->toBe((int) DesignDocExample12::PLAYER_NET_SETTLEMENT);
$edgeCtoB = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('meta_json', 'like', '%C_to_B%')
->value('net_amount');
expect((int) $edgeCtoB)->toBe((int) round(DesignDocExample12::TIER_C_TO_B));
expect($close['rebate_dispatched'])->toBe(2);
expect($close['rebate_allocations'])->toBeGreaterThan(0);
expect(DB::table('rebate_records')->where('status', 'in_bill')->count())->toBe(2);
expect(DB::table('rebate_allocations')->where('settlement_bill_id', $playerBill->id)->count())
->toBeGreaterThan(0);
});

View File

@@ -0,0 +1,90 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('admin can write off player bill bad debt and complete period when all settled', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $site->id,
'period_start' => now()->subDays(7),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $agentId,
'site_player_id' => 'bd-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'bduser',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $agentId,
'gross_win_loss' => 10000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => 10000,
'paid_amount' => 0,
'unpaid_amount' => 10000,
'status' => 'overdue',
'confirmed_at' => now(),
'locked_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'bad_debt_super',
'name' => 'Bad Debt',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [
'reason' => 'uncollectible',
])
->assertOk()
->assertJsonPath('data.original_bill_id', $billId);
$this->assertDatabaseHas('settlement_bills', [
'id' => $billId,
'status' => 'settled',
'unpaid_amount' => 0,
]);
$this->assertDatabaseHas('settlement_adjustments', [
'original_bill_id' => $billId,
'adjustment_type' => 'bad_debt',
'amount' => 10000,
]);
$this->assertDatabaseHas('settlement_periods', [
'id' => $periodId,
'status' => 'completed',
]);
});

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('locked bill can receive adjustment bill', function (): void {
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'gross_win_loss' => 1000,
'rebate_amount' => 50,
'adjustment_amount' => 0,
'net_amount' => 930,
'paid_amount' => 0,
'unpaid_amount' => 930,
'status' => 'confirmed',
'locked_at' => now(),
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$newId = app(\App\Services\AgentSettlement\AgentSettlementBillAdjustmentService::class)
->createAdjustment($billId, -30, 'adjustment', 'correction', 0);
$adjustment = DB::table('settlement_bills')->where('id', $newId)->first();
expect($adjustment)->not->toBeNull();
expect((string) $adjustment->bill_type)->toBe('adjustment');
expect((int) $adjustment->reversed_bill_id)->toBe($billId);
expect((int) $adjustment->net_amount)->toBe(-30);
});

View File

@@ -0,0 +1,84 @@
<?php
use App\Models\AdminUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('settlement payments and adjustments index return items', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 1000,
'unpaid_amount' => 0,
'paid_amount' => 1000,
'status' => 'settled',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => 'player',
'payer_id' => 1,
'payee_type' => 'agent',
'payee_id' => 1,
'amount' => 1000,
'method' => 'cash',
'status' => 'confirmed',
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $billId,
'adjustment_type' => 'adjustment',
'amount' => 100,
'reason' => 'test',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'lists_super',
'name' => 'Lists',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.settlement_bill_id', $billId);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.original_bill_id', $billId);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')
->assertOk()
->assertJsonPath('data.items.0.bill_type', 'player');
});

View File

@@ -0,0 +1,76 @@
<?php
use App\Models\AdminUser;
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('settlement periods index includes bill summary per period', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
[
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 1000,
'unpaid_amount' => 1000,
'paid_amount' => 0,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
],
[
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => 1,
'counterparty_type' => 'platform',
'counterparty_id' => 0,
'net_amount' => 5000,
'unpaid_amount' => 5000,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
],
]);
$admin = AdminUser::query()->create([
'username' => 'period_summary_admin',
'name' => 'Summary',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.summary.player_bills', 1)
->assertJsonPath('data.items.0.summary.agent_bills', 1)
->assertJsonPath('data.items.0.summary.pending_confirm', 1)
->assertJsonPath('data.items.0.summary.awaiting_payment', 1)
->assertJsonPath('data.items.0.summary.total_unpaid', 6000);
$service = app(AgentSettlementPeriodSummaryService::class);
$summaries = $service->summariesForPeriodIds([$periodId]);
expect($summaries[$periodId]['player_bills'])->toBe(1);
expect($summaries[$periodId]['agent_bills'])->toBe(1);
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Models\AgentProfile;
use App\Services\Agent\AgentProfileService;
use App\Services\AgentSettlement\BetSettlementSnapshotBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('share snapshot uses profile at build time not after change', function (): void {
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => 'snap-line',
'name' => 'snap',
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/snap-line',
'code' => 'snap-line',
'name' => 'Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AgentProfile::query()->create([
'agent_node_id' => $rootId,
'total_share_rate' => 25,
'credit_limit' => 10000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
]);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'snap-line',
'agent_node_id' => $rootId,
'site_player_id' => 'snap-p1',
'username' => 'snap1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$player = \App\Models\Player::query()->findOrFail($playerId);
$builder = app(BetSettlementSnapshotBuilder::class);
$first = $builder->buildForPlayer($player);
AgentProfile::query()->where('agent_node_id', $rootId)->update(['total_share_rate' => 50]);
$stored = json_encode($first['total_shares']);
$second = $builder->buildForPlayer($player->fresh());
expect($stored)->toContain('"snap-line":25');
expect($second['total_shares']['snap-line'])->toBe(50.0);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Player;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('settled loss does not double count bet hold', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'hold-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'hold1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 5000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$credit = app(PlayerCreditService::class);
$credit->assertMayPlaceBet($player, 200);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
$drawId = (int) \App\Models\Draw::query()->create([
'draw_no' => 'HOLD-DRAW',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
])->id;
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-HOLD-1',
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 200,
'total_rebate_amount' => 0,
'total_actual_deduct' => 200,
'total_estimated_payout' => 0,
'status' => 'placed',
'created_at' => now(),
'updated_at' => now(),
]);
$item = \App\Models\TicketItem::query()->create([
'ticket_no' => 'T-HOLD-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'direct',
'dimension' => '4d',
'digit_slot' => null,
'bet_mode' => 'single',
'unit_bet_amount' => 200,
'total_bet_amount' => 200,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 200,
'odds_snapshot_json' => '{}',
'rule_snapshot_json' => '{}',
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
]);
$item->setRelation('player', $player);
app(AgentGameSettlementRecorder::class)->recordForTicketItem($item, 0, 'settled_lose');
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
});

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('credit line hold does not change wallet balance', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'cl-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'cl1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 50000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$walletBefore = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
app(PlayerCreditService::class)->assertMayPlaceBet($player, 500);
$walletAfter = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
expect($walletAfter)->toBe($walletBefore);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(5);
expect(DB::table('credit_ledger')->where('reason', 'bet_hold')->where('owner_id', $player->id)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,59 @@
<?php
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('credit player wallet logs reads credit_ledger not wallet_txns', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:logs-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_logs',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 200,
'used_credit' => 10,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -1000,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=1&size=10')
->assertOk()
->assertJsonPath('data.ledger_source', 'credit_ledger')
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.type', 'bet')
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger');
});

View File

@@ -0,0 +1,126 @@
<?php
use App\Models\Player;
use App\Models\TicketItem;
use App\Services\AgentSettlement\GameSettlementReversalService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('reversal zeroes share ledger net and marks rebates reversed', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$siteCode = (string) $site->code;
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'rev-p1',
'username' => 'rev1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$drawId = (int) \App\Models\Draw::query()->create([
'draw_no' => 'REV-DRAW-1',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
])->id;
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-REV-1',
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 0,
'status' => 'placed',
'created_at' => now(),
'updated_at' => now(),
]);
$itemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-REV-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'direct',
'dimension' => '4d',
'digit_slot' => null,
'bet_mode' => 'single',
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => '{}',
'rule_snapshot_json' => '{}',
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'agent_node_id' => $player->agent_node_id,
'share_snapshot' => '{}',
'agent_settled_at' => now(),
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$ledgerId = (int) DB::table('share_ledger')->insertGetId([
'ticket_item_id' => $itemId,
'player_id' => $player->id,
'agent_node_id' => $player->agent_node_id,
'agent_path' => '[]',
'share_snapshot' => '{}',
'game_win_loss' => -1000,
'basic_rebate' => 50,
'shared_net_win_loss' => -950,
'allocations_json' => '[]',
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$rebateId = (int) DB::table('rebate_records')->insertGetId([
'player_id' => $player->id,
'ticket_item_id' => $itemId,
'game_type' => '*',
'valid_bet_amount' => 1000,
'rebate_rate' => 0.005,
'rebate_amount' => 50,
'rebate_type' => 'basic',
'owner_agent_id' => $player->agent_node_id,
'status' => 'accrued',
'created_at' => now(),
'updated_at' => now(),
]);
$item = TicketItem::query()->findOrFail($itemId);
app(GameSettlementReversalService::class)->reverseTicketItem($item);
$sum = (int) DB::table('share_ledger')->where('ticket_item_id', $itemId)->sum('shared_net_win_loss');
expect($sum)->toBe(0);
expect((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed');
expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,100 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function platformRolesApiToken(string $username): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('platform role index only lists fixed super_admin and agent roles', function (): void {
AdminRole::query()->create([
'slug' => 'legacy_custom_ops',
'code' => 'legacy_custom_ops',
'name' => 'Legacy Ops',
'scope_type' => AdminRole::SCOPE_SYSTEM,
'status' => 1,
'is_system' => false,
'sort_order' => 99,
]);
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_index');
$slugs = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items'))
->pluck('slug')
->all();
expect($slugs)->toBe(['super_admin', 'agent']);
});
test('platform roles cannot be created and super_admin permissions are full catalog', function (): void {
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_guard');
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
expect($super->is_system)->toBeTrue();
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
->toBe($menuActionCount);
expect($super->legacyPermissionSlugs())->not->toBeEmpty();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'new_ops',
'name' => 'New Ops',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [
'permission_slugs' => ['prd.dashboard.view'],
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$super->id, [
'name' => 'Renamed Super',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/admin-roles/'.$super->id)
->assertStatus(422);
});
test('admin-auth-sync grants super_admin the full permission catalog', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
->toBe($menuActionCount);
});

View File

@@ -0,0 +1,139 @@
<?php
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
'lottery.main_site.wallet_api_url' => null,
]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('native player can login and access me', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:test-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'agentplayer1',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 50000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$login = $this->postJson('/api/v1/player/auth/login', [
'site_code' => $site->code,
'username' => 'agentplayer1',
'password' => 'secret-pass',
]);
$login->assertOk();
$token = (string) $login->json('data.access_token');
expect($token)->not->toBe('');
$me = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me');
$me->assertOk()
->assertJsonPath('data.id', $player->id)
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
});
test('credit player wallet transfer in is rejected', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:test-2',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'agentplayer2',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$auth = app(\App\Services\Player\PlayerNativeAuthService::class);
$token = $auth->issueToken($player);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/wallet/transfer-in', [
'amount' => 1000,
'idempotent_key' => 'native-ti-1',
'currency' => 'NPR',
]);
$response->assertJsonPath('code', 1011);
});
test('sso wallet player balance does not use credit when site credit mode on', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'site_player_id' => 'sso-wallet-1',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'ssouser',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
\App\Models\PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 12000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/balance?currency=NPR');
$response->assertOk()
->assertJsonPath('data.credit_line_mode', false)
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
->assertJsonPath('data.available_balance', 12000);
});

View File

@@ -0,0 +1,42 @@
<?php
use App\Services\AgentSettlement\AgentSettlementBillGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('confirmed bill net amount cannot be mutated via guard', function (): void {
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'gross_win_loss' => 1000,
'rebate_amount' => 50,
'adjustment_amount' => 0,
'net_amount' => 930,
'paid_amount' => 0,
'unpaid_amount' => 930,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
]);
$guard = app(AgentSettlementBillGuard::class);
$guard->markConfirmed($billId);
expect(fn () => $guard->assertNetAmountMutable($billId))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('overdue player cannot place bet on credit line', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'od-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'od1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $site->id,
'period_start' => now()->subWeek(),
'period_end' => now()->subDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $player->agent_node_id,
'gross_win_loss' => 1000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'net_amount' => 1000,
'paid_amount' => 0,
'unpaid_amount' => 1000,
'status' => 'overdue',
'created_at' => now(),
'updated_at' => now(),
]);
expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,46 @@
<?php
use App\Models\Player;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('credit player wallet balance returns minor units matching admin credit limit', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'credit-bal-1',
'auth_source' => 'lottery_native',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'creditbal',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 200,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/balance?currency=NPR')
->assertOk()
->assertJsonPath('data.credit_line_mode', true)
->assertJsonPath('data.available_balance', 20000)
->assertJsonPath('data.credit_limit', 20000)
->assertJsonPath('data.available_balance_formatted', '200.00');
});

View File

@@ -0,0 +1,37 @@
<?php
use App\Models\AgentProfile;
use App\Support\AgentDefaultRolePermissions;
test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void {
$slugs = AgentDefaultRolePermissions::baseSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.settlement.agent.view')
->not->toContain('prd.wallet_reconcile.view')
->not->toContain('prd.wallet_reconcile.view_cs');
});
test('line root owner slugs include agent management packages', function (): void {
$slugs = AgentDefaultRolePermissions::lineRootOwnerSlugs();
expect($slugs)
->toContain('prd.agent.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.agent.role.manage');
});
test('owner slugs from profile add manage slugs when capabilities enabled', function (): void {
$profile = new AgentProfile([
'can_create_child_agent' => true,
'can_create_player' => false,
]);
$slugs = AgentDefaultRolePermissions::ownerSlugsFromProfile($profile);
expect($slugs)
->toContain('prd.agent.manage')
->toContain('prd.agent.profile.manage')
->not->toContain('prd.users.manage');
});

View File

@@ -50,3 +50,21 @@ test('normalizes exact draw items message in zh', function (): void {
expect($errors['items'][0])->toContain('23'); expect($errors['items'][0])->toContain('23');
}); });
test('normalizes compact english max for rebate rate in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['rebate_rate' => ['rebate rate must not be greater than 1.']],
'zh',
);
expect($errors['rebate_rate'][0])->toBe('回水比例不能超过 1100% 记为 1。');
});
test('normalizes compact english max for rebate limit in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['rebate_limit' => ['rebate limit must not be greater than 1.']],
'zh',
);
expect($errors['rebate_limit'][0])->toBe('回水上限不能超过 1100% 记为 1。');
});

View File

@@ -0,0 +1,18 @@
<?php
use App\Support\CreditAmountScale;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('major and minor convert for two decimal currency', function (): void {
expect(CreditAmountScale::majorToMinor(200, 'NPR'))->toBe(20000);
expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200);
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
});