From 2e0b257160f0ff68f835e3247f83edbfdf679948 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 17 Jun 2026 15:27:00 +0800 Subject: [PATCH] feat: enhance player authentication and agent management features - Updated AGENTS.md to clarify player interface bindings and agent account restrictions. - Improved PlayerAuthLoginController to include captcha verification for player login. - Enhanced AdminPlayerIndexController with permission checks for admin users. - Refactored AdminPlayerStoreController to enforce agent node restrictions for non-super admins. - Introduced new error codes for player authentication failures and updated related services. - Enhanced validation rules for agent profiles to include settlement cycle options. - Improved AdminCaptchaService to support separate scopes for admin and player captcha handling. - Updated various services to ensure proper credit management and settlement processes. --- AGENTS.md | 15 ++- .../Agent/AgentNodeRoleStoreController.php | 13 +-- .../Player/AdminPlayerIndexController.php | 16 +++ .../Player/AdminPlayerStoreController.php | 45 +++----- .../V1/Player/PlayerAuthCaptchaController.php | 22 ++++ .../V1/Player/PlayerAuthLoginController.php | 22 +++- .../Admin/Concerns/AgentProfileFieldRules.php | 10 +- .../Player/PlayerAuthLoginRequest.php | 2 + app/Lottery/ErrorCode.php | 3 + app/Models/AgentProfile.php | 2 + app/Providers/AppServiceProvider.php | 8 ++ .../Admin/AdminReportQueryService.php | 4 - app/Services/AdminCaptchaService.php | 30 ++++- .../Agent/AgentCreditAllocatedSyncService.php | 2 + app/Services/Agent/AgentProfileService.php | 10 +- .../GameSettlementReversalService.php | 25 ++--- .../SettlementCenterLedgerService.php | 4 +- app/Services/Player/PlayerCreditService.php | 67 ++++++++++- .../Wallet/PlayerLedgerLogsService.php | 75 ++++++++++++- app/Support/AdminAgentNodeAccess.php | 28 +++-- app/Support/AdminAgentScope.php | 90 +++++++-------- app/Support/AdminAuthorizationRegistry.php | 8 +- app/Support/AdminDataScope.php | 17 +-- app/Support/AdminSiteScope.php | 8 +- app/Support/CreditAmountScale.php | 10 +- app/Support/CreditLineMode.php | 6 +- app/Support/PlayerFundingMode.php | 13 +-- ...17_120000_backfill_root_agent_profiles.php | 49 +++++++++ lang/en/sso.php | 1 + lang/ne/sso.php | 4 + lang/zh/sso.php | 1 + routes/api/v1/public.php | 8 +- tests/Feature/AdminAgentLineApiTest.php | 15 +++ tests/Feature/AdminAuthLoginTest.php | 2 +- .../AdminAuthorizationAuditCommandTest.php | 4 + .../AdminDashboardAnalyticsApiTest.php | 7 +- tests/Feature/AdminIntegrationSiteApiTest.php | 6 +- tests/Feature/AdminPlayerManageApiTest.php | 1 + .../AdminReportAuthorizationFixTest.php | 19 +--- tests/Feature/AdminSettingBatchUpdateTest.php | 11 +- tests/Feature/AdminUserPermissionApiTest.php | 1 + tests/Feature/AgentCreditAllocationTest.php | 8 ++ ...AgentPeriodCloseFromGameSettlementTest.php | 24 ++-- tests/Feature/CreditWalletLogsTest.php | 53 +++++++++ .../FinancialChainAuditCommandTest.php | 98 ----------------- tests/Feature/GameSettlementReversalTest.php | 104 ++++++++++++++++++ tests/Feature/PlayerNativeAuthTest.php | 73 ++++++++++-- tests/Feature/PlayerRealtimeBroadcastTest.php | 3 + tests/Unit/CreditAmountScaleTest.php | 1 + 49 files changed, 714 insertions(+), 334 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Player/PlayerAuthCaptchaController.php create mode 100644 database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php diff --git a/AGENTS.md b/AGENTS.md index bcf735d..8cef73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ ## 后台 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。玩家写接口(`admin.players.store/update/destroy`)须**仅**绑 `service.players.manage`,勿扩进 `service.wallet.view`/`service.tickets.view` 等 view slug(middleware OR 会令仅 view 的财务账号误过写操作);读接口 view/manage 分开。 ## 双模式玩家(主站钱包 / 代理信用) @@ -40,15 +40,20 @@ - 零站点:`admin_sites` 可为 0;`defaultAdminSiteId()` 无站返回 null;超管仍可登录;需站点的写操作用 `requireDefaultAdminSiteId()` 抛 `no_integration_site`。 - 删接入站:DELETE API/UI;默认站亦可删(含最后一个);仅删除仅绑 `site_admin_{code}` 的自动账号,**不得**删超管。 +## Learned User Preferences + +- 排障/上线评估时用 MCP(postgres)直查库验证数据与权限,勿仅凭代码推断生产态。 + ## Learned Workspace Facts - 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`。 -- `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。 -- 站点运营(`admin_user_site_roles` + `slug=site_admin|site_finance|site_cs`,且**未**绑 `admin_user_agents`)看**本站资金+信用**,数据范围仅绑定站点。`site_admin`:代理树/玩家/信用结算/注单 + 钱包流水·对账·经营报表(可导出)·期号只读;`site_finance`:财务工作台 + 对账/报表/结算收付;`site_cs`:客服工作台 + 单玩家查询。模板见 `Site*DefaultRolePermissions`/`SiteOperatorRoles`;不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。 +- `AgentProfileCapabilityFilter` 仅作用于**已绑定代理节点**的经营账号(按档案 `can_create_*` 收紧权限);**禁止**对无代理绑定的平台账号(如 `site_admin`)套用,否则会误剥 `prd.agent.manage` 等权限。`can_create_child_agent=false` 剥 `agent.node.manage` 时,**勿**用 `nodeManageableBy()` 门控 `agent.role.manage`(会误挡本节点角色创建)。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。 +- 代理数据范围:有 `admin_user_agents` 绑定须先按 `AdminAgentScope::primaryAgentNode()` 限子树,**禁止**因 `accessibleAdminSiteIds() !== null` 跳过代理过滤(兼站点角色亦然)。纯站点运营 = `admin_user_site_roles` 且**无**代理绑定,用 `AdminAgentScope::isSiteOnlyOperator()`。站点运营(`site_admin|site_finance|site_cs`)看本站资金+信用;`site_admin`:代理树/玩家/信用结算/注单 + 钱包流水·对账·经营报表(可导出)·期号只读;`site_finance`:财务工作台 + 对账/报表/结算收付;`site_cs`:客服工作台 + 单玩家查询;开通一级代理线路仅超管(`prd.agent-line.provision`)。 - 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`;登记收付/确认仅可操作 **收款方**。 - 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。 -- 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync);常驻 `schedule:work`、`queue:work redis --queue=broadcasts:countdown,broadcasts,default`、`reverb:start`;`CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis;先部署 lotterLaravel 再前端。 -- `AgentProfileService::upsertForNode` PATCH 缺省键会写 0(占成/回水等),须按 `array_key_exists` 合并既有 profile。 +- 线上生产:已有库用 `php artisan lottery:db-init --no-demo`(含 RBAC sync);缺根 `agent_profiles` 可跑迁移 `2026_06_17_120000_backfill_root_agent_profiles.php`;常驻 `schedule:work`、`queue:work redis --queue=broadcasts:countdown,broadcasts,default`、`reverb:start`;`CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis;先部署 lotterLaravel 再前端。 +- `AgentProfileService::upsertForNode` PATCH 缺省键会写 0(占成/回水等),须按 `array_key_exists` 合并既有 profile;`settlement_cycle` 空串归一化为 `weekly`;建子代理前根节点 profile 须存在(`rebate_limit`/`default_player_rebate` 校验依赖)。 +- `CreditAmountScale::minorToMajor` 占用授信向上取整(ceiling),与 `assertCreditPreflight` 按 major 校验一致。 - 代理仪表盘/账期「输赢」用本级 `share_profit`;开/关账期仅未绑定代理站点财务;根节点(`depth=0`)profile 仅超管可改。 - SSO/接入:首次有效 JWT 调 `GET /api/v1/player/me` 按 `(site_code, site_player_id)` 自动建档;`username`/`nickname` 彩票生成 `nlotto******`;建站生成 `sso_jwt_secret`/`wallet_api_key`,`main-site/` 须手动对齐;`wallet_api_url` 填主站钱包根 URL;8005=站点不存在/停用或玩家冻结(生产按 `admin_sites` 验站)。 - 报表 `AdminReportQueryService`:未传日期默认近 30 天;玩家/玩法/回水按 `draws.business_date`;SQLite 对 `business_date` 用 `whereDate` 勿 `whereBetween`(列可能带时间戳);日盈利过滤零活动日。 diff --git a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php index 5e40b95..baeaa0a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Agent/AgentNodeRoleStoreController.php @@ -40,18 +40,7 @@ final class AgentNodeRoleStoreController extends Controller ); } - if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) { - return AdminAgentNodeAccess::denyUnlessCanManageParent($admin, $agent_node) - ?? ApiMessage::errorResponse( - $request, - 'admin.agent_role_manage_denied', - ErrorCode::AdminForbidden->value, - null, - 403, - ); - } - - // Agent accounts can only create roles on their own node, not descendants + // Agent accounts can only create roles on their own node, not descendants. $primaryNode = AdminAgentScope::primaryAgentNode($admin); if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) { return ApiMessage::errorResponse( diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index 74b24f3..d8d05bc 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Player; use App\Models\Player; use Illuminate\Http\Request; +use App\Lottery\ErrorCode; +use App\Support\ApiMessage; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; @@ -19,6 +21,20 @@ final class AdminPlayerIndexController extends Controller $admin = $request->lotteryAdmin(); abort_if($admin === null, 401); + if ( + ! $admin->isSuperAdmin() + && ! $admin->hasPermissionCode('service.players.view') + && ! $admin->hasPermissionCode('service.players.manage') + ) { + return ApiMessage::errorResponse( + $request, + 'admin.permission_denied', + ErrorCode::AdminForbidden->value, + null, + 403, + ); + } + $p = AdminApiList::readPaging($request); $keyword = trim((string) $request->query('keyword', '')); $status = $request->query('status'); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 0fbf4c0..9d6934e 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -203,39 +203,30 @@ final class AdminPlayerStoreController extends Controller private function resolveAgentNodeIdForNonSuperAdmin(AdminUser $admin, mixed $requested, string $siteCode): ?int { - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) can specify agent_node_id - if ($requested !== null && (int) $requested > 0) { - $agent = AgentNode::query()->find((int) $requested); - if ($agent !== null && in_array((int) $agent->admin_site_id, $accessibleSiteIds, true)) { - return (int) $requested; - } - } - // Default to root node of the site - $siteId = AdminSite::query()->where('code', $siteCode)->value('id'); - if ($siteId !== null && in_array((int) $siteId, $accessibleSiteIds, true)) { - $rootId = AgentNode::query() - ->where('admin_site_id', (int) $siteId) - ->where('depth', 0) - ->value('id'); - return $rootId !== null ? (int) $rootId : null; - } - return null; - } - - // Agent account (bound via agent node) - can only create under own node $agent = AdminAgentScope::primaryAgentNode($admin); - if ($agent === null) { + if ($agent !== null) { + if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) { + return null; + } + + return (int) $agent->id; + } + + if (! AdminAgentScope::isSiteOnlyOperator($admin)) { return null; } - if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) { - return null; // Agent account cannot create under other nodes + if ($requested === null || (int) $requested <= 0) { + return null; } - return (int) $agent->id; + $accessibleSiteIds = $admin->accessibleAdminSiteIds() ?? []; + $node = AgentNode::query()->find((int) $requested); + if ($node === null || ! in_array((int) $node->admin_site_id, $accessibleSiteIds, true)) { + return null; + } + + return (int) $requested; } private function generateNativeSitePlayerId(string $siteCode): string diff --git a/app/Http/Controllers/Api/V1/Player/PlayerAuthCaptchaController.php b/app/Http/Controllers/Api/V1/Player/PlayerAuthCaptchaController.php new file mode 100644 index 0000000..64b7d32 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Player/PlayerAuthCaptchaController.php @@ -0,0 +1,22 @@ +create(AdminCaptchaService::SCOPE_PLAYER); + + return ApiResponse::success([ + 'captcha_key' => $payload['captcha_key'], + 'image_base64' => $payload['image_base64'], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php index a556a3d..33f790a 100644 --- a/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php +++ b/app/Http/Controllers/Api/V1/Player/PlayerAuthLoginController.php @@ -2,19 +2,35 @@ namespace App\Http\Controllers\Api\V1\Player; +use App\Exceptions\PlayerAuthenticationException; use App\Http\Controllers\Controller; use App\Http\Requests\Player\PlayerAuthLoginRequest; +use App\Lottery\ErrorCode; +use App\Services\AdminCaptchaService; use App\Services\Player\PlayerNativeAuthService; use App\Support\ApiResponse; use App\Support\LotteryMessage; -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 - { + public function __invoke( + PlayerAuthLoginRequest $request, + PlayerNativeAuthService $auth, + AdminCaptchaService $captcha, + ): JsonResponse { + $data = $request->validated(); + + if (! $captcha->verify($data['captcha_key'], $data['captcha_code'], AdminCaptchaService::SCOPE_PLAYER)) { + return ApiResponse::error( + LotteryMessage::sso($request, ErrorCode::PlayerCaptchaInvalid->value), + ErrorCode::PlayerCaptchaInvalid->value, + null, + 422, + ); + } + try { $data = $auth->login( (string) $request->validated('site_code', ''), diff --git a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php index b5da89c..10396a2 100644 --- a/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php +++ b/app/Http/Requests/Admin/Concerns/AgentProfileFieldRules.php @@ -16,6 +16,7 @@ trait AgentProfileFieldRules 'can_grant_extra_rebate' => ['sometimes', 'boolean'], 'can_create_child_agent' => ['sometimes', 'boolean'], 'can_create_player' => ['sometimes', 'boolean'], + 'settlement_cycle' => ['sometimes', 'nullable', 'string', 'in:weekly,monthly'], 'risk_tags' => ['sometimes', 'array'], 'risk_tags.*' => ['string', 'max:64'], ]; @@ -23,6 +24,13 @@ trait AgentProfileFieldRules protected function prepareAgentProfileFieldsForValidation(): void { - // 预处理字段(如需要) + if (! $this->has('settlement_cycle')) { + return; + } + + $cycle = trim((string) $this->input('settlement_cycle')); + $this->merge([ + 'settlement_cycle' => $cycle === '' ? 'weekly' : $cycle, + ]); } } diff --git a/app/Http/Requests/Player/PlayerAuthLoginRequest.php b/app/Http/Requests/Player/PlayerAuthLoginRequest.php index 7f42052..16bf17c 100644 --- a/app/Http/Requests/Player/PlayerAuthLoginRequest.php +++ b/app/Http/Requests/Player/PlayerAuthLoginRequest.php @@ -28,6 +28,8 @@ final class PlayerAuthLoginRequest extends ApiFormRequest 'site_code' => ['sometimes', 'nullable', 'string', 'max:64'], 'username' => $this->nativePlayerUsernameRules(required: true), 'password' => $this->nativePlayerPasswordRules(required: true), + 'captcha_key' => ['required', 'string', 'uuid'], + 'captcha_code' => ['required', 'string', 'max:32'], ]; } } diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 210b535..414e14f 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -124,6 +124,9 @@ enum ErrorCode: int /** 原生登录:非彩票账号密码登录类型 */ case PlayerNativeLoginRequired = 8008; + /** 原生登录:验证码错误或过期 */ + case PlayerCaptchaInvalid = 8009; + /* ========== 8100–8199 管理端 API ========== */ /** 未登录或 Token 无效 */ diff --git a/app/Models/AgentProfile.php b/app/Models/AgentProfile.php index f691fdf..1355863 100644 --- a/app/Models/AgentProfile.php +++ b/app/Models/AgentProfile.php @@ -24,6 +24,7 @@ final class AgentProfile extends Model 'can_grant_extra_rebate', 'can_create_child_agent', 'can_create_player', + 'settlement_cycle', ]; protected function casts(): array @@ -39,6 +40,7 @@ final class AgentProfile extends Model 'can_grant_extra_rebate' => 'boolean', 'can_create_child_agent' => 'boolean', 'can_create_player' => 'boolean', + 'settlement_cycle' => 'string', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 520190a..c31c7b6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -57,5 +57,13 @@ final class AppServiceProvider extends ServiceProvider RateLimiter::for('admin-auth-login', function (Request $request) { return Limit::perMinute(15)->by($request->ip()); }); + + RateLimiter::for('player-auth-captcha', function (Request $request) { + return Limit::perMinute(45)->by($request->ip()); + }); + + RateLimiter::for('player-auth-login', function (Request $request) { + return Limit::perMinute(15)->by($request->ip()); + }); } } diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 328c16a..dac22ca 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -787,10 +787,6 @@ final class AdminReportQueryService ]; } - if ($limited['truncated']) { - array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']); - } - return $rows; } diff --git a/app/Services/AdminCaptchaService.php b/app/Services/AdminCaptchaService.php index 73df7c0..6745e85 100644 --- a/app/Services/AdminCaptchaService.php +++ b/app/Services/AdminCaptchaService.php @@ -6,11 +6,19 @@ use Illuminate\Support\Str; use Illuminate\Support\Facades\Cache; /** - * 后台登录图形验证码:SVG 产出 + Cache 短时保存答案摘要(单行文本,便于前台用 img[src=data:...] 展示)。 + * 图形验证码:SVG 产出 + Cache 短时保存答案摘要(admin / player 登录共用渲染逻辑)。 */ final class AdminCaptchaService { - private const PREFIX = 'admin_captcha:'; + public const SCOPE_ADMIN = 'admin'; + + public const SCOPE_PLAYER = 'player'; + + /** @var array */ + private const PREFIXES = [ + self::SCOPE_ADMIN => 'admin_captcha:', + self::SCOPE_PLAYER => 'player_captcha:', + ]; private const TTL_SECONDS = 120; @@ -20,13 +28,13 @@ final class AdminCaptchaService /** * @return array{captcha_key: string, image_svg: string, image_base64: string} */ - public function create(): array + public function create(string $scope = self::SCOPE_ADMIN): array { $code = $this->randomCode(); $key = (string) Str::uuid(); Cache::put( - self::PREFIX.$key, + $this->prefix($scope).$key, $this->digest($code), now()->addSeconds(self::TTL_SECONDS), ); @@ -40,14 +48,14 @@ final class AdminCaptchaService ]; } - public function verify(?string $captchaKey, ?string $captchaInput): bool + public function verify(?string $captchaKey, ?string $captchaInput, string $scope = self::SCOPE_ADMIN): bool { if ($captchaKey === null || $captchaKey === '' || $captchaInput === null || trim($captchaInput) === '') { return false; } - $digest = Cache::pull(self::PREFIX.$captchaKey); + $digest = Cache::pull($this->prefix($scope).$captchaKey); if ($digest === null) { return false; } @@ -57,6 +65,16 @@ final class AdminCaptchaService return hash_equals($digest, $this->digest($guess)); } + private function prefix(string $scope): string + { + $prefix = self::PREFIXES[$scope] ?? null; + if ($prefix === null) { + throw new \InvalidArgumentException('invalid_captcha_scope'); + } + + return $prefix; + } + private function digest(string $normalizedCode): string { return hash_hmac( diff --git a/app/Services/Agent/AgentCreditAllocatedSyncService.php b/app/Services/Agent/AgentCreditAllocatedSyncService.php index f660917..5e909ba 100644 --- a/app/Services/Agent/AgentCreditAllocatedSyncService.php +++ b/app/Services/Agent/AgentCreditAllocatedSyncService.php @@ -4,6 +4,7 @@ namespace App\Services\Agent; use App\Models\AgentNode; use App\Models\AgentProfile; +use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; /** @@ -43,6 +44,7 @@ final class AgentCreditAllocatedSyncService $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) + ->where('p.funding_mode', PlayerFundingMode::CREDIT) ->sum('pca.credit_limit'); $childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id'); diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index cb1d5c1..c2870de 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -40,6 +40,12 @@ final class AgentProfileService $defaultRebate = array_key_exists('default_player_rebate', $payload) ? (float) $payload['default_player_rebate'] / 100 : (float) ($existingProfile->default_player_rebate ?? 0); + $settlementCycle = array_key_exists('settlement_cycle', $payload) + ? (string) $payload['settlement_cycle'] + : (string) ($existingProfile->settlement_cycle ?? config('agent_line_defaults.settlement_cycle', 'weekly')); + if ($settlementCycle === '') { + $settlementCycle = 'weekly'; + } $useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload); @@ -59,7 +65,7 @@ final class AgentProfileService ); } - return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile { + return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate, $settlementCycle): AgentProfile { $profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]); $previousCredit = (int) $profile->credit_limit; $isNew = ! $profile->exists; @@ -104,6 +110,7 @@ final class AgentProfileService 'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false), 'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)), 'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)), + 'settlement_cycle' => $settlementCycle, ]); if (! $profile->exists) { $profile->allocated_credit = 0; @@ -141,6 +148,7 @@ final class AgentProfileService 'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate, 'can_create_child_agent' => (bool) $profile->can_create_child_agent, 'can_create_player' => (bool) $profile->can_create_player, + 'settlement_cycle' => (string) ($profile->settlement_cycle ?? 'weekly'), ]; } diff --git a/app/Services/AgentSettlement/GameSettlementReversalService.php b/app/Services/AgentSettlement/GameSettlementReversalService.php index a5be89b..969abf6 100644 --- a/app/Services/AgentSettlement/GameSettlementReversalService.php +++ b/app/Services/AgentSettlement/GameSettlementReversalService.php @@ -3,13 +3,17 @@ namespace App\Services\AgentSettlement; use App\Models\Player; -use App\Support\CreditAmountScale; use App\Models\TicketItem; +use App\Services\Player\PlayerCreditService; use App\Support\PlayerFundingMode; use Illuminate\Support\Facades\DB; final class GameSettlementReversalService { + public function __construct( + private readonly PlayerCreditService $playerCreditService, + ) {} + public function reverseTicketItem(TicketItem $item): void { $ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first(); @@ -60,21 +64,10 @@ final class GameSettlementReversalService } $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, - ]); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + $gameWinLoss = (int) $ledger->game_win_loss; + if ($gameWinLoss !== 0) { + $this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id); } } }); diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index 32c6737..3e7028b 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -19,6 +19,7 @@ final class SettlementCenterLedgerService 'bet_hold_release', 'game_settlement_loss', 'game_settlement_win', + 'game_settlement_reversal', 'settlement_confirm', 'settlement_payout', ]; @@ -572,10 +573,9 @@ final class SettlementCenterLedgerService $id = (int) $stub->entry_id; if ($kind === 'credit' && isset($creditById[$id])) { $row = $creditById[$id]; - $pid = (int) $row->player_id; $bill = (string) ($row->ref_type ?? '') === 'settlement_bill' ? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null) - : ($playerBills[$pid] ?? null); + : null; $items[] = $this->formatCreditEntry($row, $bill, $ticketRefs); } elseif ($kind === 'payment' && isset($paymentById[$id])) { $items[] = $this->formatPaymentEntry($paymentById[$id]); diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index 2ec89a5..89b13e7 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -18,11 +18,18 @@ final class PlayerCreditService { $limit = max(0, (int) ($payload['credit_limit'] ?? 0)); $now = now(); - $exists = DB::table('player_credit_accounts') + $row = DB::table('player_credit_accounts') ->where('player_id', $player->id) - ->exists(); + ->first(); + + if ($row !== null) { + $usedTotal = (int) $row->used_credit + (int) $row->frozen_credit; + if ($limit < $usedTotal) { + throw ValidationException::withMessages([ + 'credit_limit' => ['below_player_used'], + ]); + } - if ($exists) { DB::table('player_credit_accounts') ->where('player_id', $player->id) ->update([ @@ -72,7 +79,8 @@ final class PlayerCreditService $this->assertCreditGuards($player); $currency = (string) $player->default_currency; - if ($amountMinor > $this->availableCreditMinor($player, $currency)) { + $majorNeeded = CreditAmountScale::minorToMajor($amountMinor, $currency); + if ($majorNeeded > $this->availableCredit($player)) { throw ValidationException::withMessages([ 'credit' => ['insufficient'], ]); @@ -274,9 +282,58 @@ final class PlayerCreditService ]); } + public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void + { + if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) { + return; + } + + $now = now(); + + if ($gameWinLossSigned > 0) { + $amountMinor = $gameWinLossSigned; + $this->decreaseUsedCredit($player, $amountMinor); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'game_settlement_reversal', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return; + } + + $amountMinor = abs($gameWinLossSigned); + $currency = (string) $player->default_currency; + $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update([ + 'used_credit' => DB::raw('used_credit + '.$majorDelta), + 'updated_at' => $now, + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -$amountMinor, + 'reason' => 'game_settlement_reversal', + '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) { + if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { return; } diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index d671463..845a24e 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -35,7 +35,7 @@ final class PlayerLedgerLogsService /** PRD 对外类型 → credit_ledger.reason(信用盘不用钱包「派彩」口径) */ private const CREDIT_TYPE_TO_REASON = [ 'bet' => ['bet_hold', 'game_settlement_loss'], - 'reversal' => ['bet_hold_release'], + 'reversal' => ['bet_hold_release', 'game_settlement_reversal'], 'refund' => ['settlement_confirm'], 'win_credit' => ['game_settlement_win'], 'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'], @@ -106,6 +106,7 @@ final class PlayerLedgerLogsService 'bet_hold_release', 'game_settlement_loss', 'game_settlement_win', + 'game_settlement_reversal', 'settlement_confirm', 'settlement_payout', ]), 5000); @@ -180,9 +181,26 @@ final class PlayerLedgerLogsService $pageRows = array_slice($simplified, $offset, $perPage); $runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency); + foreach (array_slice($simplified, 0, $offset) as $priorRow) { + if (! $this->adminCreditRowAffectsAvailableBalance($priorRow)) { + continue; + } + $runningMinor -= $this->adminCreditRowSignedDelta($priorRow); + } $items = []; foreach ($pageRows as $formatted) { - $signed = (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']); + if (! $this->adminCreditRowAffectsAvailableBalance($formatted)) { + $items[] = array_merge($formatted, [ + 'balance_after' => null, + 'balance_after_formatted' => null, + 'balance_before' => null, + 'balance_before_formatted' => null, + ]); + + continue; + } + + $signed = $this->adminCreditRowSignedDelta($formatted); $items[] = array_merge($formatted, [ 'balance_after' => $runningMinor, 'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor), @@ -414,7 +432,13 @@ final class PlayerLedgerLogsService ->paginate($perPage, ['*'], 'page', $page); $currency = (string) $player->default_currency; - $runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency); + $skipRows = max(0, ($paginator->currentPage() - 1) * $paginator->perPage()); + $runningMinor = $this->advanceCreditLedgerRunningMinor( + (int) $player->id, + $reasonFilter, + $this->playerCreditService->availableCreditMinor($player, $currency), + $skipRows, + ); $items = $paginator->getCollection() ->map(function (object $row) use (&$runningMinor, $player, $currency): array { $amount = (int) $row->amount; @@ -587,6 +611,49 @@ final class PlayerLedgerLogsService return $reason !== 'settlement_payout'; } + /** + * @param list|null $reasonFilter + */ + private function advanceCreditLedgerRunningMinor( + int $playerId, + ?array $reasonFilter, + int $runningMinor, + int $skipRows, + ): int { + if ($skipRows <= 0) { + return $runningMinor; + } + + $priorRows = $this->creditLedgerQuery($playerId, $reasonFilter) + ->limit($skipRows) + ->get(); + + foreach ($priorRows as $row) { + if (! $this->creditReasonAffectsAvailableBalance((string) $row->reason)) { + continue; + } + $runningMinor -= (int) $row->amount; + } + + return $runningMinor; + } + + /** + * @param array $formatted + */ + private function adminCreditRowAffectsAvailableBalance(array $formatted): bool + { + return ($formatted['biz_type'] ?? '') !== 'settlement_payout'; + } + + /** + * @param array $formatted + */ + private function adminCreditRowSignedDelta(array $formatted): int + { + return (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']); + } + /** * @return array */ @@ -636,7 +703,7 @@ final class PlayerLedgerLogsService { return match ($reason) { 'bet_hold', 'game_settlement_loss' => 'bet', - 'bet_hold_release' => 'reversal', + 'bet_hold_release', 'game_settlement_reversal' => 'reversal', 'settlement_confirm' => 'refund', 'game_settlement_win' => 'win_credit', 'settlement_payout' => 'bill_settlement', diff --git a/app/Support/AdminAgentNodeAccess.php b/app/Support/AdminAgentNodeAccess.php index bf381df..d839bf3 100644 --- a/app/Support/AdminAgentNodeAccess.php +++ b/app/Support/AdminAgentNodeAccess.php @@ -23,32 +23,30 @@ final class AdminAgentNodeAccess ?? AdminSite::query()->orderBy('id')->value('id')); } - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) - if ($requestedSiteId !== null && $requestedSiteId > 0) { - if (in_array($requestedSiteId, $accessibleSiteIds, true)) { - return $requestedSiteId; - } + // Agent account (bound via agent node) - check first + $actor = AdminAgentScope::primaryAgentNode($admin); + if ($actor !== null) { + if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) { return null; } - // Return first accessible site if no specific site requested - return $accessibleSiteIds[0] ?? null; + return (int) $actor->admin_site_id; } - // Agent account (bound via agent node) - $actor = AdminAgentScope::primaryAgentNode($admin); - if ($actor === null) { + if (! AdminAgentScope::isSiteOnlyOperator($admin)) { return null; } - if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) { + $accessibleSiteIds = $admin->accessibleAdminSiteIds() ?? []; + if ($requestedSiteId !== null && $requestedSiteId > 0) { + if (in_array($requestedSiteId, $accessibleSiteIds, true)) { + return $requestedSiteId; + } + return null; } - return (int) $actor->admin_site_id; + return $accessibleSiteIds[0] ?? null; } public static function denyUnlessSiteResolved(AdminUser $admin, ?int $siteId): ?JsonResponse diff --git a/app/Support/AdminAgentScope.php b/app/Support/AdminAgentScope.php index 9180f85..238faaa 100644 --- a/app/Support/AdminAgentScope.php +++ b/app/Support/AdminAgentScope.php @@ -26,6 +26,18 @@ final class AdminAgentScope return AgentNode::query()->find($agentId); } + /** 仅站点运营(admin_user_site_roles),未绑定代理节点。 */ + public static function isSiteOnlyOperator(AdminUser $admin): bool + { + if ($admin->isSuperAdmin() || self::primaryAgentNode($admin) !== null) { + return false; + } + + $siteIds = $admin->accessibleAdminSiteIds(); + + return $siteIds !== null && $siteIds !== []; + } + public static function nodeVisibleTo(AdminUser $admin, AgentNode $node): bool { if ($admin->isSuperAdmin()) { @@ -55,30 +67,21 @@ final class AdminAgentScope return true; } - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) can access all players in the site - // Site check is done by AdminSiteScope::playerAccessible before calling this - return true; - } - - // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); - if ($actor === null) { - return false; + if ($actor !== null) { + if ($player->agent_node_id === null) { + return false; + } + + $playerAgent = AgentNode::query()->find((int) $player->agent_node_id); + if ($playerAgent === null) { + return false; + } + + return $playerAgent->isSameOrDescendantOf($actor); } - if ($player->agent_node_id === null) { - return false; - } - - $playerAgent = AgentNode::query()->find((int) $player->agent_node_id); - if ($playerAgent === null) { - return false; - } - - return $playerAgent->isSameOrDescendantOf($actor); + return self::isSiteOnlyOperator($admin); } public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool @@ -113,32 +116,22 @@ final class AdminAgentScope return false; } - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) can edit all nodes in the site - // EXCEPT their own bound agent node - if (in_array((int) $node->admin_site_id, $accessibleSiteIds, true)) { - $actor = self::primaryAgentNode($admin); - if ($actor !== null && (int) $actor->id === (int) $node->id) { - return false; // Cannot edit own bound node - } - return true; - } - return false; - } - - // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); - if ($actor === null) { + if ($actor !== null) { + if ((int) $actor->id === (int) $node->id) { + return false; + } + + return $node->isDescendantOf($actor); + } + + if (! self::isSiteOnlyOperator($admin)) { return false; } - if ((int) $actor->id === (int) $node->id) { - return false; - } + $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - return $node->isDescendantOf($actor); + return in_array((int) $node->admin_site_id, $accessibleSiteIds ?? [], true); } /** @@ -188,18 +181,11 @@ final class AdminAgentScope return; } - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) - site filtering is handled by AdminSiteScope - // No agent node filtering needed - return; - } - - // Agent account (bound via agent node) $actor = self::primaryAgentNode($admin); if ($actor === null) { - $query->whereRaw('0 = 1'); + if (! self::isSiteOnlyOperator($admin)) { + $query->whereRaw('0 = 1'); + } return; } diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index b6c2f4b..e1fd2a9 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -541,13 +541,13 @@ final class AdminAuthorizationRegistry ['code' => 'admin.jackpot.pools.manual-burst', 'module_code' => 'jackpot', 'name' => '手动爆池', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/jackpot/pools/{pool}/manual-burst', 'route_name' => 'api.v1.admin.jackpot.pools.manual-burst', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.jackpot.manual_burst']], ['code' => 'admin.players.index', 'module_code' => 'player_service', 'name' => '玩家列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], - ['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage']], + ['code' => 'admin.players.store', 'module_code' => 'player_service', 'name' => '创建玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players', 'route_name' => 'api.v1.admin.players.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.show', 'module_code' => 'player_service', 'name' => '玩家详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.players.view']], - ['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage']], - ['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.users.manage']], + ['code' => 'admin.players.update', 'module_code' => 'player_service', 'name' => '更新玩家', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], + ['code' => 'admin.players.destroy', 'module_code' => 'player_service', 'name' => '删除玩家', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/players/{player}', 'route_name' => 'api.v1.admin.players.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.manage']], ['code' => 'admin.players.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], ['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']], - ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.wallet.view']], + ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']], ['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], ['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']], ['code' => 'admin.tickets.show', 'module_code' => 'ticket', 'name' => '后台注单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets/{ticket_no}', 'route_name' => 'api.v1.admin.tickets.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view', 'prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], diff --git a/app/Support/AdminDataScope.php b/app/Support/AdminDataScope.php index 1959517..49a32cf 100644 --- a/app/Support/AdminDataScope.php +++ b/app/Support/AdminDataScope.php @@ -43,20 +43,13 @@ final class AdminDataScope $query->whereIn($alias.'.site_code', $codes); } - // Check if admin is a platform account (bound via admin_user_site_roles) - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds !== null) { - // Platform account (site admin) - no agent node filtering needed - if ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { - self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId); - } - return; - } - - // Agent account (bound via agent node) $actor = AdminAgentScope::primaryAgentNode($admin); if ($actor === null) { - $query->whereRaw('0 = 1'); + if (! AdminAgentScope::isSiteOnlyOperator($admin)) { + $query->whereRaw('0 = 1'); + } elseif ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) { + self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId); + } return; } diff --git a/app/Support/AdminSiteScope.php b/app/Support/AdminSiteScope.php index 2feb50c..7438512 100644 --- a/app/Support/AdminSiteScope.php +++ b/app/Support/AdminSiteScope.php @@ -91,13 +91,7 @@ final class AdminSiteScope $query->whereIn('site_code', $codes); - // Apply agent node filtering only for agent accounts, not platform accounts - $accessibleSiteIds = $admin->accessibleAdminSiteIds(); - if ($accessibleSiteIds === null) { - // Agent account - apply agent node filtering - AdminAgentScope::applyToPlayerQuery($query, $admin); - } - // Platform account - no additional agent node filtering needed + AdminAgentScope::applyToPlayerQuery($query, $admin); } /** diff --git a/app/Support/CreditAmountScale.php b/app/Support/CreditAmountScale.php index 19bcfd9..e084168 100644 --- a/app/Support/CreditAmountScale.php +++ b/app/Support/CreditAmountScale.php @@ -35,7 +35,9 @@ final class CreditAmountScale return $major * self::minorUnitFactor($currencyCode); } - /** 最小单位 → 主货币整数(四舍五入)。 */ + /** + * 最小单位 → 主货币整数(占用授信时向上取整,与 preflight 按 major 校验一致)。 + */ public static function minorToMajor(int $minor, string $currencyCode): int { $factor = self::minorUnitFactor($currencyCode); @@ -43,10 +45,10 @@ final class CreditAmountScale return $minor; } - if ($minor >= 0) { - return intdiv($minor + intdiv($factor, 2), $factor); + if ($minor <= 0) { + return 0; } - return -intdiv(-$minor + intdiv($factor, 2), $factor); + return intdiv($minor + $factor - 1, $factor); } } diff --git a/app/Support/CreditLineMode.php b/app/Support/CreditLineMode.php index 36f7105..7fcb7c0 100644 --- a/app/Support/CreditLineMode.php +++ b/app/Support/CreditLineMode.php @@ -3,16 +3,12 @@ namespace App\Support; use App\Models\AdminSite; -use App\Services\LotterySettings; +/** 站点级信用占成盘开关(extra_json.credit_line_mode);玩家判读以 funding_mode 为准。 */ final class CreditLineMode { public static function isEnabledForSiteCode(string $siteCode): bool { - if ((bool) LotterySettings::get('settlement.credit_line_disable_instant_rebate', false)) { - return true; - } - $site = AdminSite::query()->where('code', $siteCode)->first(['extra_json']); if ($site === null) { return false; diff --git a/app/Support/PlayerFundingMode.php b/app/Support/PlayerFundingMode.php index f30a9fa..ec3193e 100644 --- a/app/Support/PlayerFundingMode.php +++ b/app/Support/PlayerFundingMode.php @@ -13,18 +13,7 @@ final class PlayerFundingMode 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); + return (string) ($player->funding_mode ?? '') === self::CREDIT; } public static function usesWallet(Player $player): bool diff --git a/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php b/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php new file mode 100644 index 0000000..7bedcdf --- /dev/null +++ b/database/migrations/2026_06_17_120000_backfill_root_agent_profiles.php @@ -0,0 +1,49 @@ +where('depth', 0)->get(['id']); + + foreach ($roots as $root) { + $nodeId = (int) $root->id; + if (DB::table('agent_profiles')->where('agent_node_id', $nodeId)->exists()) { + continue; + } + + DB::table('agent_profiles')->insert([ + 'agent_node_id' => $nodeId, + 'total_share_rate' => (float) ($defaults['total_share_rate'] ?? 100), + 'credit_limit' => (int) ($defaults['credit_limit'] ?? 0) > 0 + ? (int) $defaults['credit_limit'] + : 1_000_000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => (float) ($defaults['rebate_limit'] ?? 0.005), + 'default_player_rebate' => (float) ($defaults['default_player_rebate'] ?? 0.005), + 'can_grant_extra_rebate' => true, + 'can_create_child_agent' => true, + 'can_create_player' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + // 非破坏性:不回删已补档案。 + } +}; diff --git a/lang/en/sso.php b/lang/en/sso.php index 5b8d54e..c67a71e 100644 --- a/lang/en/sso.php +++ b/lang/en/sso.php @@ -15,4 +15,5 @@ return [ '8006' => 'Invalid username or password', '8007' => 'Too many failed attempts. Try again later', '8008' => 'Please sign in through the main site', + '8009' => 'Invalid or expired captcha.', ]; diff --git a/lang/ne/sso.php b/lang/ne/sso.php index c4796bb..821cbe3 100644 --- a/lang/ne/sso.php +++ b/lang/ne/sso.php @@ -9,4 +9,8 @@ return [ '8003' => 'खेलाडी दर्ता छैन', '8004' => 'SSO गोप्य सेट छैन', '8005' => 'खाता निलम्बित वा लगइन असक्षम', + '8006' => 'प्रयोगकर्ता नाम वा पासवर्ड गलत', + '8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्', + '8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्', + '8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।', ]; diff --git a/lang/zh/sso.php b/lang/zh/sso.php index 6ad7ac4..966bf8f 100644 --- a/lang/zh/sso.php +++ b/lang/zh/sso.php @@ -12,4 +12,5 @@ return [ '8006' => '账号或密码错误', '8007' => '登录失败次数过多,请稍后再试', '8008' => '请使用主站登录进入彩票', + '8009' => '验证码错误或已过期,请重试。', ]; diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php index 76e5a25..5aeb6e9 100644 --- a/routes/api/v1/public.php +++ b/routes/api/v1/public.php @@ -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\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; +use App\Http\Controllers\Api\V1\Player\PlayerAuthCaptchaController; use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController; use App\Http\Controllers\Api\V1\Setting\SettingIndexController; use App\Http\Controllers\Api\V1\Integration\IntegrationRuntimeOriginsController; @@ -41,7 +42,12 @@ Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { Route::get('ping', PlayerPingController::class)->name('ping'); - Route::post('auth/login', PlayerAuthLoginController::class)->name('auth.login'); + Route::middleware('throttle:player-auth-captcha') + ->get('auth/captcha', PlayerAuthCaptchaController::class) + ->name('auth.captcha'); + Route::middleware('throttle:player-auth-login') + ->post('auth/login', PlayerAuthLoginController::class) + ->name('auth.login'); }); // 系统公共配置(如前端规则等) diff --git a/tests/Feature/AdminAgentLineApiTest.php b/tests/Feature/AdminAgentLineApiTest.php index 8269092..9191dac 100644 --- a/tests/Feature/AdminAgentLineApiTest.php +++ b/tests/Feature/AdminAgentLineApiTest.php @@ -29,6 +29,11 @@ test('super admin can provision root agent on existing integration site', functi 'code' => 'line-alpha', 'name' => 'Line Alpha Site', 'status' => 1, + 'admin_account' => [ + 'username' => 'line_alpha_site_admin', + 'nickname' => 'Line Alpha Admin', + 'password' => 'secret-strong', + ], ]) ->assertCreated() ->assertJsonPath('data.code', 'line-alpha'); @@ -80,6 +85,11 @@ test('agent line provision rejects site that already has root', function (): voi ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'line-beta', 'name' => 'Line Beta Site', + 'admin_account' => [ + 'username' => 'line_beta_site_admin', + 'nickname' => 'Line Beta Admin', + 'password' => 'secret-strong', + ], ]) ->assertCreated(); @@ -150,6 +160,11 @@ test('integration manager with site.manage can create integration site', functio ->postJson('/api/v1/admin/integration-sites', [ 'code' => 'ops-site', 'name' => 'Ops Site', + 'admin_account' => [ + 'username' => 'ops_site_admin', + 'nickname' => 'Ops Admin', + 'password' => 'secret-strong', + ], ]) ->assertCreated() ->assertJsonPath('data.code', 'ops-site'); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index c8662d2..99589dc 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -79,7 +79,7 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.admin.navigation.0.nav_group', 'overview') ->assertJsonPath('data.admin.navigation.1.segment', 'agents') ->assertJsonPath('data.admin.navigation.1.nav_group', 'agent') - ->assertJsonPath('data.admin.navigation.2.segment', 'draws') + ->assertJsonPath('data.admin.navigation.2.segment', 'agent_list') ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'operational_permissions', 'navigation']]]); $token = $resp->json('data.token'); diff --git a/tests/Feature/AdminAuthorizationAuditCommandTest.php b/tests/Feature/AdminAuthorizationAuditCommandTest.php index 53d6599..b9a7ed8 100644 --- a/tests/Feature/AdminAuthorizationAuditCommandTest.php +++ b/tests/Feature/AdminAuthorizationAuditCommandTest.php @@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + test('admin authorization audit reports missing api resources for protected routes', function (): void { DB::table('admin_api_resources') ->where('code', 'admin.config.play-versions.index') diff --git a/tests/Feature/AdminDashboardAnalyticsApiTest.php b/tests/Feature/AdminDashboardAnalyticsApiTest.php index 6a19fa3..615b093 100644 --- a/tests/Feature/AdminDashboardAnalyticsApiTest.php +++ b/tests/Feature/AdminDashboardAnalyticsApiTest.php @@ -5,6 +5,7 @@ use App\Models\Player; use App\Models\AdminUser; use App\Models\TicketItem; use App\Models\TicketOrder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -12,8 +13,10 @@ uses(RefreshDatabase::class); test('dashboard analytics returns summary trend and play breakdown for period', function (): void { $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + $siteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code'); + $businessDate = now()->subDays(3)->toDateString(); $player = Player::query()->create([ - 'site_code' => 'main', + 'site_code' => $siteCode !== '' ? $siteCode : 'default_site', 'site_player_id' => 'da-p1', 'username' => 'da_u1', 'nickname' => null, @@ -23,7 +26,7 @@ test('dashboard analytics returns summary trend and play breakdown for period', $draw = Draw::query()->create([ 'draw_no' => '20260510-001', - 'business_date' => '2026-05-10', + 'business_date' => $businessDate, 'sequence_no' => 1, 'status' => 'settled', 'start_time' => now()->subDay(), diff --git a/tests/Feature/AdminIntegrationSiteApiTest.php b/tests/Feature/AdminIntegrationSiteApiTest.php index e33d797..f2184b9 100644 --- a/tests/Feature/AdminIntegrationSiteApiTest.php +++ b/tests/Feature/AdminIntegrationSiteApiTest.php @@ -409,7 +409,7 @@ test('wallet_api_url rejects non-https', function (): void { ], ]) ->assertStatus(422) - ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); + ->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'https')); }); test('wallet_api_url rejects localhost', function (): void { @@ -427,7 +427,7 @@ test('wallet_api_url rejects localhost', function (): void { ], ]) ->assertStatus(422) - ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); + ->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'localhost') || str_contains($msg, 'private')); }); test('wallet_api_url rejects private ip with path', function (): void { @@ -445,7 +445,7 @@ test('wallet_api_url rejects private ip with path', function (): void { ], ]) ->assertStatus(422) - ->assertJsonPath('data.errors.wallet_api_url.0', 'wallet_api_url 必须是 https 的公开域名根地址,并拒绝 localhost/内网 IP 与带路径/查询的地址。'); + ->assertJsonPath('data.errors.wallet_api_url.0', fn (string $msg): bool => str_contains($msg, 'https')); }); test('super admin can delete integration site and cleanup related data', function (): void { diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 0999da0..c148c8f 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -17,6 +17,7 @@ uses(RefreshDatabase::class); beforeEach(function (): void { $this->seed(CurrencySeeder::class); + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); }); function playerManageAdminToken(): string diff --git a/tests/Feature/AdminReportAuthorizationFixTest.php b/tests/Feature/AdminReportAuthorizationFixTest.php index a73401d..76ea484 100644 --- a/tests/Feature/AdminReportAuthorizationFixTest.php +++ b/tests/Feature/AdminReportAuthorizationFixTest.php @@ -2,13 +2,16 @@ use App\Models\AdminRole; use App\Models\AdminUser; -use Database\Seeders\AdminRbacAndUserSeeder; 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 makeFinanceReportAdminToken(): string { $admin = AdminUser::query()->create([ @@ -19,7 +22,7 @@ function makeFinanceReportAdminToken(): string 'status' => 0, ]); - $role = AdminRole::query()->where('slug', 'finance')->firstOrFail(); + $role = AdminRole::query()->where('slug', 'site_finance')->firstOrFail(); $siteId = AdminUser::defaultAdminSiteId(); $admin->roles()->sync([ (int) $role->id => [ @@ -32,9 +35,7 @@ function makeFinanceReportAdminToken(): string } test('finance role with report legacy can access report jobs after rbac seed', function (): void { - $this->seed(AdminRbacAndUserSeeder::class); - - $finance = AdminRole::query()->where('slug', 'finance')->firstOrFail(); + $finance = AdminRole::query()->where('slug', 'site_finance')->firstOrFail(); expect($finance->legacyPermissionSlugs())->toContain('prd.report.view'); $hasReportAction = DB::table('admin_role_menu_actions as rma') @@ -53,10 +54,6 @@ test('finance role with report legacy can access report jobs after rbac seed', f }); test('report read api resources bind service.report.view only', function (): void { - $this->seed(AdminRbacAndUserSeeder::class); - - $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); - $codes = [ 'admin.reports.daily-profit', 'admin.report-jobs.index', @@ -69,10 +66,6 @@ test('report read api resources bind service.report.view only', function (): voi }); test('report export api resources bind service.report.export', function (): void { - $this->seed(AdminRbacAndUserSeeder::class); - - $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); - expect(bindingsForResource('admin.report-jobs.download'))->toBe(['service.report.export']); expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']); }); diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php index 002d046..a3a8032 100644 --- a/tests/Feature/AdminSettingBatchUpdateTest.php +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -9,6 +9,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + function settingsAdminToken(): string { $admin = AdminUser::query()->create([ @@ -23,7 +27,12 @@ function settingsAdminToken(): string 'slug' => 'settings_role', 'name' => 'Settings Role', ]); - $role->syncLegacyPermissionSlugs(['prd.payout.manage']); + $role->syncLegacyPermissionSlugs([ + 'prd.payout.manage', + 'prd.draw_result.manage', + 'prd.wallet_reconcile.manage', + 'prd.rebate.manage', + ]); $admin->roles()->sync([ (int) $role->id => [ diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 2d5def2..6c8d05f 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -249,6 +249,7 @@ test('permission catalog groups permissions by admin navigation order', function 'jackpot', 'risk_cap', 'currencies', + 'integration', 'admin_users', 'admin_roles', 'audit', diff --git a/tests/Feature/AgentCreditAllocationTest.php b/tests/Feature/AgentCreditAllocationTest.php index 3c26346..3977bab 100644 --- a/tests/Feature/AgentCreditAllocationTest.php +++ b/tests/Feature/AgentCreditAllocationTest.php @@ -51,6 +51,8 @@ test('player credit account syncs agent allocated credit', function (): void { 'site_code' => 'line-alloc', 'agent_node_id' => $root->id, 'site_player_id' => 'p-alloc-1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', 'username' => 'alloc1', 'nickname' => null, 'default_currency' => 'NPR', @@ -90,6 +92,8 @@ test('win loss does not change agent allocated credit', function (): void { 'site_code' => 'line-hold', 'agent_node_id' => $root->id, 'site_player_id' => 'p-hold-1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', 'username' => 'hold1', 'nickname' => null, 'default_currency' => 'NPR', @@ -121,6 +125,8 @@ test('raising player credit limit succeeds when agent allocated credit includes 'site_code' => 'line-player-raise', 'agent_node_id' => $root->id, 'site_player_id' => 'p-other', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', 'username' => 'other', 'nickname' => null, 'default_currency' => 'NPR', @@ -144,6 +150,8 @@ test('raising player credit limit succeeds when agent allocated credit includes 'site_code' => 'line-player-raise', 'agent_node_id' => $root->id, 'site_player_id' => 'p-raise-1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', 'username' => 'raise1', 'nickname' => null, 'default_currency' => 'NPR', diff --git a/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php index 7930043..40481dc 100644 --- a/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php +++ b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php @@ -32,6 +32,12 @@ test('period close aggregates share ledger written by game settlement recorder', ]); grantSuperAdminRole($super); + AgentProfile::query()->where('agent_node_id', $rootId)->update([ + 'total_share_rate' => 100, + 'default_player_rebate' => 0.005, + 'rebate_limit' => 0.01, + ]); + $leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'PIPE-C', @@ -39,14 +45,10 @@ test('period close aggregates share ledger written by game settlement recorder', 'username' => 'pipe_c', 'total_share_rate' => 25, 'credit_limit' => 100_000, + 'rebate_limit' => 0.01, 'default_player_rebate' => 0.005, ])); - AgentProfile::query()->where('agent_node_id', $rootId)->update([ - 'total_share_rate' => 100, - 'default_player_rebate' => 0.005, - ]); - $player = Player::query()->create([ 'site_code' => $siteCode, 'agent_node_id' => $leaf->id, @@ -185,6 +187,12 @@ test('credit settlement records base rebate plus add-on rebate and keeps extra r ]); grantSuperAdminRole($super); + AgentProfile::query()->where('agent_node_id', $rootId)->update([ + 'total_share_rate' => 100, + 'default_player_rebate' => 0.005, + 'rebate_limit' => 0.01, + ]); + $leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'PIPE-X', @@ -192,15 +200,11 @@ test('credit settlement records base rebate plus add-on rebate and keeps extra r 'username' => 'pipe_x', 'total_share_rate' => 25, 'credit_limit' => 100_000, + 'rebate_limit' => 0.01, 'default_player_rebate' => 0.005, 'can_grant_extra_rebate' => true, ])); - AgentProfile::query()->where('agent_node_id', $rootId)->update([ - 'total_share_rate' => 100, - 'default_player_rebate' => 0.005, - ]); - $player = Player::query()->create([ 'site_code' => $siteCode, 'agent_node_id' => $leaf->id, diff --git a/tests/Feature/CreditWalletLogsTest.php b/tests/Feature/CreditWalletLogsTest.php index 793b563..907b3f4 100644 --- a/tests/Feature/CreditWalletLogsTest.php +++ b/tests/Feature/CreditWalletLogsTest.php @@ -134,3 +134,56 @@ test('credit player wallet logs distinguish win credit from bill settlement', fu ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.type', 'win_credit'); }); + +test('credit wallet logs page 2 balance_after continues from page 1 baseline', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:logs-page2', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_logs_page2', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 12, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + for ($i = 1; $i <= 12; $i++) { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -1000, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => $i, + 'created_at' => now()->subMinutes(12 - $i), + 'updated_at' => now()->subMinutes(12 - $i), + ]); + } + + $page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->json('data'); + + $page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=2&size=10') + ->assertOk() + ->json('data'); + + expect($page1['total'])->toBe(12); + expect($page2['items'])->toHaveCount(2); + + $page1LastBalanceAfter = $page1['items'][9]['balance_after']; + $page2FirstBalanceAfter = $page2['items'][0]['balance_after']; + + // Page 2 must continue the running balance from page 1, not restart at current available. + expect($page2FirstBalanceAfter)->toBe($page1LastBalanceAfter + 1000); +}); diff --git a/tests/Feature/FinancialChainAuditCommandTest.php b/tests/Feature/FinancialChainAuditCommandTest.php index 45abb5e..6f1e2e3 100644 --- a/tests/Feature/FinancialChainAuditCommandTest.php +++ b/tests/Feature/FinancialChainAuditCommandTest.php @@ -4,107 +4,9 @@ use App\Models\Player; use App\Models\PlayerWallet; use App\Models\WalletTxn; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; uses(RefreshDatabase::class); -beforeEach(function (): void { - Schema::create('players', function (Blueprint $table): void { - $table->id(); - $table->string('site_code'); - $table->string('site_player_id'); - $table->string('username')->nullable(); - $table->string('nickname')->nullable(); - $table->string('default_currency')->default('NPR'); - $table->string('funding_mode')->default('wallet'); - $table->smallInteger('status')->default(0); - $table->timestamps(); - }); - - Schema::create('player_wallets', function (Blueprint $table): void { - $table->id(); - $table->foreignId('player_id'); - $table->string('wallet_type'); - $table->string('currency_code'); - $table->bigInteger('balance')->default(0); - $table->bigInteger('frozen_balance')->default(0); - $table->smallInteger('status')->default(0); - $table->integer('version')->default(0); - $table->timestamps(); - }); - - Schema::create('wallet_txns', function (Blueprint $table): void { - $table->id(); - $table->string('txn_no'); - $table->foreignId('player_id'); - $table->foreignId('wallet_id'); - $table->string('biz_type'); - $table->string('biz_no')->nullable(); - $table->smallInteger('direction'); - $table->bigInteger('amount'); - $table->bigInteger('balance_before'); - $table->bigInteger('balance_after'); - $table->string('status'); - $table->string('external_ref_no')->nullable(); - $table->string('idempotent_key')->nullable(); - $table->string('remark')->nullable(); - $table->timestamps(); - }); - - Schema::create('transfer_orders', function (Blueprint $table): void { - $table->id(); - $table->string('transfer_no'); - $table->foreignId('player_id'); - $table->string('direction'); - $table->string('currency_code'); - $table->bigInteger('amount'); - $table->string('idempotent_key'); - $table->string('status'); - $table->timestamps(); - }); - - Schema::create('player_credit_accounts', function (Blueprint $table): void { - $table->foreignId('player_id')->primary(); - $table->bigInteger('credit_limit')->default(0); - $table->bigInteger('used_credit')->default(0); - $table->bigInteger('frozen_credit')->default(0); - $table->timestamps(); - }); - - Schema::create('credit_ledger', function (Blueprint $table): void { - $table->id(); - $table->string('owner_type'); - $table->unsignedBigInteger('owner_id'); - $table->bigInteger('amount'); - $table->string('reason'); - $table->string('ref_type')->nullable(); - $table->unsignedBigInteger('ref_id')->nullable(); - $table->timestamps(); - }); - - Schema::create('ticket_items', function (Blueprint $table): void { - $table->id(); - }); - - Schema::create('settlement_bills', function (Blueprint $table): void { - $table->id(); - $table->string('bill_type')->default('player'); - $table->bigInteger('net_amount')->default(0); - $table->bigInteger('paid_amount')->default(0); - $table->bigInteger('unpaid_amount')->default(0); - $table->json('meta_json')->nullable(); - }); - - Schema::create('payment_records', function (Blueprint $table): void { - $table->id(); - $table->foreignId('settlement_bill_id'); - $table->bigInteger('amount'); - $table->string('status'); - $table->timestamp('confirmed_at')->nullable(); - }); -}); - test('financial chain audit passes for consistent wallet ledger', function (): void { $player = Player::query()->create([ 'site_code' => 'main', diff --git a/tests/Feature/GameSettlementReversalTest.php b/tests/Feature/GameSettlementReversalTest.php index 129a7fd..e31b8e6 100644 --- a/tests/Feature/GameSettlementReversalTest.php +++ b/tests/Feature/GameSettlementReversalTest.php @@ -124,3 +124,107 @@ test('reversal zeroes share ledger net and marks rebates reversed', function (): 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(); }); + +test('reversal restores credit used for settled win on credit player', 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' => 'rev-credit-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'revcredit1', + '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(), + ]); + + $drawId = (int) \App\Models\Draw::query()->create([ + 'draw_no' => 'REV-CREDIT-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-REV-CREDIT-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-CREDIT-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' => 1000, + '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(), + ]); + + DB::table('share_ledger')->insert([ + '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' => 0, + 'shared_net_win_loss' => -1000, + 'allocations_json' => '[]', + 'settled_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Simulate post-win used_credit (win decreased used by 10 major for 1000 minor). + DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]); + + $item = TicketItem::query()->findOrFail($itemId); + app(GameSettlementReversalService::class)->reverseTicketItem($item); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10); + expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php index abe757c..845b443 100644 --- a/tests/Feature/PlayerNativeAuthTest.php +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -6,8 +6,10 @@ use App\Support\PlayerFundingMode; use Database\Seeders\CurrencySeeder; use Database\Seeders\LotterySettingsSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -21,6 +23,24 @@ beforeEach(function (): void { $this->seed(LotterySettingsSeeder::class); }); +/** + * @return array{captcha_key: string, captcha_code: string} + */ +function playerLoginCaptcha(string $code = 'xwz2'): array +{ + $key = (string) Str::uuid(); + Cache::put( + 'player_captcha:'.$key, + hash_hmac('sha256', strtolower($code), (string) config('app.key')), + now()->addSeconds(120), + ); + + return [ + 'captcha_key' => $key, + 'captcha_code' => $code, + ]; +} + test('native player can login without site code using default site', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); @@ -38,10 +58,10 @@ test('native player can login without site code using default site', function () 'status' => 0, ]); - $login = $this->postJson('/api/v1/player/auth/login', [ + $login = $this->postJson('/api/v1/player/auth/login', array_merge([ 'username' => 'agentplayer0', 'password' => 'secret-pass', - ]); + ], playerLoginCaptcha())); $login->assertOk() ->assertJsonPath('data.player.id', $player->id); @@ -81,10 +101,10 @@ test('native player can login without site code on non-default site when usernam 'status' => 0, ]); - $this->postJson('/api/v1/player/auth/login', [ + $this->postJson('/api/v1/player/auth/login', array_merge([ 'username' => 'play1', 'password' => 'secret-pass', - ]) + ], playerLoginCaptcha())) ->assertOk() ->assertJsonPath('data.player.id', $player->id) ->assertJsonPath('data.player.site_code', 'kk88'); @@ -132,10 +152,47 @@ test('native player login without site code rejects ambiguous username across si ]); } - $this->postJson('/api/v1/player/auth/login', [ + $this->postJson('/api/v1/player/auth/login', array_merge([ 'username' => 'dup_user', 'password' => 'secret-pass', - ])->assertJsonPath('code', 8006); + ], playerLoginCaptcha()))->assertJsonPath('code', 8006); +}); + +test('player auth captcha exposes key and image base64', function (): void { + $resp = $this->getJson('/api/v1/player/auth/captcha'); + + $resp->assertOk(); + $data = $resp->json('data'); + expect($data)->toHaveKeys(['captcha_key', 'image_base64']) + ->and(Str::isUuid((string) $data['captcha_key']))->toBeTrue() + ->and(base64_decode((string) $data['image_base64'], true))->not->toBeFalse(); +}); + +test('native player login rejects invalid captcha', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:captcha-fail', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'captchafail', + 'password_hash' => Hash::make('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $captcha = playerLoginCaptcha('xwz2'); + + $this->postJson('/api/v1/player/auth/login', [ + 'username' => 'captchafail', + 'password' => 'secret-pass', + 'captcha_key' => $captcha['captcha_key'], + 'captcha_code' => 'aaaa', + ])->assertStatus(422)->assertJsonPath('code', 8009); }); test('native player can login and access me', function (): void { @@ -164,11 +221,11 @@ test('native player can login and access me', function (): void { 'updated_at' => now(), ]); - $login = $this->postJson('/api/v1/player/auth/login', [ + $login = $this->postJson('/api/v1/player/auth/login', array_merge([ 'site_code' => $site->code, 'username' => 'agentplayer1', 'password' => 'secret-pass', - ]); + ], playerLoginCaptcha())); $login->assertOk(); $token = (string) $login->json('data.access_token'); diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php index eb6504a..f64d403 100644 --- a/tests/Feature/PlayerRealtimeBroadcastTest.php +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -13,6 +13,7 @@ use App\Services\Ticket\RiskPoolService; use App\Services\Wallet\LotteryTransferService; use App\Services\Wallet\WalletBalanceRealtimeNotifier; use Database\Seeders\CurrencySeeder; +use Database\Seeders\LotterySettingsSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -20,7 +21,9 @@ uses(RefreshDatabase::class); beforeEach(function (): void { config(['broadcasting.default' => 'reverb']); + config(['lottery.main_site.wallet_api_url' => null]); $this->seed(CurrencySeeder::class); + $this->seed(LotterySettingsSeeder::class); }); test('wallet balance notifier dispatches balance update broadcast', function (): void { diff --git a/tests/Unit/CreditAmountScaleTest.php b/tests/Unit/CreditAmountScaleTest.php index 393b29c..49802da 100644 --- a/tests/Unit/CreditAmountScaleTest.php +++ b/tests/Unit/CreditAmountScaleTest.php @@ -15,4 +15,5 @@ test('major and minor convert for two decimal currency', function (): void { expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200); expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3); expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2); + expect(CreditAmountScale::minorToMajor(201, 'NPR'))->toBe(3); });