feat: enhance player authentication and agent management features
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
- 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.
This commit is contained in:
15
AGENTS.md
15
AGENTS.md
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
## 后台 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。玩家写接口(`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`。
|
- 零站点:`admin_sites` 可为 0;`defaultAdminSiteId()` 无站返回 null;超管仍可登录;需站点的写操作用 `requireDefaultAdminSiteId()` 抛 `no_integration_site`。
|
||||||
- 删接入站:DELETE API/UI;默认站亦可删(含最后一个);仅删除仅绑 `site_admin_{code}` 的自动账号,**不得**删超管。
|
- 删接入站:DELETE API/UI;默认站亦可删(含最后一个);仅删除仅绑 `site_admin_{code}` 的自动账号,**不得**删超管。
|
||||||
|
|
||||||
|
## Learned User Preferences
|
||||||
|
|
||||||
|
- 排障/上线评估时用 MCP(postgres)直查库验证数据与权限,勿仅凭代码推断生产态。
|
||||||
|
|
||||||
## Learned Workspace Facts
|
## Learned Workspace Facts
|
||||||
|
|
||||||
- 期号 `close_time`/`draw_time` UTC 存储;下注由 `DrawHallSnapshotBuilder` 实时判定;列表展示 DB `status`,详情 API 有 `hall_preview_status`。
|
- 期号 `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`,实际操作仍受直属边 + 收款方校验。
|
- `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_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`)。
|
- 代理数据范围:有 `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=本节点`;登记收付/确认仅可操作 **收款方**。
|
- 结算中心登记收付/确认/坏账/补差 UI 需 `prd.settlement.agent.manage`(`canManage`);仅 view 时操作区静默隐藏。另需账单 `status` ∈ confirmed/partial_paid/overdue 且 `unpaid_amount > 0`。**坏账核销 / 补差冲正** 另需未绑定代理(站点财务,`canFinanceAdjustments`),绑定代理仅有收付/确认。绑定代理账单可见范围:**玩家账单**仅直属玩家;**代理账单**仅 `owner=本节点` 或 `counterparty=本节点`;登记收付/确认仅可操作 **收款方**。
|
||||||
- 收付/调账/坏账后端落库 `payment_records`、`settlement_adjustments`;账期详情 **收付与调账** Tab 查操作台账,**账务流水** 仅玩家信用变动;单张账单详情内另有该账单的收付列表。
|
- 收付/调账/坏账后端落库 `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 再前端。
|
- 线上生产:已有库用 `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。
|
- `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 仅超管可改。
|
- 代理仪表盘/账期「输赢」用本级 `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` 验站)。
|
- 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`(列可能带时间戳);日盈利过滤零活动日。
|
- 报表 `AdminReportQueryService`:未传日期默认近 30 天;玩家/玩法/回水按 `draws.business_date`;SQLite 对 `business_date` 用 `whereDate` 勿 `whereBetween`(列可能带时间戳);日盈利过滤零活动日。
|
||||||
|
|||||||
@@ -40,18 +40,7 @@ final class AgentNodeRoleStoreController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! AdminAgentScope::nodeManageableBy($admin, $agent_node)) {
|
// Agent accounts can only create roles on their own node, not descendants.
|
||||||
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
|
|
||||||
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
|
$primaryNode = AdminAgentScope::primaryAgentNode($admin);
|
||||||
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
|
if ($primaryNode !== null && (int) $primaryNode->id !== (int) $agent_node->id) {
|
||||||
return ApiMessage::errorResponse(
|
return ApiMessage::errorResponse(
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1\Admin\Player;
|
|||||||
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Support\ApiMessage;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@@ -19,6 +21,20 @@ final class AdminPlayerIndexController extends Controller
|
|||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
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);
|
$p = AdminApiList::readPaging($request);
|
||||||
$keyword = trim((string) $request->query('keyword', ''));
|
$keyword = trim((string) $request->query('keyword', ''));
|
||||||
$status = $request->query('status');
|
$status = $request->query('status');
|
||||||
|
|||||||
@@ -203,41 +203,32 @@ final class AdminPlayerStoreController extends Controller
|
|||||||
|
|
||||||
private function resolveAgentNodeIdForNonSuperAdmin(AdminUser $admin, mixed $requested, string $siteCode): ?int
|
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);
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
if ($agent === null) {
|
if ($agent !== null) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) {
|
if ($requested !== null && (int) $requested > 0 && (int) $requested !== (int) $agent->id) {
|
||||||
return null; // Agent account cannot create under other nodes
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $agent->id;
|
return (int) $agent->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! AdminAgentScope::isSiteOnlyOperator($admin)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requested === null || (int) $requested <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
private function generateNativeSitePlayerId(string $siteCode): string
|
||||||
{
|
{
|
||||||
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1\Player;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\AdminCaptchaService;
|
||||||
|
use App\Support\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** GET /api/v1/player/auth/captcha — 玩家账号密码登录图形验证码 */
|
||||||
|
final class PlayerAuthCaptchaController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(AdminCaptchaService $captcha): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $captcha->create(AdminCaptchaService::SCOPE_PLAYER);
|
||||||
|
|
||||||
|
return ApiResponse::success([
|
||||||
|
'captcha_key' => $payload['captcha_key'],
|
||||||
|
'image_base64' => $payload['image_base64'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,35 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Player;
|
namespace App\Http\Controllers\Api\V1\Player;
|
||||||
|
|
||||||
|
use App\Exceptions\PlayerAuthenticationException;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Player\PlayerAuthLoginRequest;
|
use App\Http\Requests\Player\PlayerAuthLoginRequest;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Services\AdminCaptchaService;
|
||||||
use App\Services\Player\PlayerNativeAuthService;
|
use App\Services\Player\PlayerNativeAuthService;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use App\Support\LotteryMessage;
|
use App\Support\LotteryMessage;
|
||||||
use App\Exceptions\PlayerAuthenticationException;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
/** POST /api/v1/player/auth/login — 代理线下玩家账号密码登录 */
|
/** POST /api/v1/player/auth/login — 代理线下玩家账号密码登录 */
|
||||||
final class PlayerAuthLoginController extends Controller
|
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 {
|
try {
|
||||||
$data = $auth->login(
|
$data = $auth->login(
|
||||||
(string) $request->validated('site_code', ''),
|
(string) $request->validated('site_code', ''),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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'],
|
||||||
|
'settlement_cycle' => ['sometimes', 'nullable', 'string', 'in:weekly,monthly'],
|
||||||
'risk_tags' => ['sometimes', 'array'],
|
'risk_tags' => ['sometimes', 'array'],
|
||||||
'risk_tags.*' => ['string', 'max:64'],
|
'risk_tags.*' => ['string', 'max:64'],
|
||||||
];
|
];
|
||||||
@@ -23,6 +24,13 @@ trait AgentProfileFieldRules
|
|||||||
|
|
||||||
protected function prepareAgentProfileFieldsForValidation(): void
|
protected function prepareAgentProfileFieldsForValidation(): void
|
||||||
{
|
{
|
||||||
// 预处理字段(如需要)
|
if (! $this->has('settlement_cycle')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cycle = trim((string) $this->input('settlement_cycle'));
|
||||||
|
$this->merge([
|
||||||
|
'settlement_cycle' => $cycle === '' ? 'weekly' : $cycle,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ final class PlayerAuthLoginRequest extends ApiFormRequest
|
|||||||
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
'site_code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
'username' => $this->nativePlayerUsernameRules(required: true),
|
'username' => $this->nativePlayerUsernameRules(required: true),
|
||||||
'password' => $this->nativePlayerPasswordRules(required: true),
|
'password' => $this->nativePlayerPasswordRules(required: true),
|
||||||
|
'captcha_key' => ['required', 'string', 'uuid'],
|
||||||
|
'captcha_code' => ['required', 'string', 'max:32'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ enum ErrorCode: int
|
|||||||
/** 原生登录:非彩票账号密码登录类型 */
|
/** 原生登录:非彩票账号密码登录类型 */
|
||||||
case PlayerNativeLoginRequired = 8008;
|
case PlayerNativeLoginRequired = 8008;
|
||||||
|
|
||||||
|
/** 原生登录:验证码错误或过期 */
|
||||||
|
case PlayerCaptchaInvalid = 8009;
|
||||||
|
|
||||||
/* ========== 8100–8199 管理端 API ========== */
|
/* ========== 8100–8199 管理端 API ========== */
|
||||||
|
|
||||||
/** 未登录或 Token 无效 */
|
/** 未登录或 Token 无效 */
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final class AgentProfile extends Model
|
|||||||
'can_grant_extra_rebate',
|
'can_grant_extra_rebate',
|
||||||
'can_create_child_agent',
|
'can_create_child_agent',
|
||||||
'can_create_player',
|
'can_create_player',
|
||||||
|
'settlement_cycle',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -39,6 +40,7 @@ final class AgentProfile extends Model
|
|||||||
'can_grant_extra_rebate' => 'boolean',
|
'can_grant_extra_rebate' => 'boolean',
|
||||||
'can_create_child_agent' => 'boolean',
|
'can_create_child_agent' => 'boolean',
|
||||||
'can_create_player' => 'boolean',
|
'can_create_player' => 'boolean',
|
||||||
|
'settlement_cycle' => 'string',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,5 +57,13 @@ final class AppServiceProvider extends ServiceProvider
|
|||||||
RateLimiter::for('admin-auth-login', function (Request $request) {
|
RateLimiter::for('admin-auth-login', function (Request $request) {
|
||||||
return Limit::perMinute(15)->by($request->ip());
|
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());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -787,10 +787,6 @@ final class AdminReportQueryService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($limited['truncated']) {
|
|
||||||
array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ use Illuminate\Support\Str;
|
|||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台登录图形验证码:SVG 产出 + Cache 短时保存答案摘要(单行文本,便于前台用 img[src=data:...] 展示)。
|
* 图形验证码:SVG 产出 + Cache 短时保存答案摘要(admin / player 登录共用渲染逻辑)。
|
||||||
*/
|
*/
|
||||||
final class AdminCaptchaService
|
final class AdminCaptchaService
|
||||||
{
|
{
|
||||||
private const PREFIX = 'admin_captcha:';
|
public const SCOPE_ADMIN = 'admin';
|
||||||
|
|
||||||
|
public const SCOPE_PLAYER = 'player';
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private const PREFIXES = [
|
||||||
|
self::SCOPE_ADMIN => 'admin_captcha:',
|
||||||
|
self::SCOPE_PLAYER => 'player_captcha:',
|
||||||
|
];
|
||||||
|
|
||||||
private const TTL_SECONDS = 120;
|
private const TTL_SECONDS = 120;
|
||||||
|
|
||||||
@@ -20,13 +28,13 @@ final class AdminCaptchaService
|
|||||||
/**
|
/**
|
||||||
* @return array{captcha_key: string, image_svg: string, image_base64: string}
|
* @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();
|
$code = $this->randomCode();
|
||||||
$key = (string) Str::uuid();
|
$key = (string) Str::uuid();
|
||||||
|
|
||||||
Cache::put(
|
Cache::put(
|
||||||
self::PREFIX.$key,
|
$this->prefix($scope).$key,
|
||||||
$this->digest($code),
|
$this->digest($code),
|
||||||
now()->addSeconds(self::TTL_SECONDS),
|
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 === ''
|
if ($captchaKey === null || $captchaKey === ''
|
||||||
|| $captchaInput === null || trim($captchaInput) === '') {
|
|| $captchaInput === null || trim($captchaInput) === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$digest = Cache::pull(self::PREFIX.$captchaKey);
|
$digest = Cache::pull($this->prefix($scope).$captchaKey);
|
||||||
if ($digest === null) {
|
if ($digest === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,16 @@ final class AdminCaptchaService
|
|||||||
return hash_equals($digest, $this->digest($guess));
|
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
|
private function digest(string $normalizedCode): string
|
||||||
{
|
{
|
||||||
return hash_hmac(
|
return hash_hmac(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services\Agent;
|
|||||||
|
|
||||||
use App\Models\AgentNode;
|
use App\Models\AgentNode;
|
||||||
use App\Models\AgentProfile;
|
use App\Models\AgentProfile;
|
||||||
|
use App\Support\PlayerFundingMode;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +44,7 @@ final class AgentCreditAllocatedSyncService
|
|||||||
$playerTotal = (int) DB::table('player_credit_accounts as pca')
|
$playerTotal = (int) DB::table('player_credit_accounts as pca')
|
||||||
->join('players as p', 'p.id', '=', 'pca.player_id')
|
->join('players as p', 'p.id', '=', 'pca.player_id')
|
||||||
->where('p.agent_node_id', $agent->id)
|
->where('p.agent_node_id', $agent->id)
|
||||||
|
->where('p.funding_mode', PlayerFundingMode::CREDIT)
|
||||||
->sum('pca.credit_limit');
|
->sum('pca.credit_limit');
|
||||||
|
|
||||||
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');
|
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ final class AgentProfileService
|
|||||||
$defaultRebate = array_key_exists('default_player_rebate', $payload)
|
$defaultRebate = array_key_exists('default_player_rebate', $payload)
|
||||||
? (float) $payload['default_player_rebate'] / 100
|
? (float) $payload['default_player_rebate'] / 100
|
||||||
: (float) ($existingProfile->default_player_rebate ?? 0);
|
: (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);
|
$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]);
|
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]);
|
||||||
$previousCredit = (int) $profile->credit_limit;
|
$previousCredit = (int) $profile->credit_limit;
|
||||||
$isNew = ! $profile->exists;
|
$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_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_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)),
|
'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)),
|
||||||
|
'settlement_cycle' => $settlementCycle,
|
||||||
]);
|
]);
|
||||||
if (! $profile->exists) {
|
if (! $profile->exists) {
|
||||||
$profile->allocated_credit = 0;
|
$profile->allocated_credit = 0;
|
||||||
@@ -141,6 +148,7 @@ final class AgentProfileService
|
|||||||
'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate,
|
'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate,
|
||||||
'can_create_child_agent' => (bool) $profile->can_create_child_agent,
|
'can_create_child_agent' => (bool) $profile->can_create_child_agent,
|
||||||
'can_create_player' => (bool) $profile->can_create_player,
|
'can_create_player' => (bool) $profile->can_create_player,
|
||||||
|
'settlement_cycle' => (string) ($profile->settlement_cycle ?? 'weekly'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Support\CreditAmountScale;
|
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
|
use App\Services\Player\PlayerCreditService;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class GameSettlementReversalService
|
final class GameSettlementReversalService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PlayerCreditService $playerCreditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function reverseTicketItem(TicketItem $item): void
|
public function reverseTicketItem(TicketItem $item): void
|
||||||
{
|
{
|
||||||
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
|
$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);
|
$player = Player::query()->find((int) $ledger->player_id);
|
||||||
if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) {
|
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
|
||||||
$playerId = (int) $ledger->player_id;
|
$gameWinLoss = (int) $ledger->game_win_loss;
|
||||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
if ($gameWinLoss !== 0) {
|
||||||
if ($row !== null) {
|
$this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id);
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class SettlementCenterLedgerService
|
|||||||
'bet_hold_release',
|
'bet_hold_release',
|
||||||
'game_settlement_loss',
|
'game_settlement_loss',
|
||||||
'game_settlement_win',
|
'game_settlement_win',
|
||||||
|
'game_settlement_reversal',
|
||||||
'settlement_confirm',
|
'settlement_confirm',
|
||||||
'settlement_payout',
|
'settlement_payout',
|
||||||
];
|
];
|
||||||
@@ -572,10 +573,9 @@ final class SettlementCenterLedgerService
|
|||||||
$id = (int) $stub->entry_id;
|
$id = (int) $stub->entry_id;
|
||||||
if ($kind === 'credit' && isset($creditById[$id])) {
|
if ($kind === 'credit' && isset($creditById[$id])) {
|
||||||
$row = $creditById[$id];
|
$row = $creditById[$id];
|
||||||
$pid = (int) $row->player_id;
|
|
||||||
$bill = (string) ($row->ref_type ?? '') === 'settlement_bill'
|
$bill = (string) ($row->ref_type ?? '') === 'settlement_bill'
|
||||||
? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null)
|
? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null)
|
||||||
: ($playerBills[$pid] ?? null);
|
: null;
|
||||||
$items[] = $this->formatCreditEntry($row, $bill, $ticketRefs);
|
$items[] = $this->formatCreditEntry($row, $bill, $ticketRefs);
|
||||||
} elseif ($kind === 'payment' && isset($paymentById[$id])) {
|
} elseif ($kind === 'payment' && isset($paymentById[$id])) {
|
||||||
$items[] = $this->formatPaymentEntry($paymentById[$id]);
|
$items[] = $this->formatPaymentEntry($paymentById[$id]);
|
||||||
|
|||||||
@@ -18,11 +18,18 @@ final class PlayerCreditService
|
|||||||
{
|
{
|
||||||
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
|
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
|
||||||
$now = now();
|
$now = now();
|
||||||
$exists = DB::table('player_credit_accounts')
|
$row = DB::table('player_credit_accounts')
|
||||||
->where('player_id', $player->id)
|
->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')
|
DB::table('player_credit_accounts')
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
->update([
|
->update([
|
||||||
@@ -72,7 +79,8 @@ final class PlayerCreditService
|
|||||||
$this->assertCreditGuards($player);
|
$this->assertCreditGuards($player);
|
||||||
|
|
||||||
$currency = (string) $player->default_currency;
|
$currency = (string) $player->default_currency;
|
||||||
if ($amountMinor > $this->availableCreditMinor($player, $currency)) {
|
$majorNeeded = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||||
|
if ($majorNeeded > $this->availableCredit($player)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'credit' => ['insufficient'],
|
'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
|
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
|
||||||
{
|
{
|
||||||
if ($amountMinor <= 0) {
|
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class PlayerLedgerLogsService
|
|||||||
/** PRD 对外类型 → credit_ledger.reason(信用盘不用钱包「派彩」口径) */
|
/** PRD 对外类型 → credit_ledger.reason(信用盘不用钱包「派彩」口径) */
|
||||||
private const CREDIT_TYPE_TO_REASON = [
|
private const CREDIT_TYPE_TO_REASON = [
|
||||||
'bet' => ['bet_hold', 'game_settlement_loss'],
|
'bet' => ['bet_hold', 'game_settlement_loss'],
|
||||||
'reversal' => ['bet_hold_release'],
|
'reversal' => ['bet_hold_release', 'game_settlement_reversal'],
|
||||||
'refund' => ['settlement_confirm'],
|
'refund' => ['settlement_confirm'],
|
||||||
'win_credit' => ['game_settlement_win'],
|
'win_credit' => ['game_settlement_win'],
|
||||||
'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'],
|
'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'],
|
||||||
@@ -106,6 +106,7 @@ final class PlayerLedgerLogsService
|
|||||||
'bet_hold_release',
|
'bet_hold_release',
|
||||||
'game_settlement_loss',
|
'game_settlement_loss',
|
||||||
'game_settlement_win',
|
'game_settlement_win',
|
||||||
|
'game_settlement_reversal',
|
||||||
'settlement_confirm',
|
'settlement_confirm',
|
||||||
'settlement_payout',
|
'settlement_payout',
|
||||||
]), 5000);
|
]), 5000);
|
||||||
@@ -180,9 +181,26 @@ final class PlayerLedgerLogsService
|
|||||||
$pageRows = array_slice($simplified, $offset, $perPage);
|
$pageRows = array_slice($simplified, $offset, $perPage);
|
||||||
|
|
||||||
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
|
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
|
||||||
|
foreach (array_slice($simplified, 0, $offset) as $priorRow) {
|
||||||
|
if (! $this->adminCreditRowAffectsAvailableBalance($priorRow)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$runningMinor -= $this->adminCreditRowSignedDelta($priorRow);
|
||||||
|
}
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($pageRows as $formatted) {
|
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, [
|
$items[] = array_merge($formatted, [
|
||||||
'balance_after' => $runningMinor,
|
'balance_after' => $runningMinor,
|
||||||
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
|
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
|
||||||
@@ -414,7 +432,13 @@ final class PlayerLedgerLogsService
|
|||||||
->paginate($perPage, ['*'], 'page', $page);
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$currency = (string) $player->default_currency;
|
$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()
|
$items = $paginator->getCollection()
|
||||||
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
|
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
|
||||||
$amount = (int) $row->amount;
|
$amount = (int) $row->amount;
|
||||||
@@ -587,6 +611,49 @@ final class PlayerLedgerLogsService
|
|||||||
return $reason !== 'settlement_payout';
|
return $reason !== 'settlement_payout';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string>|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<string, mixed> $formatted
|
||||||
|
*/
|
||||||
|
private function adminCreditRowAffectsAvailableBalance(array $formatted): bool
|
||||||
|
{
|
||||||
|
return ($formatted['biz_type'] ?? '') !== 'settlement_payout';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $formatted
|
||||||
|
*/
|
||||||
|
private function adminCreditRowSignedDelta(array $formatted): int
|
||||||
|
{
|
||||||
|
return (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -636,7 +703,7 @@ final class PlayerLedgerLogsService
|
|||||||
{
|
{
|
||||||
return match ($reason) {
|
return match ($reason) {
|
||||||
'bet_hold', 'game_settlement_loss' => 'bet',
|
'bet_hold', 'game_settlement_loss' => 'bet',
|
||||||
'bet_hold_release' => 'reversal',
|
'bet_hold_release', 'game_settlement_reversal' => 'reversal',
|
||||||
'settlement_confirm' => 'refund',
|
'settlement_confirm' => 'refund',
|
||||||
'game_settlement_win' => 'win_credit',
|
'game_settlement_win' => 'win_credit',
|
||||||
'settlement_payout' => 'bill_settlement',
|
'settlement_payout' => 'bill_settlement',
|
||||||
|
|||||||
@@ -23,27 +23,9 @@ final class AdminAgentNodeAccess
|
|||||||
?? AdminSite::query()->orderBy('id')->value('id'));
|
?? AdminSite::query()->orderBy('id')->value('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if admin is a platform account (bound via admin_user_site_roles)
|
// Agent account (bound via agent node) - check first
|
||||||
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
|
||||||
if ($accessibleSiteIds !== null) {
|
|
||||||
// Platform account (site admin)
|
|
||||||
if ($requestedSiteId !== null && $requestedSiteId > 0) {
|
|
||||||
if (in_array($requestedSiteId, $accessibleSiteIds, true)) {
|
|
||||||
return $requestedSiteId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return first accessible site if no specific site requested
|
|
||||||
return $accessibleSiteIds[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent account (bound via agent node)
|
|
||||||
$actor = AdminAgentScope::primaryAgentNode($admin);
|
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||||
if ($actor === null) {
|
if ($actor !== null) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) {
|
if ($requestedSiteId !== null && $requestedSiteId > 0 && $requestedSiteId !== (int) $actor->admin_site_id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -51,6 +33,22 @@ final class AdminAgentNodeAccess
|
|||||||
return (int) $actor->admin_site_id;
|
return (int) $actor->admin_site_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! AdminAgentScope::isSiteOnlyOperator($admin)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accessibleSiteIds = $admin->accessibleAdminSiteIds() ?? [];
|
||||||
|
if ($requestedSiteId !== null && $requestedSiteId > 0) {
|
||||||
|
if (in_array($requestedSiteId, $accessibleSiteIds, true)) {
|
||||||
|
return $requestedSiteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $accessibleSiteIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
public static function denyUnlessSiteResolved(AdminUser $admin, ?int $siteId): ?JsonResponse
|
public static function denyUnlessSiteResolved(AdminUser $admin, ?int $siteId): ?JsonResponse
|
||||||
{
|
{
|
||||||
if ($siteId !== null && $siteId > 0) {
|
if ($siteId !== null && $siteId > 0) {
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ final class AdminAgentScope
|
|||||||
return AgentNode::query()->find($agentId);
|
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
|
public static function nodeVisibleTo(AdminUser $admin, AgentNode $node): bool
|
||||||
{
|
{
|
||||||
if ($admin->isSuperAdmin()) {
|
if ($admin->isSuperAdmin()) {
|
||||||
@@ -55,20 +67,8 @@ final class AdminAgentScope
|
|||||||
return true;
|
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);
|
$actor = self::primaryAgentNode($admin);
|
||||||
if ($actor === null) {
|
if ($actor !== null) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($player->agent_node_id === null) {
|
if ($player->agent_node_id === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,9 @@ final class AdminAgentScope
|
|||||||
return $playerAgent->isSameOrDescendantOf($actor);
|
return $playerAgent->isSameOrDescendantOf($actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return self::isSiteOnlyOperator($admin);
|
||||||
|
}
|
||||||
|
|
||||||
public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool
|
public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool
|
||||||
{
|
{
|
||||||
if ($admin->isSuperAdmin()) {
|
if ($admin->isSuperAdmin()) {
|
||||||
@@ -113,27 +116,8 @@ final class AdminAgentScope
|
|||||||
return false;
|
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);
|
$actor = self::primaryAgentNode($admin);
|
||||||
if ($actor !== null && (int) $actor->id === (int) $node->id) {
|
if ($actor !== null) {
|
||||||
return false; // Cannot edit own bound node
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent account (bound via agent node)
|
|
||||||
$actor = self::primaryAgentNode($admin);
|
|
||||||
if ($actor === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $actor->id === (int) $node->id) {
|
if ((int) $actor->id === (int) $node->id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -141,6 +125,15 @@ final class AdminAgentScope
|
|||||||
return $node->isDescendantOf($actor);
|
return $node->isDescendantOf($actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! self::isSiteOnlyOperator($admin)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
|
||||||
|
|
||||||
|
return in_array((int) $node->admin_site_id, $accessibleSiteIds ?? [], true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Builder<AgentNode>
|
* @return Builder<AgentNode>
|
||||||
*/
|
*/
|
||||||
@@ -188,18 +181,11 @@ final class AdminAgentScope
|
|||||||
return;
|
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);
|
$actor = self::primaryAgentNode($admin);
|
||||||
if ($actor === null) {
|
if ($actor === null) {
|
||||||
|
if (! self::isSiteOnlyOperator($admin)) {
|
||||||
$query->whereRaw('0 = 1');
|
$query->whereRaw('0 = 1');
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.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.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.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, '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, '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.freeze', 'module_code' => 'player_service', 'name' => '冻结玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/freeze', 'route_name' => 'api.v1.admin.players.freeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
|
||||||
['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
|
['code' => 'admin.players.unfreeze', 'module_code' => 'player_service', 'name' => '解冻玩家', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/players/{player}/unfreeze', 'route_name' => 'api.v1.admin.players.unfreeze', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.players.freeze']],
|
||||||
['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.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.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']],
|
||||||
['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']],
|
['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']],
|
||||||
['code' => 'admin.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']],
|
['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']],
|
||||||
|
|||||||
@@ -43,20 +43,13 @@ final class AdminDataScope
|
|||||||
$query->whereIn($alias.'.site_code', $codes);
|
$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);
|
$actor = AdminAgentScope::primaryAgentNode($admin);
|
||||||
if ($actor === null) {
|
if ($actor === null) {
|
||||||
|
if (! AdminAgentScope::isSiteOnlyOperator($admin)) {
|
||||||
$query->whereRaw('0 = 1');
|
$query->whereRaw('0 = 1');
|
||||||
|
} elseif ($requestedAgentNodeId !== null && $requestedAgentNodeId > 0) {
|
||||||
|
self::applyAgentNodeIdOnAlias($query, $admin, $alias, $requestedAgentNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,14 +91,8 @@ final class AdminSiteScope
|
|||||||
|
|
||||||
$query->whereIn('site_code', $codes);
|
$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);
|
AdminAgentScope::applyToPlayerQuery($query, $admin);
|
||||||
}
|
}
|
||||||
// Platform account - no additional agent node filtering needed
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在站点范围基础上,可选按请求的 site_code 再收窄。
|
* 在站点范围基础上,可选按请求的 site_code 再收窄。
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ final class CreditAmountScale
|
|||||||
return $major * self::minorUnitFactor($currencyCode);
|
return $major * self::minorUnitFactor($currencyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最小单位 → 主货币整数(四舍五入)。 */
|
/**
|
||||||
|
* 最小单位 → 主货币整数(占用授信时向上取整,与 preflight 按 major 校验一致)。
|
||||||
|
*/
|
||||||
public static function minorToMajor(int $minor, string $currencyCode): int
|
public static function minorToMajor(int $minor, string $currencyCode): int
|
||||||
{
|
{
|
||||||
$factor = self::minorUnitFactor($currencyCode);
|
$factor = self::minorUnitFactor($currencyCode);
|
||||||
@@ -43,10 +45,10 @@ final class CreditAmountScale
|
|||||||
return $minor;
|
return $minor;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($minor >= 0) {
|
if ($minor <= 0) {
|
||||||
return intdiv($minor + intdiv($factor, 2), $factor);
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return -intdiv(-$minor + intdiv($factor, 2), $factor);
|
return intdiv($minor + $factor - 1, $factor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,12 @@
|
|||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use App\Models\AdminSite;
|
use App\Models\AdminSite;
|
||||||
use App\Services\LotterySettings;
|
|
||||||
|
|
||||||
|
/** 站点级信用占成盘开关(extra_json.credit_line_mode);玩家判读以 funding_mode 为准。 */
|
||||||
final class CreditLineMode
|
final class CreditLineMode
|
||||||
{
|
{
|
||||||
public static function isEnabledForSiteCode(string $siteCode): bool
|
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']);
|
$site = AdminSite::query()->where('code', $siteCode)->first(['extra_json']);
|
||||||
if ($site === null) {
|
if ($site === null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -13,18 +13,7 @@ final class PlayerFundingMode
|
|||||||
|
|
||||||
public static function usesCredit(Player $player): bool
|
public static function usesCredit(Player $player): bool
|
||||||
{
|
{
|
||||||
$mode = (string) ($player->funding_mode ?? '');
|
return (string) ($player->funding_mode ?? '') === self::CREDIT;
|
||||||
|
|
||||||
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
|
public static function usesWallet(Player $player): bool
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('agent_nodes') || ! Schema::hasTable('agent_profiles')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = config('agent_line_defaults', []);
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$roots = DB::table('agent_nodes')->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
|
||||||
|
{
|
||||||
|
// 非破坏性:不回删已补档案。
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,4 +15,5 @@ return [
|
|||||||
'8006' => 'Invalid username or password',
|
'8006' => 'Invalid username or password',
|
||||||
'8007' => 'Too many failed attempts. Try again later',
|
'8007' => 'Too many failed attempts. Try again later',
|
||||||
'8008' => 'Please sign in through the main site',
|
'8008' => 'Please sign in through the main site',
|
||||||
|
'8009' => 'Invalid or expired captcha.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ return [
|
|||||||
'8003' => 'खेलाडी दर्ता छैन',
|
'8003' => 'खेलाडी दर्ता छैन',
|
||||||
'8004' => 'SSO गोप्य सेट छैन',
|
'8004' => 'SSO गोप्य सेट छैन',
|
||||||
'8005' => 'खाता निलम्बित वा लगइन असक्षम',
|
'8005' => 'खाता निलम्बित वा लगइन असक्षम',
|
||||||
|
'8006' => 'प्रयोगकर्ता नाम वा पासवर्ड गलत',
|
||||||
|
'8007' => 'धेरै असफल प्रयास। पछि फेरि प्रयास गर्नुहोस्',
|
||||||
|
'8008' => 'कृपया मुख्य साइटबाट लगइन गर्नुहोस्',
|
||||||
|
'8009' => 'क्याप्चा गलत वा म्याद सकिएको छ।',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ return [
|
|||||||
'8006' => '账号或密码错误',
|
'8006' => '账号或密码错误',
|
||||||
'8007' => '登录失败次数过多,请稍后再试',
|
'8007' => '登录失败次数过多,请稍后再试',
|
||||||
'8008' => '请使用主站登录进入彩票',
|
'8008' => '请使用主站登录进入彩票',
|
||||||
|
'8009' => '验证码错误或已过期,请重试。',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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\PlayerAuthCaptchaController;
|
||||||
use App\Http\Controllers\Api\V1\Player\PlayerAuthLoginController;
|
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;
|
||||||
@@ -41,7 +42,12 @@ 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');
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 系统公共配置(如前端规则等)
|
// 系统公共配置(如前端规则等)
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ test('super admin can provision root agent on existing integration site', functi
|
|||||||
'code' => 'line-alpha',
|
'code' => 'line-alpha',
|
||||||
'name' => 'Line Alpha Site',
|
'name' => 'Line Alpha Site',
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'line_alpha_site_admin',
|
||||||
|
'nickname' => 'Line Alpha Admin',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.code', 'line-alpha');
|
->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', [
|
->postJson('/api/v1/admin/integration-sites', [
|
||||||
'code' => 'line-beta',
|
'code' => 'line-beta',
|
||||||
'name' => 'Line Beta Site',
|
'name' => 'Line Beta Site',
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'line_beta_site_admin',
|
||||||
|
'nickname' => 'Line Beta Admin',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->assertCreated();
|
->assertCreated();
|
||||||
|
|
||||||
@@ -150,6 +160,11 @@ test('integration manager with site.manage can create integration site', functio
|
|||||||
->postJson('/api/v1/admin/integration-sites', [
|
->postJson('/api/v1/admin/integration-sites', [
|
||||||
'code' => 'ops-site',
|
'code' => 'ops-site',
|
||||||
'name' => 'Ops Site',
|
'name' => 'Ops Site',
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'ops_site_admin',
|
||||||
|
'nickname' => 'Ops Admin',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.code', 'ops-site');
|
->assertJsonPath('data.code', 'ops-site');
|
||||||
|
|||||||
@@ -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.0.nav_group', 'overview')
|
||||||
->assertJsonPath('data.admin.navigation.1.segment', 'agents')
|
->assertJsonPath('data.admin.navigation.1.segment', 'agents')
|
||||||
->assertJsonPath('data.admin.navigation.1.nav_group', 'agent')
|
->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']]]);
|
->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'operational_permissions', 'navigation']]]);
|
||||||
|
|
||||||
$token = $resp->json('data.token');
|
$token = $resp->json('data.token');
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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 {
|
test('admin authorization audit reports missing api resources for protected routes', function (): void {
|
||||||
DB::table('admin_api_resources')
|
DB::table('admin_api_resources')
|
||||||
->where('code', 'admin.config.play-versions.index')
|
->where('code', 'admin.config.play-versions.index')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use App\Models\Player;
|
|||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Models\TicketItem;
|
use App\Models\TicketItem;
|
||||||
use App\Models\TicketOrder;
|
use App\Models\TicketOrder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
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 {
|
test('dashboard analytics returns summary trend and play breakdown for period', function (): void {
|
||||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
$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([
|
$player = Player::query()->create([
|
||||||
'site_code' => 'main',
|
'site_code' => $siteCode !== '' ? $siteCode : 'default_site',
|
||||||
'site_player_id' => 'da-p1',
|
'site_player_id' => 'da-p1',
|
||||||
'username' => 'da_u1',
|
'username' => 'da_u1',
|
||||||
'nickname' => null,
|
'nickname' => null,
|
||||||
@@ -23,7 +26,7 @@ test('dashboard analytics returns summary trend and play breakdown for period',
|
|||||||
|
|
||||||
$draw = Draw::query()->create([
|
$draw = Draw::query()->create([
|
||||||
'draw_no' => '20260510-001',
|
'draw_no' => '20260510-001',
|
||||||
'business_date' => '2026-05-10',
|
'business_date' => $businessDate,
|
||||||
'sequence_no' => 1,
|
'sequence_no' => 1,
|
||||||
'status' => 'settled',
|
'status' => 'settled',
|
||||||
'start_time' => now()->subDay(),
|
'start_time' => now()->subDay(),
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ test('wallet_api_url rejects non-https', function (): void {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertStatus(422)
|
->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 {
|
test('wallet_api_url rejects localhost', function (): void {
|
||||||
@@ -427,7 +427,7 @@ test('wallet_api_url rejects localhost', function (): void {
|
|||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertStatus(422)
|
->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 {
|
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)
|
->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 {
|
test('super admin can delete integration site and cleanup related data', function (): void {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ uses(RefreshDatabase::class);
|
|||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
$this->seed(CurrencySeeder::class);
|
$this->seed(CurrencySeeder::class);
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
function playerManageAdminToken(): string
|
function playerManageAdminToken(): string
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
use App\Models\AdminRole;
|
use App\Models\AdminRole;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use Database\Seeders\AdminRbacAndUserSeeder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
function makeFinanceReportAdminToken(): string
|
function makeFinanceReportAdminToken(): string
|
||||||
{
|
{
|
||||||
$admin = AdminUser::query()->create([
|
$admin = AdminUser::query()->create([
|
||||||
@@ -19,7 +22,7 @@ function makeFinanceReportAdminToken(): string
|
|||||||
'status' => 0,
|
'status' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$role = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
$role = AdminRole::query()->where('slug', 'site_finance')->firstOrFail();
|
||||||
$siteId = AdminUser::defaultAdminSiteId();
|
$siteId = AdminUser::defaultAdminSiteId();
|
||||||
$admin->roles()->sync([
|
$admin->roles()->sync([
|
||||||
(int) $role->id => [
|
(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 {
|
test('finance role with report legacy can access report jobs after rbac seed', function (): void {
|
||||||
$this->seed(AdminRbacAndUserSeeder::class);
|
$finance = AdminRole::query()->where('slug', 'site_finance')->firstOrFail();
|
||||||
|
|
||||||
$finance = AdminRole::query()->where('slug', 'finance')->firstOrFail();
|
|
||||||
expect($finance->legacyPermissionSlugs())->toContain('prd.report.view');
|
expect($finance->legacyPermissionSlugs())->toContain('prd.report.view');
|
||||||
|
|
||||||
$hasReportAction = DB::table('admin_role_menu_actions as rma')
|
$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 {
|
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 = [
|
$codes = [
|
||||||
'admin.reports.daily-profit',
|
'admin.reports.daily-profit',
|
||||||
'admin.report-jobs.index',
|
'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 {
|
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.download'))->toBe(['service.report.export']);
|
||||||
expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']);
|
expect(bindingsForResource('admin.report-jobs.store'))->toBe(['service.report.export']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
function settingsAdminToken(): string
|
function settingsAdminToken(): string
|
||||||
{
|
{
|
||||||
$admin = AdminUser::query()->create([
|
$admin = AdminUser::query()->create([
|
||||||
@@ -23,7 +27,12 @@ function settingsAdminToken(): string
|
|||||||
'slug' => 'settings_role',
|
'slug' => 'settings_role',
|
||||||
'name' => '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([
|
$admin->roles()->sync([
|
||||||
(int) $role->id => [
|
(int) $role->id => [
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ test('permission catalog groups permissions by admin navigation order', function
|
|||||||
'jackpot',
|
'jackpot',
|
||||||
'risk_cap',
|
'risk_cap',
|
||||||
'currencies',
|
'currencies',
|
||||||
|
'integration',
|
||||||
'admin_users',
|
'admin_users',
|
||||||
'admin_roles',
|
'admin_roles',
|
||||||
'audit',
|
'audit',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ test('player credit account syncs agent allocated credit', function (): void {
|
|||||||
'site_code' => 'line-alloc',
|
'site_code' => 'line-alloc',
|
||||||
'agent_node_id' => $root->id,
|
'agent_node_id' => $root->id,
|
||||||
'site_player_id' => 'p-alloc-1',
|
'site_player_id' => 'p-alloc-1',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
'username' => 'alloc1',
|
'username' => 'alloc1',
|
||||||
'nickname' => null,
|
'nickname' => null,
|
||||||
'default_currency' => 'NPR',
|
'default_currency' => 'NPR',
|
||||||
@@ -90,6 +92,8 @@ test('win loss does not change agent allocated credit', function (): void {
|
|||||||
'site_code' => 'line-hold',
|
'site_code' => 'line-hold',
|
||||||
'agent_node_id' => $root->id,
|
'agent_node_id' => $root->id,
|
||||||
'site_player_id' => 'p-hold-1',
|
'site_player_id' => 'p-hold-1',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
'username' => 'hold1',
|
'username' => 'hold1',
|
||||||
'nickname' => null,
|
'nickname' => null,
|
||||||
'default_currency' => 'NPR',
|
'default_currency' => 'NPR',
|
||||||
@@ -121,6 +125,8 @@ test('raising player credit limit succeeds when agent allocated credit includes
|
|||||||
'site_code' => 'line-player-raise',
|
'site_code' => 'line-player-raise',
|
||||||
'agent_node_id' => $root->id,
|
'agent_node_id' => $root->id,
|
||||||
'site_player_id' => 'p-other',
|
'site_player_id' => 'p-other',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
'username' => 'other',
|
'username' => 'other',
|
||||||
'nickname' => null,
|
'nickname' => null,
|
||||||
'default_currency' => 'NPR',
|
'default_currency' => 'NPR',
|
||||||
@@ -144,6 +150,8 @@ test('raising player credit limit succeeds when agent allocated credit includes
|
|||||||
'site_code' => 'line-player-raise',
|
'site_code' => 'line-player-raise',
|
||||||
'agent_node_id' => $root->id,
|
'agent_node_id' => $root->id,
|
||||||
'site_player_id' => 'p-raise-1',
|
'site_player_id' => 'p-raise-1',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
'username' => 'raise1',
|
'username' => 'raise1',
|
||||||
'nickname' => null,
|
'nickname' => null,
|
||||||
'default_currency' => 'NPR',
|
'default_currency' => 'NPR',
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ test('period close aggregates share ledger written by game settlement recorder',
|
|||||||
]);
|
]);
|
||||||
grantSuperAdminRole($super);
|
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([
|
$leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([
|
||||||
'parent_id' => $rootId,
|
'parent_id' => $rootId,
|
||||||
'code' => 'PIPE-C',
|
'code' => 'PIPE-C',
|
||||||
@@ -39,14 +45,10 @@ test('period close aggregates share ledger written by game settlement recorder',
|
|||||||
'username' => 'pipe_c',
|
'username' => 'pipe_c',
|
||||||
'total_share_rate' => 25,
|
'total_share_rate' => 25,
|
||||||
'credit_limit' => 100_000,
|
'credit_limit' => 100_000,
|
||||||
|
'rebate_limit' => 0.01,
|
||||||
'default_player_rebate' => 0.005,
|
'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([
|
$player = Player::query()->create([
|
||||||
'site_code' => $siteCode,
|
'site_code' => $siteCode,
|
||||||
'agent_node_id' => $leaf->id,
|
'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);
|
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([
|
$leaf = app(AgentNodeService::class)->createChild($super, agentChildPayload([
|
||||||
'parent_id' => $rootId,
|
'parent_id' => $rootId,
|
||||||
'code' => 'PIPE-X',
|
'code' => 'PIPE-X',
|
||||||
@@ -192,15 +200,11 @@ test('credit settlement records base rebate plus add-on rebate and keeps extra r
|
|||||||
'username' => 'pipe_x',
|
'username' => 'pipe_x',
|
||||||
'total_share_rate' => 25,
|
'total_share_rate' => 25,
|
||||||
'credit_limit' => 100_000,
|
'credit_limit' => 100_000,
|
||||||
|
'rebate_limit' => 0.01,
|
||||||
'default_player_rebate' => 0.005,
|
'default_player_rebate' => 0.005,
|
||||||
'can_grant_extra_rebate' => true,
|
'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([
|
$player = Player::query()->create([
|
||||||
'site_code' => $siteCode,
|
'site_code' => $siteCode,
|
||||||
'agent_node_id' => $leaf->id,
|
'agent_node_id' => $leaf->id,
|
||||||
|
|||||||
@@ -134,3 +134,56 @@ test('credit player wallet logs distinguish win credit from bill settlement', fu
|
|||||||
->assertJsonPath('data.total', 1)
|
->assertJsonPath('data.total', 1)
|
||||||
->assertJsonPath('data.items.0.type', 'win_credit');
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,107 +4,9 @@ use App\Models\Player;
|
|||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\WalletTxn;
|
use App\Models\WalletTxn;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
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 {
|
test('financial chain audit passes for consistent wallet ledger', function (): void {
|
||||||
$player = Player::query()->create([
|
$player = Player::query()->create([
|
||||||
'site_code' => 'main',
|
'site_code' => 'main',
|
||||||
|
|||||||
@@ -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((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed');
|
||||||
expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ use App\Support\PlayerFundingMode;
|
|||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -21,6 +23,24 @@ beforeEach(function (): void {
|
|||||||
$this->seed(LotterySettingsSeeder::class);
|
$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 {
|
test('native player can login without site code using default site', function (): void {
|
||||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
$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,
|
'status' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$login = $this->postJson('/api/v1/player/auth/login', [
|
$login = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||||
'username' => 'agentplayer0',
|
'username' => 'agentplayer0',
|
||||||
'password' => 'secret-pass',
|
'password' => 'secret-pass',
|
||||||
]);
|
], playerLoginCaptcha()));
|
||||||
|
|
||||||
$login->assertOk()
|
$login->assertOk()
|
||||||
->assertJsonPath('data.player.id', $player->id);
|
->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,
|
'status' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->postJson('/api/v1/player/auth/login', [
|
$this->postJson('/api/v1/player/auth/login', array_merge([
|
||||||
'username' => 'play1',
|
'username' => 'play1',
|
||||||
'password' => 'secret-pass',
|
'password' => 'secret-pass',
|
||||||
])
|
], playerLoginCaptcha()))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.player.id', $player->id)
|
->assertJsonPath('data.player.id', $player->id)
|
||||||
->assertJsonPath('data.player.site_code', 'kk88');
|
->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',
|
'username' => 'dup_user',
|
||||||
'password' => 'secret-pass',
|
'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 {
|
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(),
|
'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,
|
'site_code' => $site->code,
|
||||||
'username' => 'agentplayer1',
|
'username' => 'agentplayer1',
|
||||||
'password' => 'secret-pass',
|
'password' => 'secret-pass',
|
||||||
]);
|
], playerLoginCaptcha()));
|
||||||
|
|
||||||
$login->assertOk();
|
$login->assertOk();
|
||||||
$token = (string) $login->json('data.access_token');
|
$token = (string) $login->json('data.access_token');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Services\Ticket\RiskPoolService;
|
|||||||
use App\Services\Wallet\LotteryTransferService;
|
use App\Services\Wallet\LotteryTransferService;
|
||||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ uses(RefreshDatabase::class);
|
|||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
config(['broadcasting.default' => 'reverb']);
|
config(['broadcasting.default' => 'reverb']);
|
||||||
|
config(['lottery.main_site.wallet_api_url' => null]);
|
||||||
$this->seed(CurrencySeeder::class);
|
$this->seed(CurrencySeeder::class);
|
||||||
|
$this->seed(LotterySettingsSeeder::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet balance notifier dispatches balance update broadcast', function (): void {
|
test('wallet balance notifier dispatches balance update broadcast', function (): void {
|
||||||
|
|||||||
@@ -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(20000, 'NPR'))->toBe(200);
|
||||||
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
|
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
|
||||||
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
|
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
|
||||||
|
expect(CreditAmountScale::minorToMajor(201, 'NPR'))->toBe(3);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user