Compare commits
2 Commits
3a59cc3f42
...
b496a9457c
| Author | SHA1 | Date | |
|---|---|---|---|
| b496a9457c | |||
| 4e370a79dc |
@@ -32,7 +32,13 @@ REVERB_SCHEME=http
|
|||||||
|
|
||||||
QUEUE_CONNECTION=redis
|
QUEUE_CONNECTION=redis
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=redis
|
||||||
LOTTERY_RISK_POOL_USE_REDIS_LUA=false
|
LOTTERY_RISK_POOL_USE_REDIS_LUA=true
|
||||||
|
|
||||||
|
# 生产建议独立配置;留空则回落 MAIN_SITE_SSO_JWT_SECRET(勿在生产与 SSO 混用)
|
||||||
|
# LOTTERY_NATIVE_JWT_SECRET=
|
||||||
|
|
||||||
|
# 预发可设为 false,禁止代理账期关账
|
||||||
|
AGENT_SETTLEMENT_ALLOW_PRODUCTION_CLOSE=true
|
||||||
|
|
||||||
REDIS_HOST=127.0.0.1
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
|
|||||||
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: lotterLaravel CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports: ["6379:6379"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: "8.3"
|
||||||
|
extensions: pdo_sqlite, redis
|
||||||
|
coverage: none
|
||||||
|
- run: composer install --no-interaction --prefer-dist
|
||||||
|
- run: cp .env.example .env && php artisan key:generate
|
||||||
|
- run: php artisan test
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
- 期号 `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` 等权限。绑定经营代理主账号统一绑 `slug=agent`,模板仅含 `prd.settlement.agent.view`;登录态对绑定代理主账号自动补足 `settlement.agent.manage`,实际操作仍受直属边 + 收款方校验。
|
||||||
- 站点管理员(`admin_user_site_roles` + `slug=site_admin`,且**未**绑 `admin_user_agents`)定位单站信用盘运营(代理树/玩家/结算/注单/报表);不含开奖赔率等平台技术权限;开通一级代理线路仅超管(`prd.agent-line.provision`)。
|
- 站点运营(`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`)。
|
||||||
- 结算中心登记收付/确认/坏账/补差 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);常驻 `schedule:work`、`queue:work redis --queue=broadcasts:countdown,broadcasts,default`、`reverb:start`;`CACHE_STORE`/`QUEUE_CONNECTION` 须 Redis;先部署 lotterLaravel 再前端。
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
||||||
|
|
||||||
|
use App\Models\AdminSite;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Models\TransferOrder;
|
use App\Models\TransferOrder;
|
||||||
use App\Models\ReconcileJob;
|
use App\Models\ReconcileJob;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Models\ReconcileItem;
|
use App\Models\ReconcileItem;
|
||||||
|
use App\Support\AdminAgentScope;
|
||||||
use App\Support\AdminApiList;
|
use App\Support\AdminApiList;
|
||||||
|
use App\Support\AdminReconcileScope;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Support\AdminTransferOrderCapabilities;
|
use App\Support\AdminTransferOrderCapabilities;
|
||||||
@@ -28,11 +31,27 @@ final class ReconcileItemIndexController extends Controller
|
|||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
abort_if($admin === null, 401);
|
abort_if($admin === null, 401);
|
||||||
|
|
||||||
|
if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
$p = AdminApiList::readPaging($request, 50, 200);
|
$p = AdminApiList::readPaging($request, 50, 200);
|
||||||
|
|
||||||
$paginator = $reconcile_job->items()
|
$itemsQuery = $reconcile_job->items()->orderBy('id');
|
||||||
->orderBy('id')
|
if (! $admin->isSuperAdmin()) {
|
||||||
->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
$siteIds = $admin->accessibleAdminSiteIds() ?? [];
|
||||||
|
$siteCodes = $siteIds === []
|
||||||
|
? []
|
||||||
|
: AdminSite::query()
|
||||||
|
->whereIn('id', $siteIds)
|
||||||
|
->pluck('code')
|
||||||
|
->map(static fn ($code): string => (string) $code)
|
||||||
|
->all();
|
||||||
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
AdminReconcileScope::applyItemsTransferPlayerScope($itemsQuery, $siteCodes, $agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $itemsQuery->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||||
|
|
||||||
$transferNos = collect($paginator->items())
|
$transferNos = collect($paginator->items())
|
||||||
->map(fn (ReconcileItem $item) => $item->side_a_ref)
|
->map(fn (ReconcileItem $item) => $item->side_a_ref)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
||||||
|
|
||||||
use App\Models\ReconcileJob;
|
use App\Models\ReconcileJob;
|
||||||
|
use App\Support\AdminReconcileScope;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Support\AdminApiList;
|
use App\Support\AdminApiList;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -13,6 +14,9 @@ final class ReconcileJobIndexController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
abort_if($admin === null, 401);
|
||||||
|
|
||||||
$p = AdminApiList::readPaging($request);
|
$p = AdminApiList::readPaging($request);
|
||||||
$type = trim((string) $request->query('reconcile_type', ''));
|
$type = trim((string) $request->query('reconcile_type', ''));
|
||||||
|
|
||||||
@@ -21,6 +25,8 @@ final class ReconcileJobIndexController extends Controller
|
|||||||
$q->where('reconcile_type', $type);
|
$q->where('reconcile_type', $type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AdminReconcileScope::applyToJobsQuery($q, $admin);
|
||||||
|
|
||||||
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
|
||||||
|
|
||||||
return AdminApiList::json($paginator, fn (ReconcileJob $j) => $this->row($j));
|
return AdminApiList::json($paginator, fn (ReconcileJob $j) => $this->row($j));
|
||||||
|
|||||||
@@ -3,15 +3,24 @@
|
|||||||
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
|
||||||
|
|
||||||
use App\Models\ReconcileJob;
|
use App\Models\ReconcileJob;
|
||||||
|
use App\Support\AdminReconcileScope;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
/** GET /api/v1/admin/reconcile-jobs/{reconcile_job} */
|
/** GET /api/v1/admin/reconcile-jobs/{reconcile_job} */
|
||||||
final class ReconcileJobShowController extends Controller
|
final class ReconcileJobShowController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(ReconcileJob $reconcile_job): JsonResponse
|
public function __invoke(Request $request, ReconcileJob $reconcile_job): JsonResponse
|
||||||
{
|
{
|
||||||
|
$admin = $request->lotteryAdmin();
|
||||||
|
abort_if($admin === null, 401);
|
||||||
|
|
||||||
|
if ($denied = AdminReconcileScope::denyUnlessJobAccessible($admin, $reconcile_job)) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'id' => (int) $reconcile_job->id,
|
'id' => (int) $reconcile_job->id,
|
||||||
'job_no' => $reconcile_job->job_no,
|
'job_no' => $reconcile_job->job_no,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Admin\Reports;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Admin\AdminReportQueryRequest;
|
|
||||||
use App\Services\Admin\AdminReportQueryService;
|
|
||||||
use App\Support\AdminApiList;
|
|
||||||
use App\Support\AdminScopePolicy;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
/** GET /api/v1/admin/reports/rebate-commission */
|
|
||||||
final class AdminReportRebateCommissionController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(AdminReportQueryRequest $request, AdminReportQueryService $service): JsonResponse
|
|
||||||
{
|
|
||||||
$admin = $request->lotteryAdmin();
|
|
||||||
abort_if($admin === null, 401);
|
|
||||||
|
|
||||||
$validated = $request->validated();
|
|
||||||
$p = AdminApiList::readPaging($request);
|
|
||||||
$range = $service->resolveDateRange($validated);
|
|
||||||
$playCode = isset($validated['play_code']) ? trim((string) $validated['play_code']) : null;
|
|
||||||
$scope = AdminScopePolicy::resolveContext($request, $admin, 'site_code', 'agent_node_id');
|
|
||||||
|
|
||||||
$paginator = $service->rebateCommissionPaginated(
|
|
||||||
$playCode !== '' ? $playCode : null,
|
|
||||||
$range['date_from'],
|
|
||||||
$range['date_to'],
|
|
||||||
$p['page'],
|
|
||||||
$p['perPage'],
|
|
||||||
$scope,
|
|
||||||
);
|
|
||||||
|
|
||||||
return AdminApiList::jsonWith($paginator, static function (object $row): array {
|
|
||||||
return [
|
|
||||||
'play_code' => (string) $row->play_code,
|
|
||||||
'total_rebate_minor' => (int) $row->total_rebate_minor,
|
|
||||||
'order_count' => (int) $row->order_count,
|
|
||||||
'ticket_item_count' => (int) $row->ticket_item_count,
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope),
|
|
||||||
'disclaimer' => 'wallet_instant_rebate_not_agent_period_settlement',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,6 @@ final class ReportJobStoreRequest extends ApiFormRequest
|
|||||||
'hot_number_risk_report',
|
'hot_number_risk_report',
|
||||||
'play_dimension_report',
|
'play_dimension_report',
|
||||||
'sold_out_number_report',
|
'sold_out_number_report',
|
||||||
'rebate_commission_report',
|
|
||||||
'audit_operation_report',
|
'audit_operation_report',
|
||||||
'wallet_txns_daily',
|
'wallet_txns_daily',
|
||||||
'transfer_orders_daily',
|
'transfer_orders_daily',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class ReconcileJob extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'job_no',
|
'job_no',
|
||||||
'admin_user_id',
|
'admin_user_id',
|
||||||
|
'admin_site_id',
|
||||||
'reconcile_type',
|
'reconcile_type',
|
||||||
'status',
|
'status',
|
||||||
'period_start',
|
'period_start',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use App\Support\AdminDataScope;
|
|||||||
use App\Support\AdminScopeContext;
|
use App\Support\AdminScopeContext;
|
||||||
use App\Support\AdminAgentScope;
|
use App\Support\AdminAgentScope;
|
||||||
use App\Support\AdminScopeContextResolver;
|
use App\Support\AdminScopeContextResolver;
|
||||||
use App\Support\SitePlatformRole;
|
use App\Support\SiteOperatorRoles;
|
||||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +32,8 @@ final class AdminDashboardSnapshotBuilder
|
|||||||
private readonly AdminReportQueryService $reportQuery,
|
private readonly AdminReportQueryService $reportQuery,
|
||||||
private readonly AgentDashboardOverviewBuilder $agentOverview,
|
private readonly AgentDashboardOverviewBuilder $agentOverview,
|
||||||
private readonly SiteDashboardOverviewBuilder $siteOverview,
|
private readonly SiteDashboardOverviewBuilder $siteOverview,
|
||||||
|
private readonly SiteFinanceDashboardOverviewBuilder $siteFinanceOverview,
|
||||||
|
private readonly SiteCsDashboardOverviewBuilder $siteCsOverview,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
@@ -60,12 +62,19 @@ final class AdminDashboardSnapshotBuilder
|
|||||||
],
|
],
|
||||||
'agent_overview' => null,
|
'agent_overview' => null,
|
||||||
'site_overview' => null,
|
'site_overview' => null,
|
||||||
|
'site_finance_overview' => null,
|
||||||
|
'site_cs_overview' => null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($admin->primaryAgentNode() !== null) {
|
if ($admin->primaryAgentNode() !== null) {
|
||||||
$out['agent_overview'] = $this->agentOverview->build($admin);
|
$out['agent_overview'] = $this->agentOverview->build($admin);
|
||||||
} elseif (SitePlatformRole::userHasSiteAdminRole($admin)) {
|
} elseif (SiteOperatorRoles::userHasSiteOperatorRole($admin)) {
|
||||||
$out['site_overview'] = $this->siteOverview->build($admin, $scope);
|
$operatorSlug = SiteOperatorRoles::primarySiteOperatorSlug($admin);
|
||||||
|
match ($operatorSlug) {
|
||||||
|
SiteOperatorRoles::SLUG_SITE_FINANCE => $out['site_finance_overview'] = $this->siteFinanceOverview->build($admin, $scope),
|
||||||
|
SiteOperatorRoles::SLUG_SITE_CS => $out['site_cs_overview'] = $this->siteCsOverview->build($admin),
|
||||||
|
default => $out['site_overview'] = $this->siteOverview->build($admin, $scope),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($canDraw) {
|
if ($canDraw) {
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ namespace App\Services\Admin;
|
|||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Player;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Models\ReconcileJob;
|
use App\Models\ReconcileJob;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Models\ReconcileItem;
|
use App\Models\ReconcileItem;
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
use App\Services\AuditLogger;
|
use App\Services\AuditLogger;
|
||||||
|
use App\Support\AdminReconcileScope;
|
||||||
|
use App\Support\AdminScopePolicy;
|
||||||
|
use App\Support\AdminSiteScope;
|
||||||
|
use App\Support\ApiMessage;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Services\Wallet\WalletTransferReconcileDetector;
|
use App\Services\Wallet\WalletTransferReconcileDetector;
|
||||||
|
|
||||||
@@ -102,6 +108,23 @@ final class AdminReconcileJobService
|
|||||||
$periodEnd ??= now()->endOfDay();
|
$periodEnd ??= now()->endOfDay();
|
||||||
$periodStart ??= $periodEnd->copy()->startOfDay();
|
$periodStart ??= $periodEnd->copy()->startOfDay();
|
||||||
|
|
||||||
|
$player = null;
|
||||||
|
if ($playerId !== null) {
|
||||||
|
$player = Player::query()->find($playerId);
|
||||||
|
if ($player === null || ! AdminSiteScope::playerAccessible($admin, $player)) {
|
||||||
|
abort(ApiMessage::errorResponse(
|
||||||
|
$request,
|
||||||
|
'admin.site_player_access_denied',
|
||||||
|
ErrorCode::AdminForbidden->value,
|
||||||
|
null,
|
||||||
|
403,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope = AdminScopePolicy::resolveContext($request, $admin);
|
||||||
|
$adminSiteId = AdminReconcileScope::resolveAdminSiteIdForJob($admin, $player);
|
||||||
|
|
||||||
[$items] = $this->detector->scanItems(
|
[$items] = $this->detector->scanItems(
|
||||||
$periodStart,
|
$periodStart,
|
||||||
$periodEnd,
|
$periodEnd,
|
||||||
@@ -109,9 +132,17 @@ final class AdminReconcileJobService
|
|||||||
staleMinutes: 15,
|
staleMinutes: 15,
|
||||||
playerId: $playerId,
|
playerId: $playerId,
|
||||||
includeMainSiteCheck: true,
|
includeMainSiteCheck: true,
|
||||||
|
scope: $scope,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job = $this->detector->persistJob($items, $periodStart, $periodEnd, (int) $admin->getKey());
|
$job = $this->detector->persistJob(
|
||||||
|
$items,
|
||||||
|
$periodStart,
|
||||||
|
$periodEnd,
|
||||||
|
(int) $admin->getKey(),
|
||||||
|
$adminSiteId,
|
||||||
|
$playerId,
|
||||||
|
);
|
||||||
|
|
||||||
AuditLogger::recordForAdmin(
|
AuditLogger::recordForAdmin(
|
||||||
$admin,
|
$admin,
|
||||||
|
|||||||
@@ -478,19 +478,6 @@ final class AdminReportQueryService
|
|||||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
return $query->paginate($perPage, ['*'], 'page', $page);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rebateCommissionPaginated(
|
|
||||||
?string $playCode,
|
|
||||||
string $dateFrom,
|
|
||||||
string $dateTo,
|
|
||||||
int $page,
|
|
||||||
int $perPage,
|
|
||||||
AdminUser|AdminScopeContext|null $scope = null,
|
|
||||||
): LengthAwarePaginator {
|
|
||||||
$query = $this->rebateCommissionBaseQuery($playCode, $dateFrom, $dateTo, $scope);
|
|
||||||
|
|
||||||
return $query->paginate($perPage, ['*'], 'page', $page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<int, string|int|float|null>>
|
* @return list<array<int, string|int|float|null>>
|
||||||
*/
|
*/
|
||||||
@@ -505,7 +492,6 @@ final class AdminReportQueryService
|
|||||||
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope),
|
'daily_profit_summary' => $this->dailyProfitExportRows($dateFrom, $dateTo, $scope),
|
||||||
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
'player_win_loss' => $this->playerWinLossExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
||||||
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
'play_dimension_report' => $this->playDimensionExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
||||||
'rebate_commission_report' => $this->rebateCommissionExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
|
||||||
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
|
'audit_operation_report' => $this->auditExportRows($filterJson, $dateFrom, $dateTo),
|
||||||
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
'wallet_transfer_report', 'transfer_orders_daily' => $this->transferOrdersExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
||||||
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
'wallet_txns_daily' => $this->walletTxnsExportRows($filterJson, $dateFrom, $dateTo, $scope),
|
||||||
@@ -546,7 +532,6 @@ final class AdminReportQueryService
|
|||||||
'hot_number_risk_report' => '热门号码风险报表',
|
'hot_number_risk_report' => '热门号码风险报表',
|
||||||
'play_dimension_report' => '玩法维度报表',
|
'play_dimension_report' => '玩法维度报表',
|
||||||
'sold_out_number_report' => '售罄号码报表',
|
'sold_out_number_report' => '售罄号码报表',
|
||||||
'rebate_commission_report' => '佣金回水报表',
|
|
||||||
'audit_operation_report' => '后台操作审计报表',
|
'audit_operation_report' => '后台操作审计报表',
|
||||||
default => $reportType,
|
default => $reportType,
|
||||||
};
|
};
|
||||||
@@ -618,28 +603,6 @@ final class AdminReportQueryService
|
|||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<int, string|int|float|null>>
|
|
||||||
*/
|
|
||||||
private function rebateCommissionExportRows(?array $filterJson, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null): array
|
|
||||||
{
|
|
||||||
$playCode = isset($filterJson['play_code']) ? trim((string) $filterJson['play_code']) : null;
|
|
||||||
$rows = [
|
|
||||||
['玩法', '回水', '订单数', '注单数'],
|
|
||||||
];
|
|
||||||
$items = $this->rebateCommissionBaseQuery($playCode !== '' ? $playCode : null, $dateFrom, $dateTo, $scope)->get();
|
|
||||||
foreach ($items as $row) {
|
|
||||||
$rows[] = [
|
|
||||||
(string) $row->play_code,
|
|
||||||
(int) $row->total_rebate_minor,
|
|
||||||
(int) $row->order_count,
|
|
||||||
(int) $row->ticket_item_count,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<int, string|int|float|null>>
|
* @return list<array<int, string|int|float|null>>
|
||||||
*/
|
*/
|
||||||
@@ -657,7 +620,8 @@ final class AdminReportQueryService
|
|||||||
$q->whereDate('created_at', '>=', $dateFrom)
|
$q->whereDate('created_at', '>=', $dateFrom)
|
||||||
->whereDate('created_at', '<=', $dateTo);
|
->whereDate('created_at', '<=', $dateTo);
|
||||||
|
|
||||||
foreach ($q->limit(5000)->get() as $log) {
|
$limited = \App\Support\LimitedQuery::get($q, 5000);
|
||||||
|
foreach ($limited['rows'] as $log) {
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
(int) $log->id,
|
(int) $log->id,
|
||||||
(string) $log->operator_type,
|
(string) $log->operator_type,
|
||||||
@@ -669,6 +633,10 @@ final class AdminReportQueryService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($limited['truncated']) {
|
||||||
|
$rows[] = ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试'];
|
||||||
|
}
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,31 +707,6 @@ final class AdminReportQueryService
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return \Illuminate\Database\Query\Builder */
|
|
||||||
private function rebateCommissionBaseQuery(?string $playCode, string $dateFrom, string $dateTo, AdminUser|AdminScopeContext|null $scope = null)
|
|
||||||
{
|
|
||||||
$context = $this->normalizeScope($scope);
|
|
||||||
$query = DB::table('ticket_items as ti')
|
|
||||||
->join('ticket_orders as o', 'o.id', '=', 'ti.order_id')
|
|
||||||
->join('draws as d', 'd.id', '=', 'o.draw_id')
|
|
||||||
->selectRaw('ti.play_code')
|
|
||||||
->selectRaw('SUM(ti.total_bet_amount - ti.actual_deduct_amount) as total_rebate_minor')
|
|
||||||
->selectRaw('COUNT(DISTINCT o.id) as order_count')
|
|
||||||
->selectRaw('COUNT(ti.id) as ticket_item_count')
|
|
||||||
->whereDate('d.business_date', '>=', $dateFrom)
|
|
||||||
->whereDate('d.business_date', '<=', $dateTo)
|
|
||||||
->groupBy('ti.play_code')
|
|
||||||
->orderBy('ti.play_code');
|
|
||||||
|
|
||||||
if ($playCode !== null && $playCode !== '') {
|
|
||||||
$query->where('ti.play_code', $playCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
AdminDataScope::applyToTicketOrdersViaPlayer($query, $context?->admin, 'o');
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<int, string|int|float|null>>
|
* @return list<array<int, string|int|float|null>>
|
||||||
*/
|
*/
|
||||||
@@ -844,6 +787,10 @@ final class AdminReportQueryService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($limited['truncated']) {
|
||||||
|
array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']);
|
||||||
|
}
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
app/Services/Admin/SiteCsDashboardOverviewBuilder.php
Normal file
62
app/Services/Admin/SiteCsDashboardOverviewBuilder.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Player;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 站点客服仪表盘:玩家规模与今日注单活跃度,不含全局盈亏。 */
|
||||||
|
final class SiteCsDashboardOverviewBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function build(AdminUser $admin): ?array
|
||||||
|
{
|
||||||
|
if (! $admin->hasPermissionCode('dashboard.view')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
|
||||||
|
if ($site === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteId = (int) $site->id;
|
||||||
|
$siteCode = (string) $site->code;
|
||||||
|
$today = now()->toDateString();
|
||||||
|
$activity = $this->todayTicketStats($siteCode, $today);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_name' => (string) $site->name,
|
||||||
|
'player_count' => (int) Player::query()->where('site_code', $siteCode)->count(),
|
||||||
|
'ticket_order_count_today' => $activity['order_count'],
|
||||||
|
'active_player_count_today' => $activity['player_count'],
|
||||||
|
'latest_ticket_at' => $activity['latest_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{player_count: int, order_count: int, latest_at: ?string}
|
||||||
|
*/
|
||||||
|
private function todayTicketStats(string $siteCode, string $today): array
|
||||||
|
{
|
||||||
|
$base = DB::table('ticket_orders as o')
|
||||||
|
->join('draws as d', 'd.id', '=', 'o.draw_id')
|
||||||
|
->join('players as p', 'p.id', '=', 'o.player_id')
|
||||||
|
->where('p.site_code', $siteCode)
|
||||||
|
->whereDate('d.business_date', $today);
|
||||||
|
|
||||||
|
$latestAt = (clone $base)->max('o.created_at');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'player_count' => (int) (clone $base)->distinct('o.player_id')->count('o.player_id'),
|
||||||
|
'order_count' => (int) (clone $base)->count(),
|
||||||
|
'latest_at' => $latestAt !== null ? Carbon::parse((string) $latestAt)->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin;
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
use App\Models\AdminSite;
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Models\AgentNode;
|
use App\Models\AgentNode;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
@@ -27,7 +26,7 @@ final class SiteDashboardOverviewBuilder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$site = $this->resolvePrimarySite($admin);
|
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
|
||||||
if ($site === null) {
|
if ($site === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -52,7 +51,11 @@ final class SiteDashboardOverviewBuilder
|
|||||||
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped)
|
$currencyCode = $this->reportQuery->resolvePeriodCurrencyCode($today, $today, $scoped)
|
||||||
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped);
|
?? $this->reportQuery->resolvePeriodCurrencyCode($sevenDayFrom, $today, $scoped);
|
||||||
$todayActivity = $this->todayActivityStats($siteCode, $today);
|
$todayActivity = $this->todayActivityStats($siteCode, $today);
|
||||||
$pendingBills = $this->pendingBillStats($siteId);
|
$billStats = SiteSettlementBillStats::forSite($siteId);
|
||||||
|
$pendingBills = [
|
||||||
|
'count' => $billStats['pending_confirm_count'] + $billStats['payable_count'],
|
||||||
|
'unpaid_minor' => $billStats['payable_unpaid_minor'],
|
||||||
|
];
|
||||||
$topAgentToday = $this->topAgentToday($scoped, $today);
|
$topAgentToday = $this->topAgentToday($scoped, $today);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -78,18 +81,6 @@ final class SiteDashboardOverviewBuilder
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePrimarySite(AdminUser $admin): ?AdminSite
|
|
||||||
{
|
|
||||||
$siteIds = $admin->accessibleAdminSiteIds();
|
|
||||||
if ($siteIds === null || $siteIds === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AdminSite::query()
|
|
||||||
->where('id', (int) $siteIds[0])
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{player_count: int, order_count: int, latest_bet_at: ?string}
|
* @return array{player_count: int, order_count: int, latest_bet_at: ?string}
|
||||||
*/
|
*/
|
||||||
@@ -110,22 +101,6 @@ final class SiteDashboardOverviewBuilder
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{count: int, unpaid_minor: int}
|
|
||||||
*/
|
|
||||||
private function pendingBillStats(int $siteId): array
|
|
||||||
{
|
|
||||||
$query = DB::table('settlement_bills as sb')
|
|
||||||
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
|
||||||
->where('sp.admin_site_id', $siteId)
|
|
||||||
->whereIn('sb.status', ['pending', 'pending_confirm', 'partial']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'count' => (int) $query->count(),
|
|
||||||
'unpaid_minor' => (int) $query->sum('sb.unpaid_amount'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
22
app/Services/Admin/SiteDashboardSiteResolver.php
Normal file
22
app/Services/Admin/SiteDashboardSiteResolver.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
|
use App\Models\AdminSite;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
|
||||||
|
/** 站点运营仪表盘:解析账号绑定的主接入站点。 */
|
||||||
|
final class SiteDashboardSiteResolver
|
||||||
|
{
|
||||||
|
public static function resolvePrimarySite(AdminUser $admin): ?AdminSite
|
||||||
|
{
|
||||||
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
|
if ($siteIds === null || $siteIds === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdminSite::query()
|
||||||
|
->where('id', (int) $siteIds[0])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/Admin/SiteFinanceDashboardOverviewBuilder.php
Normal file
92
app/Services/Admin/SiteFinanceDashboardOverviewBuilder.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\TransferOrder;
|
||||||
|
use App\Support\AdminAgentScope;
|
||||||
|
use App\Support\AdminDataScope;
|
||||||
|
use App\Support\AdminScopeContext;
|
||||||
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||||
|
|
||||||
|
/** 站点财务仪表盘:对账异常、待确认/待收付账单、钱包玩家规模。 */
|
||||||
|
final class SiteFinanceDashboardOverviewBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function build(AdminUser $admin, AdminScopeContext $scope): ?array
|
||||||
|
{
|
||||||
|
if (! $admin->hasPermissionCode('dashboard.view')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = SiteDashboardSiteResolver::resolvePrimarySite($admin);
|
||||||
|
if ($site === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteId = (int) $site->id;
|
||||||
|
$siteCode = (string) $site->code;
|
||||||
|
$billStats = SiteSettlementBillStats::forSite($siteId);
|
||||||
|
$currencyCode = Player::query()
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->whereNotNull('default_currency')
|
||||||
|
->value('default_currency');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'admin_site_id' => $siteId,
|
||||||
|
'site_code' => $siteCode,
|
||||||
|
'site_name' => (string) $site->name,
|
||||||
|
'wallet_player_count' => (int) Player::query()
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('funding_mode', 'wallet')
|
||||||
|
->count(),
|
||||||
|
'credit_player_count' => (int) Player::query()
|
||||||
|
->where('site_code', $siteCode)
|
||||||
|
->where('funding_mode', 'credit')
|
||||||
|
->count(),
|
||||||
|
'pending_confirm_bill_count' => $billStats['pending_confirm_count'],
|
||||||
|
'payable_bill_count' => $billStats['payable_count'],
|
||||||
|
'payable_unpaid_minor' => $billStats['payable_unpaid_minor'],
|
||||||
|
'pending_bill_count' => $billStats['pending_confirm_count'] + $billStats['payable_count'],
|
||||||
|
'pending_unpaid_minor' => $billStats['payable_unpaid_minor'],
|
||||||
|
'abnormal_transfer_count' => $this->abnormalTransferCount($admin, $scope),
|
||||||
|
'currency_code' => is_string($currencyCode) && $currencyCode !== '' ? $currencyCode : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function abnormalTransferCount(AdminUser $admin, AdminScopeContext $scope): int
|
||||||
|
{
|
||||||
|
$query = TransferOrder::query()
|
||||||
|
->whereIn('status', ['processing', 'failed', 'pending_reconcile']);
|
||||||
|
AdminDataScope::applyEloquentViaPlayer($query, $admin);
|
||||||
|
$this->applyRequestedScopeViaPlayer($query, $scope);
|
||||||
|
|
||||||
|
return (int) $query->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EloquentBuilder<mixed> $query
|
||||||
|
*/
|
||||||
|
private function applyRequestedScopeViaPlayer(EloquentBuilder $query, AdminScopeContext $scope): void
|
||||||
|
{
|
||||||
|
$siteCode = $scope->effectiveRequestedSiteCode();
|
||||||
|
$agentNodeId = $scope->effectiveRequestedAgentNodeId();
|
||||||
|
if ($siteCode === null && $agentNodeId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = $scope->admin;
|
||||||
|
$query->whereHas('player', static function (EloquentBuilder $playerQuery) use ($admin, $siteCode, $agentNodeId): void {
|
||||||
|
if ($siteCode !== null) {
|
||||||
|
$playerQuery->where('site_code', $siteCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($agentNodeId !== null) {
|
||||||
|
AdminAgentScope::applyRequestedAgentNodeFilter($playerQuery, $admin, $agentNodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Services/Admin/SiteSettlementBillStats.php
Normal file
36
app/Services/Admin/SiteSettlementBillStats.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 站点信用账期账单摘要(待确认 / 待收付)。 */
|
||||||
|
final class SiteSettlementBillStats
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* pending_confirm_count: int,
|
||||||
|
* payable_count: int,
|
||||||
|
* payable_unpaid_minor: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public static function forSite(int $siteId): array
|
||||||
|
{
|
||||||
|
$pendingConfirmQuery = DB::table('settlement_bills as sb')
|
||||||
|
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||||
|
->where('sp.admin_site_id', $siteId)
|
||||||
|
->where('sb.status', 'pending_confirm');
|
||||||
|
|
||||||
|
$payableQuery = DB::table('settlement_bills as sb')
|
||||||
|
->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id')
|
||||||
|
->where('sp.admin_site_id', $siteId)
|
||||||
|
->whereIn('sb.status', ['confirmed', 'partial_paid', 'overdue'])
|
||||||
|
->where('sb.unpaid_amount', '>', 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending_confirm_count' => (int) (clone $pendingConfirmQuery)->count(),
|
||||||
|
'payable_count' => (int) (clone $payableQuery)->count(),
|
||||||
|
'payable_unpaid_minor' => (int) (clone $payableQuery)->sum('sb.unpaid_amount'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Models\AdminUser;
|
|||||||
use App\Support\AdminAgentSettlementScope;
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Support\AgentSettlementPeriodWindow;
|
use App\Support\AgentSettlementPeriodWindow;
|
||||||
use App\Support\CurrencyFormatter;
|
use App\Support\CurrencyFormatter;
|
||||||
|
use App\Support\LimitedQuery;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -53,7 +54,9 @@ final class SettlementCenterLedgerService
|
|||||||
$periodId = $filters->settlementPeriodId;
|
$periodId = $filters->settlementPeriodId;
|
||||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||||
$settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
|
$settledRange = $this->resolveSettledRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
$playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||||
|
$playerBills = $playerBillsResult['map'];
|
||||||
|
$billsTruncated = $playerBillsResult['truncated'];
|
||||||
|
|
||||||
$stubQueries = [];
|
$stubQueries = [];
|
||||||
if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
|
if ($this->shouldIncludeLedgerStub($filters, 'credit')) {
|
||||||
@@ -130,6 +133,7 @@ final class SettlementCenterLedgerService
|
|||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $perPage,
|
'per_page' => $perPage,
|
||||||
'ledger_source' => 'settlement_ledger',
|
'ledger_source' => 'settlement_ledger',
|
||||||
|
'truncated' => $billsTruncated || $total > 5000,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +157,12 @@ final class SettlementCenterLedgerService
|
|||||||
): array {
|
): array {
|
||||||
$periodId = $filters->settlementPeriodId;
|
$periodId = $filters->settlementPeriodId;
|
||||||
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
$range = $this->resolveCreatedRange($periodId, $filters->createdFrom, $filters->createdTo);
|
||||||
$rows = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
|
$fetched = $this->fetchBetFlowCreditRows($admin, $siteCode, $range, $filters);
|
||||||
$playerBills = $this->playerBillsMap($admin, $siteCode, $periodId);
|
$rows = $fetched['rows'];
|
||||||
|
$sourceTruncated = $fetched['truncated'];
|
||||||
|
$playerBillsResult = $this->playerBillsMap($admin, $siteCode, $periodId);
|
||||||
|
$playerBills = $playerBillsResult['map'];
|
||||||
|
$billsTruncated = $playerBillsResult['truncated'];
|
||||||
|
|
||||||
$ticketIds = [];
|
$ticketIds = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
@@ -207,6 +215,7 @@ final class SettlementCenterLedgerService
|
|||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $perPage,
|
'per_page' => $perPage,
|
||||||
'ledger_source' => 'credit_ledger',
|
'ledger_source' => 'credit_ledger',
|
||||||
|
'truncated' => $sourceTruncated || $billsTruncated,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,7 +820,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, object>
|
* @return array{map: array<int, object>, truncated: bool}
|
||||||
*/
|
*/
|
||||||
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
|
private function playerBillsMap(AdminUser $admin, string $siteCode, ?int $periodId): array
|
||||||
{
|
{
|
||||||
@@ -841,8 +850,9 @@ final class SettlementCenterLedgerService
|
|||||||
|
|
||||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p');
|
||||||
|
|
||||||
|
$limited = LimitedQuery::get($query, 500);
|
||||||
$map = [];
|
$map = [];
|
||||||
foreach ($query->limit(500)->get() as $bill) {
|
foreach ($limited['rows'] as $bill) {
|
||||||
$pid = (int) $bill->player_id;
|
$pid = (int) $bill->player_id;
|
||||||
if (! isset($map[$pid])) {
|
if (! isset($map[$pid])) {
|
||||||
$map[$pid] = $bill;
|
$map[$pid] = $bill;
|
||||||
@@ -859,7 +869,10 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return [
|
||||||
|
'map' => $map,
|
||||||
|
'truncated' => $limited['truncated'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -921,7 +934,7 @@ final class SettlementCenterLedgerService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||||
* @return list<object>
|
* @return array{rows: list<object>, truncated: bool}
|
||||||
*/
|
*/
|
||||||
private function fetchBetFlowCreditRows(
|
private function fetchBetFlowCreditRows(
|
||||||
AdminUser $admin,
|
AdminUser $admin,
|
||||||
@@ -975,7 +988,12 @@ final class SettlementCenterLedgerService
|
|||||||
$query->whereBetween('cl.created_at', $range);
|
$query->whereBetween('cl.created_at', $range);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->limit(5000)->get()->all();
|
$limited = LimitedQuery::get($query, 5000);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $limited['rows']->all(),
|
||||||
|
'truncated' => $limited['truncated'],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ final class DrawTickService
|
|||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
report($e);
|
report($e);
|
||||||
|
\Illuminate\Support\Facades\Log::warning('draw_tick_settlement_failed', [
|
||||||
|
'draw_id' => $draw->id,
|
||||||
|
'draw_no' => $draw->draw_no,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ final class PlayerCreditService
|
|||||||
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
|
return CreditAmountScale::majorToMinor($this->availableCredit($player), $currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 逾期/代理线门禁与可用额度预检(不占额)。 */
|
||||||
|
public function assertCreditPreflight(Player $player, int $amountMinor): void
|
||||||
|
{
|
||||||
|
if (! PlayerFundingMode::usesCredit($player) || $amountMinor <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertCreditGuards($player);
|
||||||
|
|
||||||
|
$currency = (string) $player->default_currency;
|
||||||
|
if ($amountMinor > $this->availableCreditMinor($player, $currency)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'credit' => ['insufficient'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function holdForBet(Player $player, int $amountMinor): void
|
public function holdForBet(Player $player, int $amountMinor): void
|
||||||
{
|
{
|
||||||
if ($amountMinor <= 0) {
|
if ($amountMinor <= 0) {
|
||||||
@@ -73,22 +90,45 @@ final class PlayerCreditService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$currency = (string) $player->default_currency;
|
$currency = (string) $player->default_currency;
|
||||||
$availableMinor = $this->availableCreditMinor($player, $currency);
|
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$row = DB::table('player_credit_accounts')
|
||||||
|
->where('player_id', $player->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'credit' => ['insufficient'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$availableMajor = max(
|
||||||
|
0,
|
||||||
|
(int) $row->credit_limit - (int) $row->used_credit - (int) $row->frozen_credit,
|
||||||
|
);
|
||||||
|
$availableMinor = CreditAmountScale::majorToMinor($availableMajor, $currency);
|
||||||
if ($amountMinor > $availableMinor) {
|
if ($amountMinor > $availableMinor) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'credit' => ['insufficient'],
|
'credit' => ['insufficient'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
$updated = DB::table('player_credit_accounts')
|
||||||
|
|
||||||
DB::table('player_credit_accounts')
|
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
|
->whereRaw('credit_limit - used_credit - frozen_credit >= ?', [$majorDelta])
|
||||||
->update([
|
->update([
|
||||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||||
'updated_at' => now(),
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($updated !== 1) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'credit' => ['insufficient'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
DB::table('credit_ledger')->insert([
|
DB::table('credit_ledger')->insert([
|
||||||
'owner_type' => 'player',
|
'owner_type' => 'player',
|
||||||
'owner_id' => $player->id,
|
'owner_id' => $player->id,
|
||||||
@@ -96,8 +136,8 @@ final class PlayerCreditService
|
|||||||
'reason' => 'bet_hold',
|
'reason' => 'bet_hold',
|
||||||
'ref_type' => 'bet',
|
'ref_type' => 'bet',
|
||||||
'ref_id' => null,
|
'ref_id' => null,
|
||||||
'created_at' => now(),
|
'created_at' => $now,
|
||||||
'updated_at' => now(),
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +153,13 @@ final class PlayerCreditService
|
|||||||
|
|
||||||
$currency = (string) $player->default_currency;
|
$currency = (string) $player->default_currency;
|
||||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
|
||||||
|
$now = now();
|
||||||
|
|
||||||
DB::table('player_credit_accounts')
|
DB::table('player_credit_accounts')
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
->update([
|
->update([
|
||||||
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
'used_credit' => DB::raw('used_credit + '.$majorDelta),
|
||||||
'updated_at' => now(),
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::table('credit_ledger')->insert([
|
DB::table('credit_ledger')->insert([
|
||||||
@@ -163,6 +204,16 @@ final class PlayerCreditService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertCreditGuards($player);
|
||||||
|
$this->holdForBet($player, $amountMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertCreditGuards(Player $player): void
|
||||||
|
{
|
||||||
|
if (! PlayerFundingMode::usesCredit($player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$overdue = DB::table('settlement_bills')
|
$overdue = DB::table('settlement_bills')
|
||||||
->where('owner_type', 'player')
|
->where('owner_type', 'player')
|
||||||
->where('owner_id', $player->id)
|
->where('owner_id', $player->id)
|
||||||
@@ -181,8 +232,6 @@ final class PlayerCreditService
|
|||||||
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
|
AgentOverdueGuard::assertAgentMayGrantCredit($agentNodeId);
|
||||||
AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId);
|
AgentOverdueGuard::assertAgentLineMayPlaceBet($agentNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->holdForBet($player, $amountMinor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
|
public function releaseBetHold(Player $player, int $amountMinor, int $ticketItemId): void
|
||||||
@@ -205,6 +254,26 @@ final class PlayerCreditService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reverseBetHold(Player $player, int $amountMinor): void
|
||||||
|
{
|
||||||
|
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->decreaseUsedCredit($player, $amountMinor);
|
||||||
|
|
||||||
|
DB::table('credit_ledger')->insert([
|
||||||
|
'owner_type' => 'player',
|
||||||
|
'owner_id' => $player->id,
|
||||||
|
'amount' => $amountMinor,
|
||||||
|
'reason' => 'bet_hold_release',
|
||||||
|
'ref_type' => 'bet',
|
||||||
|
'ref_id' => null,
|
||||||
|
'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) {
|
||||||
@@ -254,12 +323,16 @@ final class PlayerCreditService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$playerId = (int) $player->id;
|
$playerId = (int) $player->id;
|
||||||
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
|
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
|
||||||
|
|
||||||
|
$row = DB::table('player_credit_accounts')
|
||||||
|
->where('player_id', $playerId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
if ($row === null) {
|
if ($row === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, (string) $player->default_currency);
|
|
||||||
$next = max(0, (int) $row->used_credit - $majorDelta);
|
$next = max(0, (int) $row->used_credit - $majorDelta);
|
||||||
DB::table('player_credit_accounts')
|
DB::table('player_credit_accounts')
|
||||||
->where('player_id', $playerId)
|
->where('player_id', $playerId)
|
||||||
|
|||||||
@@ -148,6 +148,54 @@ final class RiskPoolService
|
|||||||
foreach ($locks as $lock) {
|
foreach ($locks as $lock) {
|
||||||
$number4d = $lock['number_4d'];
|
$number4d = $lock['number_4d'];
|
||||||
$amount = (int) $lock['amount'];
|
$amount = (int) $lock['amount'];
|
||||||
|
$this->acquireRedisLockForCombination($drawId, $number4d, $amount);
|
||||||
|
|
||||||
|
$acquired[] = ['number_4d' => $number4d, 'amount' => $amount];
|
||||||
|
$total += $amount;
|
||||||
|
|
||||||
|
$this->syncDatabaseAfterRedisAcquire($drawId, $ticketItem, $number4d, $amount);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->releaseRedisLocks($drawId, $acquired);
|
||||||
|
$this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line');
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 事务回滚时补偿 Redis 侧已占用额度(DB 锁行会随事务回滚)。
|
||||||
|
*
|
||||||
|
* @param list<array{number_4d:string, amount:int}> $locks
|
||||||
|
*/
|
||||||
|
public function compensateRedisAcquires(int $drawId, array $locks): void
|
||||||
|
{
|
||||||
|
if ($locks === [] || ! $this->shouldUseRedisAtomicLocks()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->releaseRedisLocks($drawId, $locks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{number_4d:string, amount:int}> $locks
|
||||||
|
*/
|
||||||
|
public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void
|
||||||
|
{
|
||||||
|
if ($this->shouldUseRedisAtomicLocks()) {
|
||||||
|
$this->releaseRedisLocks($drawId, $locks);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($locks as $lock) {
|
||||||
|
$this->releaseDatabaseLocks($drawId, $ticketItem, [$lock], 'ticket_rollback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acquireRedisLockForCombination(int $drawId, string $number4d, int $amount): void
|
||||||
|
{
|
||||||
|
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||||
$pool = $this->firstOrMakePool($drawId, $number4d);
|
$pool = $this->firstOrMakePool($drawId, $number4d);
|
||||||
$key = $this->redisPoolKey($drawId, $number4d);
|
$key = $this->redisPoolKey($drawId, $number4d);
|
||||||
|
|
||||||
@@ -169,37 +217,40 @@ final class RiskPoolService
|
|||||||
(int) $pool->version,
|
(int) $pool->version,
|
||||||
$this->redisPoolTtlSeconds(),
|
$this->redisPoolTtlSeconds(),
|
||||||
));
|
));
|
||||||
if (($result['code'] ?? null) !== 'OK') {
|
|
||||||
|
if (($result['code'] ?? null) === 'OK') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($result['code'] ?? null) === 'INSUFFICIENT_CAP') {
|
||||||
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
$acquired[] = ['number_4d' => $number4d, 'amount' => $amount];
|
if ($attempt === 0 && in_array($result['code'] ?? '', ['VERSION_CONFLICT', 'POOL_NOT_INITIALIZED'], true)) {
|
||||||
$total += $amount;
|
$freshPool = RiskPool::query()
|
||||||
|
->where('draw_id', $drawId)
|
||||||
|
->where('normalized_number', $number4d)
|
||||||
|
->firstOrFail();
|
||||||
|
$this->syncRedisStateFromPool($freshPool);
|
||||||
|
|
||||||
$this->syncDatabaseAfterRedisAcquire($drawId, $ticketItem, $number4d, $amount);
|
continue;
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->releaseRedisLocks($drawId, $acquired);
|
|
||||||
$this->releaseDatabaseLocks($drawId, $ticketItem, $acquired, 'ticket_failed_line');
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $total;
|
$this->throwForRedisAcquireFailure($result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array{number_4d:string, amount:int}> $locks
|
* @param array{code:string, remaining:int, locked:int, version:int} $result
|
||||||
*/
|
*/
|
||||||
public function release(int $drawId, ?TicketItem $ticketItem, array $locks): void
|
private function throwForRedisAcquireFailure(array $result): void
|
||||||
{
|
{
|
||||||
if ($this->shouldUseRedisAtomicLocks()) {
|
throw new TicketOperationException(
|
||||||
$this->releaseRedisLocks($drawId, $locks);
|
'risk_pool_unavailable',
|
||||||
}
|
ErrorCode::InternalError->value,
|
||||||
|
503,
|
||||||
foreach ($locks as $lock) {
|
['redis_code' => $result['code'] ?? 'unknown'],
|
||||||
$this->releaseDatabaseLocks($drawId, $ticketItem, [$lock], 'ticket_rollback');
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void
|
public function publishManualSoldOut(Draw $draw, string $normalizedNumber): void
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ final class TicketPlacementService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$riskRedisCompensation = [];
|
||||||
|
|
||||||
$placement = DB::transaction(function () use (
|
$placement = DB::transaction(function () use (
|
||||||
$player,
|
$player,
|
||||||
$currencyCode,
|
$currencyCode,
|
||||||
@@ -79,7 +81,17 @@ final class TicketPlacementService
|
|||||||
$expectedVersions,
|
$expectedVersions,
|
||||||
$clientTraceId,
|
$clientTraceId,
|
||||||
$drawNo,
|
$drawNo,
|
||||||
|
&$riskRedisCompensation,
|
||||||
): array {
|
): array {
|
||||||
|
DB::afterRollback(function () use (&$riskRedisCompensation): void {
|
||||||
|
foreach ($riskRedisCompensation as $entry) {
|
||||||
|
$this->riskPoolService->compensateRedisAcquires(
|
||||||
|
(int) $entry['draw_id'],
|
||||||
|
$entry['locks'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$draw = Draw::query()
|
$draw = Draw::query()
|
||||||
->where('draw_no', $drawNo)
|
->where('draw_no', $drawNo)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -162,7 +174,9 @@ final class TicketPlacementService
|
|||||||
|
|
||||||
$creditLine = PlayerFundingMode::usesCredit($player);
|
$creditLine = PlayerFundingMode::usesCredit($player);
|
||||||
|
|
||||||
if (! $creditLine) {
|
if ($creditLine) {
|
||||||
|
$this->playerCreditService->assertCreditPreflight($player, $totalActualDeduct);
|
||||||
|
} else {
|
||||||
$wallet = PlayerWallet::query()
|
$wallet = PlayerWallet::query()
|
||||||
->where('player_id', $player->id)
|
->where('player_id', $player->id)
|
||||||
->where('wallet_type', 'lottery')
|
->where('wallet_type', 'lottery')
|
||||||
@@ -295,6 +309,10 @@ final class TicketPlacementService
|
|||||||
$successTotalRebate += $rebateAmount;
|
$successTotalRebate += $rebateAmount;
|
||||||
$successTotalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
$successTotalActualDeduct += (int) $evaluated['actual_deduct_amount'];
|
||||||
$successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
|
$successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout'];
|
||||||
|
$riskRedisCompensation[] = [
|
||||||
|
'draw_id' => (int) $draw->id,
|
||||||
|
'locks' => $locks,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($successfulItems === []) {
|
if ($successfulItems === []) {
|
||||||
@@ -370,7 +388,7 @@ final class TicketPlacementService
|
|||||||
])->save();
|
])->save();
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
DB::transaction(function () use ($order, $player): void {
|
DB::transaction(function () use ($order, $player, $placement): void {
|
||||||
$items = TicketItem::query()
|
$items = TicketItem::query()
|
||||||
->where('order_id', $order->id)
|
->where('order_id', $order->id)
|
||||||
->where('status', 'pending_confirm')
|
->where('status', 'pending_confirm')
|
||||||
@@ -401,6 +419,11 @@ final class TicketPlacementService
|
|||||||
if (! PlayerFundingMode::usesCredit($player)) {
|
if (! PlayerFundingMode::usesCredit($player)) {
|
||||||
$this->ticketWalletService->reverseBetDeduct($order);
|
$this->ticketWalletService->reverseBetDeduct($order);
|
||||||
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
|
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
|
||||||
|
} else {
|
||||||
|
$this->playerCreditService->reverseBetHold(
|
||||||
|
$player,
|
||||||
|
(int) $placement['success_total_actual_deduct'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\WalletTxn;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use App\Support\AdminDataScope;
|
use App\Support\AdminDataScope;
|
||||||
use App\Support\CurrencyFormatter;
|
use App\Support\CurrencyFormatter;
|
||||||
|
use App\Support\LimitedQuery;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
|
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
|
||||||
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
use App\Services\AgentSettlement\SettlementPartyEnrichment;
|
||||||
@@ -100,14 +101,15 @@ final class PlayerLedgerLogsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$currency = (string) $player->default_currency;
|
$currency = (string) $player->default_currency;
|
||||||
$rawRows = $this->creditLedgerQuery($player->id, [
|
$limited = LimitedQuery::get($this->creditLedgerQuery($player->id, [
|
||||||
'bet_hold',
|
'bet_hold',
|
||||||
'bet_hold_release',
|
'bet_hold_release',
|
||||||
'game_settlement_loss',
|
'game_settlement_loss',
|
||||||
'game_settlement_win',
|
'game_settlement_win',
|
||||||
'settlement_confirm',
|
'settlement_confirm',
|
||||||
'settlement_payout',
|
'settlement_payout',
|
||||||
])->limit(5000)->get()->all();
|
]), 5000);
|
||||||
|
$rawRows = $limited['rows']->all();
|
||||||
|
|
||||||
$enriched = array_map(function (object $row) use ($player): object {
|
$enriched = array_map(function (object $row) use ($player): object {
|
||||||
return (object) [
|
return (object) [
|
||||||
@@ -196,6 +198,7 @@ final class PlayerLedgerLogsService
|
|||||||
'total' => $total,
|
'total' => $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $perPage,
|
'per_page' => $perPage,
|
||||||
|
'truncated' => $limited['truncated'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use App\Models\WalletTxn;
|
|||||||
use App\Models\TransferOrder;
|
use App\Models\TransferOrder;
|
||||||
use App\Models\ReconcileJob;
|
use App\Models\ReconcileJob;
|
||||||
use App\Models\ReconcileItem;
|
use App\Models\ReconcileItem;
|
||||||
|
use App\Support\AdminScopeContext;
|
||||||
|
use App\Support\AdminScopePolicy;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -56,6 +58,7 @@ final class WalletTransferReconcileDetector
|
|||||||
int $staleMinutes = 15,
|
int $staleMinutes = 15,
|
||||||
?int $playerId = null,
|
?int $playerId = null,
|
||||||
bool $includeMainSiteCheck = true,
|
bool $includeMainSiteCheck = true,
|
||||||
|
?AdminScopeContext $scope = null,
|
||||||
): array {
|
): array {
|
||||||
$limit = max(1, $limit);
|
$limit = max(1, $limit);
|
||||||
$staleMinutes = max(1, $staleMinutes);
|
$staleMinutes = max(1, $staleMinutes);
|
||||||
@@ -77,6 +80,10 @@ final class WalletTransferReconcileDetector
|
|||||||
$query->where('player_id', $playerId);
|
$query->where('player_id', $playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($scope !== null && ! $scope->isSuperAdmin()) {
|
||||||
|
AdminScopePolicy::applyViaPlayer($query, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
$orders = $query->get();
|
$orders = $query->get();
|
||||||
|
|
||||||
if ($orders->isEmpty()) {
|
if ($orders->isEmpty()) {
|
||||||
@@ -118,13 +125,16 @@ final class WalletTransferReconcileDetector
|
|||||||
Carbon $periodStart,
|
Carbon $periodStart,
|
||||||
Carbon $periodEnd,
|
Carbon $periodEnd,
|
||||||
?int $adminUserId,
|
?int $adminUserId,
|
||||||
|
?int $adminSiteId = null,
|
||||||
|
?int $playerId = null,
|
||||||
): ReconcileJob {
|
): ReconcileJob {
|
||||||
return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId): ReconcileJob {
|
return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId, $adminSiteId, $playerId): ReconcileJob {
|
||||||
$jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString()));
|
$jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString()));
|
||||||
|
|
||||||
$job = ReconcileJob::query()->create([
|
$job = ReconcileJob::query()->create([
|
||||||
'job_no' => $jobNo,
|
'job_no' => $jobNo,
|
||||||
'admin_user_id' => $adminUserId,
|
'admin_user_id' => $adminUserId,
|
||||||
|
'admin_site_id' => $adminSiteId,
|
||||||
'reconcile_type' => self::RECONCILE_TYPE,
|
'reconcile_type' => self::RECONCILE_TYPE,
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'period_start' => $periodStart,
|
'period_start' => $periodStart,
|
||||||
@@ -132,6 +142,7 @@ final class WalletTransferReconcileDetector
|
|||||||
'summary_json' => [
|
'summary_json' => [
|
||||||
'item_count' => count($items),
|
'item_count' => count($items),
|
||||||
'mismatch_count' => count($items),
|
'mismatch_count' => count($items),
|
||||||
|
'player_id' => $playerId,
|
||||||
],
|
],
|
||||||
'finished_at' => now(),
|
'finished_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ final class AdminAuthProfile
|
|||||||
* },
|
* },
|
||||||
* site: ?array{id: int, code: string, name: string},
|
* site: ?array{id: int, code: string, name: string},
|
||||||
* is_super_admin: bool,
|
* is_super_admin: bool,
|
||||||
|
* account_kind: 'super_admin'|'site_admin'|'site_finance'|'site_cs'|'agent_operator'|'platform_account',
|
||||||
* operational_permissions: list<string>,
|
* operational_permissions: list<string>,
|
||||||
* delegation_ceiling: list<string>,
|
* delegation_ceiling: list<string>,
|
||||||
* accessible_sites?: list<array{id: int, code: string, name: string}>
|
* accessible_sites?: list<array{id: int, code: string, name: string}>
|
||||||
@@ -49,6 +50,7 @@ final class AdminAuthProfile
|
|||||||
{
|
{
|
||||||
$fresh = $admin->fresh();
|
$fresh = $admin->fresh();
|
||||||
$permissionSlugs = $fresh->adminPermissionSlugs();
|
$permissionSlugs = $fresh->adminPermissionSlugs();
|
||||||
|
$operationalCodes = $fresh->effectiveMenuActionPermissionCodes();
|
||||||
$agent = self::agentContext($fresh);
|
$agent = self::agentContext($fresh);
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -61,7 +63,8 @@ final class AdminAuthProfile
|
|||||||
'agent' => $agent,
|
'agent' => $agent,
|
||||||
'site' => self::siteContext($fresh),
|
'site' => self::siteContext($fresh),
|
||||||
'is_super_admin' => $fresh->isSuperAdmin(),
|
'is_super_admin' => $fresh->isSuperAdmin(),
|
||||||
'operational_permissions' => $permissionSlugs,
|
'account_kind' => self::accountKind($fresh),
|
||||||
|
'operational_permissions' => $operationalCodes,
|
||||||
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
|
'delegation_ceiling' => AgentDelegationAuthorization::delegationLegacySlugsForAdminUser($fresh),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ final class AdminAuthProfile
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! SitePlatformRole::userHasSiteAdminRole($admin)) {
|
if (! SiteOperatorRoles::userHasSiteOperatorRole($admin)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,4 +133,22 @@ final class AdminAuthProfile
|
|||||||
'can_create_player' => (bool) ($profile?->can_create_player ?? false),
|
'can_create_player' => (bool) ($profile?->can_create_player ?? false),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function accountKind(AdminUser $admin): string
|
||||||
|
{
|
||||||
|
if ($admin->isSuperAdmin()) {
|
||||||
|
return 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($admin->hasPrimaryAgentBinding()) {
|
||||||
|
return 'agent_operator';
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteRole = SiteOperatorRoles::primarySiteOperatorSlug($admin);
|
||||||
|
if ($siteRole !== null) {
|
||||||
|
return $siteRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'platform_account';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,7 +565,6 @@ final class AdminAuthorizationRegistry
|
|||||||
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
['code' => 'admin.reports.daily-profit', 'module_code' => 'report', 'name' => '每日盈亏汇总', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/daily-profit', 'route_name' => 'api.v1.admin.reports.daily-profit', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||||
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
['code' => 'admin.reports.player-win-loss', 'module_code' => 'report', 'name' => '玩家输赢报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/player-win-loss', 'route_name' => 'api.v1.admin.reports.player-win-loss', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||||
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
['code' => 'admin.reports.play-dimension', 'module_code' => 'report', 'name' => '玩法维度报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/play-dimension', 'route_name' => 'api.v1.admin.reports.play-dimension', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||||
['code' => 'admin.reports.rebate-commission', 'module_code' => 'report', 'name' => '佣金回水报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reports/rebate-commission', 'route_name' => 'api.v1.admin.reports.rebate-commission', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
|
||||||
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
['code' => 'admin.report-jobs.index', 'module_code' => 'report', 'name' => '报表任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view']],
|
||||||
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']],
|
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']],
|
||||||
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']],
|
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']],
|
||||||
|
|||||||
206
app/Support/AdminReconcileScope.php
Normal file
206
app/Support/AdminReconcileScope.php
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
|
use App\Models\AdminSite;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\AgentNode;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\ReconcileJob;
|
||||||
|
use App\Support\ApiMessage;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/** 对账任务:按接入站点(及绑定代理子树)收敛可见范围。 */
|
||||||
|
final class AdminReconcileScope
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Builder<ReconcileJob> $query
|
||||||
|
*/
|
||||||
|
public static function applyToJobsQuery(Builder $query, AdminUser $admin): void
|
||||||
|
{
|
||||||
|
if ($admin->isSuperAdmin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
|
if ($siteIds === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($siteIds === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteCodes = AdminSite::query()
|
||||||
|
->whereIn('id', $siteIds)
|
||||||
|
->pluck('code')
|
||||||
|
->map(static fn ($code): string => (string) $code)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
|
||||||
|
$query->where(function (Builder $outer) use ($siteIds, $siteCodes, $agent): void {
|
||||||
|
$outer->whereIn('admin_site_id', $siteIds);
|
||||||
|
|
||||||
|
if ($siteCodes !== []) {
|
||||||
|
$outer->orWhere(function (Builder $legacy) use ($siteCodes, $agent): void {
|
||||||
|
$legacy->whereNull('admin_site_id')
|
||||||
|
->whereHas('items', function (Builder $items) use ($siteCodes, $agent): void {
|
||||||
|
self::applyItemsTransferPlayerScope($items, $siteCodes, $agent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($agent instanceof AgentNode) {
|
||||||
|
$query->whereHas('items', function (Builder $items) use ($agent): void {
|
||||||
|
self::applyItemsAgentSubtree($items, $agent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function jobAccessible(AdminUser $admin, ReconcileJob $job): bool
|
||||||
|
{
|
||||||
|
if ($admin->isSuperAdmin()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
|
if ($siteIds === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($siteIds === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobSiteId = $job->admin_site_id !== null ? (int) $job->admin_site_id : null;
|
||||||
|
if ($jobSiteId !== null) {
|
||||||
|
if (! in_array($jobSiteId, $siteIds, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$siteCodes = AdminSite::query()
|
||||||
|
->whereIn('id', $siteIds)
|
||||||
|
->pluck('code')
|
||||||
|
->map(static fn ($code): string => (string) $code)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$hasScopedItem = $job->items()
|
||||||
|
->where(function (Builder $items) use ($siteCodes, $admin): void {
|
||||||
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
self::applyItemsTransferPlayerScope($items, $siteCodes, $agent);
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasScopedItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
if ($agent === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $job->items()
|
||||||
|
->where(function (Builder $items) use ($agent): void {
|
||||||
|
self::applyItemsAgentSubtree($items, $agent);
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveAdminSiteIdForJob(AdminUser $admin, ?Player $player = null): ?int
|
||||||
|
{
|
||||||
|
if ($player !== null) {
|
||||||
|
$siteId = AdminSite::query()
|
||||||
|
->where('code', (string) $player->site_code)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
return $siteId !== null ? (int) $siteId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($admin->isSuperAdmin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = AdminAgentScope::primaryAgentNode($admin);
|
||||||
|
if ($agent !== null) {
|
||||||
|
return (int) $agent->admin_site_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
|
|
||||||
|
return $siteIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function denyUnlessJobAccessible(AdminUser $admin, ReconcileJob $job): ?JsonResponse
|
||||||
|
{
|
||||||
|
if (! self::jobAccessible($admin, $job)) {
|
||||||
|
return ApiMessage::errorResponse(
|
||||||
|
request(),
|
||||||
|
'admin.reconcile_job_access_denied',
|
||||||
|
ErrorCode::AdminForbidden->value,
|
||||||
|
null,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<\App\Models\ReconcileItem> $query
|
||||||
|
* @param list<string> $siteCodes
|
||||||
|
*/
|
||||||
|
public static function applyItemsTransferPlayerScope(
|
||||||
|
Builder $query,
|
||||||
|
array $siteCodes,
|
||||||
|
?AgentNode $agent = null,
|
||||||
|
): void {
|
||||||
|
if ($siteCodes === []) {
|
||||||
|
$query->whereRaw('0 = 1');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereExists(function ($sub) use ($siteCodes, $agent): void {
|
||||||
|
$sub->selectRaw('1')
|
||||||
|
->from('transfer_orders')
|
||||||
|
->join('players', 'players.id', '=', 'transfer_orders.player_id')
|
||||||
|
->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref');
|
||||||
|
|
||||||
|
$sub->whereIn('players.site_code', $siteCodes);
|
||||||
|
|
||||||
|
if ($agent instanceof AgentNode) {
|
||||||
|
$sub->whereIn('players.agent_node_id', function ($nodes) use ($agent): void {
|
||||||
|
$nodes->select('id')
|
||||||
|
->from('agent_nodes')
|
||||||
|
->where('path', 'like', $agent->path.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<\App\Models\ReconcileItem> $query
|
||||||
|
*/
|
||||||
|
private static function applyItemsAgentSubtree(Builder $query, AgentNode $agent): void
|
||||||
|
{
|
||||||
|
$query->whereExists(function ($sub) use ($agent): void {
|
||||||
|
$sub->selectRaw('1')
|
||||||
|
->from('transfer_orders')
|
||||||
|
->join('players', 'players.id', '=', 'transfer_orders.player_id')
|
||||||
|
->whereColumn('transfer_orders.transfer_no', 'reconcile_items.side_a_ref')
|
||||||
|
->whereIn('players.agent_node_id', function ($nodes) use ($agent): void {
|
||||||
|
$nodes->select('id')
|
||||||
|
->from('agent_nodes')
|
||||||
|
->where('path', 'like', $agent->path.'%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Support/LimitedQuery.php
Normal file
26
app/Support/LimitedQuery.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||||
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class LimitedQuery
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param EloquentBuilder|QueryBuilder $query
|
||||||
|
* @return array{rows: Collection, truncated: bool}
|
||||||
|
*/
|
||||||
|
public static function get(EloquentBuilder|QueryBuilder $query, int $limit): array
|
||||||
|
{
|
||||||
|
$limit = max(1, $limit);
|
||||||
|
$rows = (clone $query)->limit($limit + 1)->get();
|
||||||
|
$truncated = $rows->count() > $limit;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rows' => $truncated ? $rows->take($limit)->values() : $rows,
|
||||||
|
'truncated' => $truncated,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,29 @@ namespace App\Support;
|
|||||||
|
|
||||||
use App\Models\AdminRole;
|
use App\Models\AdminRole;
|
||||||
|
|
||||||
/** 平台角色管理仅维护的两个内置系统角色。 */
|
/** 平台内置系统角色(超管 / 站点运营 / 代理)。 */
|
||||||
final class PlatformSystemRoles
|
final class PlatformSystemRoles
|
||||||
{
|
{
|
||||||
public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN;
|
public const SLUG_SUPER_ADMIN = AdminRole::ROLE_SUPER_ADMIN;
|
||||||
|
|
||||||
public const SLUG_AGENT = 'agent';
|
public const SLUG_AGENT = 'agent';
|
||||||
|
|
||||||
public const SLUG_SITE_ADMIN = SitePlatformRole::SLUG;
|
public const SLUG_SITE_ADMIN = SiteOperatorRoles::SLUG_SITE_ADMIN;
|
||||||
|
|
||||||
|
public const SLUG_SITE_FINANCE = SiteOperatorRoles::SLUG_SITE_FINANCE;
|
||||||
|
|
||||||
|
public const SLUG_SITE_CS = SiteOperatorRoles::SLUG_SITE_CS;
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function fixedSlugs(): array
|
public static function fixedSlugs(): array
|
||||||
{
|
{
|
||||||
return [self::SLUG_SUPER_ADMIN, self::SLUG_SITE_ADMIN, self::SLUG_AGENT];
|
return [
|
||||||
|
self::SLUG_SUPER_ADMIN,
|
||||||
|
self::SLUG_SITE_ADMIN,
|
||||||
|
self::SLUG_SITE_FINANCE,
|
||||||
|
self::SLUG_SITE_CS,
|
||||||
|
self::SLUG_AGENT,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function isFixedSlug(string $slug): bool
|
public static function isFixedSlug(string $slug): bool
|
||||||
@@ -52,6 +62,8 @@ final class PlatformSystemRoles
|
|||||||
{
|
{
|
||||||
self::ensureSuperAdminRole();
|
self::ensureSuperAdminRole();
|
||||||
SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole();
|
SiteAdminDefaultRolePermissions::ensurePlatformSiteAdminRole();
|
||||||
|
SiteFinanceDefaultRolePermissions::ensurePlatformSiteFinanceRole();
|
||||||
|
SiteCsDefaultRolePermissions::ensurePlatformSiteCsRole();
|
||||||
AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
AgentDefaultRolePermissions::ensurePlatformAgentRole();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ final class SiteAdminDefaultRolePermissions
|
|||||||
'prd.users.manage',
|
'prd.users.manage',
|
||||||
'prd.tickets.view',
|
'prd.tickets.view',
|
||||||
'prd.report.view',
|
'prd.report.view',
|
||||||
|
'prd.report.export',
|
||||||
|
'prd.draw_result.view',
|
||||||
|
'prd.wallet_reconcile.manage',
|
||||||
'prd.settlement.agent.view',
|
'prd.settlement.agent.view',
|
||||||
'prd.settlement.agent.manage',
|
'prd.settlement.agent.manage',
|
||||||
'prd.integration.view',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +48,7 @@ final class SiteAdminDefaultRolePermissions
|
|||||||
[
|
[
|
||||||
'code' => SitePlatformRole::SLUG,
|
'code' => SitePlatformRole::SLUG,
|
||||||
'name' => '站点管理员',
|
'name' => '站点管理员',
|
||||||
'description' => '接入站点后台默认权限(代理/玩家/结算运营 + 站点仪表盘)',
|
'description' => '接入站点后台默认权限(本站代理/玩家/信用结算 + 钱包流水与经营报表 + 期号只读)',
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
'is_system' => true,
|
'is_system' => true,
|
||||||
'sort_order' => 40,
|
'sort_order' => 40,
|
||||||
|
|||||||
49
app/Support/SiteCsDefaultRolePermissions.php
Normal file
49
app/Support/SiteCsDefaultRolePermissions.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\AdminRole;
|
||||||
|
|
||||||
|
/** 平台「站点客服」内置角色:单玩家查询,不含全局经营报表与代理管理。 */
|
||||||
|
final class SiteCsDefaultRolePermissions
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private const TEMPLATE_SLUGS = [
|
||||||
|
'prd.dashboard.view',
|
||||||
|
'prd.users.view_cs',
|
||||||
|
'prd.tickets.view',
|
||||||
|
'prd.wallet_reconcile.view_cs',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function templateSlugs(): array
|
||||||
|
{
|
||||||
|
return self::TEMPLATE_SLUGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensurePlatformSiteCsRole(): AdminRole
|
||||||
|
{
|
||||||
|
$role = AdminRole::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'slug' => SiteOperatorRoles::SLUG_SITE_CS,
|
||||||
|
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => SiteOperatorRoles::SLUG_SITE_CS,
|
||||||
|
'name' => '站点客服',
|
||||||
|
'description' => '单站客服:玩家/注单/单用户钱包查询工作台,不含全局报表与代理经营',
|
||||||
|
'status' => 1,
|
||||||
|
'is_system' => true,
|
||||||
|
'sort_order' => 42,
|
||||||
|
'owner_agent_id' => null,
|
||||||
|
'delegated_from_role_id' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$role->syncLegacyPermissionSlugs(
|
||||||
|
AdminPermissionInheritance::expand(self::TEMPLATE_SLUGS),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $role->fresh() ?? $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Support/SiteFinanceDefaultRolePermissions.php
Normal file
55
app/Support/SiteFinanceDefaultRolePermissions.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\AdminRole;
|
||||||
|
|
||||||
|
/** 平台「站点财务」内置角色:对账、报表导出、结算收付,不含代理树经营。 */
|
||||||
|
final class SiteFinanceDefaultRolePermissions
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private const TEMPLATE_SLUGS = [
|
||||||
|
'prd.dashboard.view',
|
||||||
|
'prd.draw_result.view',
|
||||||
|
'prd.users.view_finance',
|
||||||
|
'prd.tickets.view',
|
||||||
|
'prd.report.view',
|
||||||
|
'prd.report.export',
|
||||||
|
'prd.wallet_reconcile.manage',
|
||||||
|
'prd.wallet_adjust.manage',
|
||||||
|
'prd.settlement.agent.view',
|
||||||
|
'prd.settlement.agent.manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function templateSlugs(): array
|
||||||
|
{
|
||||||
|
return self::TEMPLATE_SLUGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ensurePlatformSiteFinanceRole(): AdminRole
|
||||||
|
{
|
||||||
|
$role = AdminRole::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'slug' => SiteOperatorRoles::SLUG_SITE_FINANCE,
|
||||||
|
'scope_type' => AdminRole::SCOPE_SYSTEM,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => SiteOperatorRoles::SLUG_SITE_FINANCE,
|
||||||
|
'name' => '站点财务',
|
||||||
|
'description' => '单站财务/对账:钱包流水、对账、经营报表导出、信用结算收付',
|
||||||
|
'status' => 1,
|
||||||
|
'is_system' => true,
|
||||||
|
'sort_order' => 41,
|
||||||
|
'owner_agent_id' => null,
|
||||||
|
'delegated_from_role_id' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$role->syncLegacyPermissionSlugs(
|
||||||
|
AdminPermissionInheritance::expand(self::TEMPLATE_SLUGS),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $role->fresh() ?? $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Support/SiteOperatorRoles.php
Normal file
62
app/Support/SiteOperatorRoles.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/** 接入站点平台账号内置角色(绑定 admin_user_site_roles,无代理节点)。 */
|
||||||
|
final class SiteOperatorRoles
|
||||||
|
{
|
||||||
|
public const SLUG_SITE_ADMIN = 'site_admin';
|
||||||
|
|
||||||
|
public const SLUG_SITE_FINANCE = 'site_finance';
|
||||||
|
|
||||||
|
public const SLUG_SITE_CS = 'site_cs';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function slugs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SLUG_SITE_ADMIN,
|
||||||
|
self::SLUG_SITE_FINANCE,
|
||||||
|
self::SLUG_SITE_CS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isSiteOperatorSlug(string $slug): bool
|
||||||
|
{
|
||||||
|
return in_array($slug, self::slugs(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function userHasSiteOperatorRole(AdminUser $user): bool
|
||||||
|
{
|
||||||
|
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::primarySiteOperatorSlug($user) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function userHasSiteAdminRole(AdminUser $user): bool
|
||||||
|
{
|
||||||
|
return self::primarySiteOperatorSlug($user) === self::SLUG_SITE_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function primarySiteOperatorSlug(AdminUser $user): ?string
|
||||||
|
{
|
||||||
|
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = DB::table('admin_user_site_roles as usr')
|
||||||
|
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
|
||||||
|
->where('usr.admin_user_id', $user->id)
|
||||||
|
->whereIn('r.slug', self::slugs())
|
||||||
|
->orderBy('usr.site_id')
|
||||||
|
->orderBy('r.sort_order')
|
||||||
|
->value('r.slug');
|
||||||
|
|
||||||
|
return is_string($slug) && $slug !== '' ? $slug : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ use App\Models\AdminUser;
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
/** 接入站点后台账号统一使用平台系统角色 slug=site_admin。 */
|
/** 接入站点后台主运营角色 slug=site_admin。 */
|
||||||
final class SitePlatformRole
|
final class SitePlatformRole
|
||||||
{
|
{
|
||||||
public const SLUG = 'site_admin';
|
public const SLUG = SiteOperatorRoles::SLUG_SITE_ADMIN;
|
||||||
|
|
||||||
public static function resolve(): AdminRole
|
public static function resolve(): AdminRole
|
||||||
{
|
{
|
||||||
@@ -41,14 +41,6 @@ final class SitePlatformRole
|
|||||||
|
|
||||||
public static function userHasSiteAdminRole(AdminUser $user): bool
|
public static function userHasSiteAdminRole(AdminUser $user): bool
|
||||||
{
|
{
|
||||||
if ($user->isSuperAdmin() || $user->hasPrimaryAgentBinding()) {
|
return SiteOperatorRoles::userHasSiteAdminRole($user);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::table('admin_user_site_roles as usr')
|
|
||||||
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
|
|
||||||
->where('usr.admin_user_id', $user->id)
|
|
||||||
->where('r.slug', self::SLUG)
|
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reconcile_jobs', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('admin_site_id')
|
||||||
|
->nullable()
|
||||||
|
->after('admin_user_id')
|
||||||
|
->constrained('admin_sites')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::statement(<<<'SQL'
|
||||||
|
UPDATE reconcile_jobs AS rj
|
||||||
|
SET admin_site_id = sub.admin_site_id
|
||||||
|
FROM (
|
||||||
|
SELECT ri.reconcile_job_id, MIN(asite.id) AS admin_site_id
|
||||||
|
FROM reconcile_items AS ri
|
||||||
|
INNER JOIN transfer_orders AS tord ON tord.transfer_no = ri.side_a_ref
|
||||||
|
INNER JOIN players AS p ON p.id = tord.player_id
|
||||||
|
INNER JOIN admin_sites AS asite ON asite.code = p.site_code
|
||||||
|
GROUP BY ri.reconcile_job_id
|
||||||
|
) AS sub
|
||||||
|
WHERE rj.id = sub.reconcile_job_id
|
||||||
|
AND rj.admin_site_id IS NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reconcile_jobs', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('admin_site_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||||
|
$table->index(
|
||||||
|
['owner_type', 'owner_id', 'status'],
|
||||||
|
'settlement_bills_owner_status_idx',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('settlement_bills', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex('settlement_bills_owner_status_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\SiteCsDefaultRolePermissions;
|
||||||
|
use App\Support\SiteFinanceDefaultRolePermissions;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
SiteFinanceDefaultRolePermissions::ensurePlatformSiteFinanceRole();
|
||||||
|
SiteCsDefaultRolePermissions::ensurePlatformSiteCsRole();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 不回滚内置角色,避免已绑定的站点财务/客服账号失权。
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -38,7 +38,12 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
|
|||||||
|
|
||||||
- 后台查询范围统一为:`site_scope ∩ agent_subtree_scope`。
|
- 后台查询范围统一为:`site_scope ∩ agent_subtree_scope`。
|
||||||
- 新增统一入口 `App\Support\AdminScopePolicy`,查询应优先通过该策略应用数据范围。
|
- 新增统一入口 `App\Support\AdminScopePolicy`,查询应优先通过该策略应用数据范围。
|
||||||
- `auth/me` 继续返回 `permissions`(`prd.*`)兼容前端,同时新增 `operational_permissions` 字段用于显式表达可操作权限集合。
|
- `auth/me` 字段分工:
|
||||||
|
- `permissions`:legacy `prd.*` 产品权限(侧栏、角色 UI、旧页面门控)
|
||||||
|
- `operational_permissions`:`admin_menu_actions.permission_code`(与 API 中间件鉴权一致)
|
||||||
|
- `account_kind`:`super_admin` | `site_admin` | `site_finance` | `site_cs` | `agent_operator` | `platform_account`
|
||||||
|
|
||||||
|
前端新代码优先使用 `useAdminPermission().hasAnyCode()` 与 `operational_permissions`;`permissions` / `prd.*` 逐步废弃。
|
||||||
|
|
||||||
## 已废弃的 `prd.*`(请求体仍可传入,会自动归一)
|
## 已废弃的 `prd.*`(请求体仍可传入,会自动归一)
|
||||||
|
|
||||||
@@ -70,8 +75,13 @@ php artisan lottery:admin-auth-audit # 仅体检:受保护路由是
|
|||||||
|
|
||||||
### 路径 B:平台运营账号(单站)
|
### 路径 B:平台运营账号(单站)
|
||||||
|
|
||||||
1. 平台 **角色管理** 有三个内置角色:**超级管理员**(平台唯一账号,`admin_users.is_super_admin`,不绑定站点,自动拥有全部 `prd.*`)、**站点管理员**(`slug=site_admin`,接入站点自动创建的后台账号默认角色,含站点仪表盘 + 代理/玩家/结算运营)、**代理**(经营主账号默认模板)。若需更细的平台运营分工,可在「平台账号」上绑定 **站点管理员** 或 **代理** 后按需收窄权限;勿授予 `prd.agent-line.provision`、全站接入密钥类权限。
|
1. 平台 **角色管理** 内置角色:
|
||||||
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**(`admin_site_id`),勾选上一步角色。
|
- **超级管理员**(`is_super_admin`,全库唯一,不绑站点)
|
||||||
|
- **站点管理员**(`slug=site_admin`):接入站点默认主运营;代理/玩家/信用结算/钱包对账/经营报表(含导出)/期号只读
|
||||||
|
- **站点财务**(`slug=site_finance`):财务工作台(对账异常、待确认/待收付账单);对账、报表导出、补单冲正、结算收付;不含代理树经营
|
||||||
|
- **站点客服**(`slug=site_cs`):客服工作台(玩家/今日注单);玩家/注单/单用户钱包查询;不含全局报表与代理
|
||||||
|
- **代理**(`slug=agent`):经营主账号默认模板
|
||||||
|
2. **系统 → 平台账号 → 新建**:填写账号信息,**选择目标站点**(`admin_site_id`),勾选角色(默认接入站自动绑 `site_admin`)。
|
||||||
3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。
|
3. 对方登录后仅见绑定站点数据;`auth/me.accessible_sites` 列出可访问站点(单站时一项)。
|
||||||
|
|
||||||
改角色绑定时须带上同一 `admin_site_id`(`PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot,不影响其他站点绑定。
|
改角色绑定时须带上同一 `admin_site_id`(`PUT /api/v1/admin/admin-users/{id}/roles`),仅替换该站点上的角色 pivot,不影响其他站点绑定。
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
bootstrap="vendor/autoload.php"
|
bootstrap="vendor/autoload.php"
|
||||||
colors="true"
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
>
|
>
|
||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Unit">
|
<testsuite name="Unit">
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
|
<ini name="memory_limit" value="512M"/>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>彩票代理接入文档</title>
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-100 antialiased">
|
|
||||||
@php
|
|
||||||
$sections = [
|
|
||||||
[
|
|
||||||
'id' => 'overview',
|
|
||||||
'title' => '1. 文档概览',
|
|
||||||
'summary' => '说明接入目标、适用对象与总体范围。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'architecture',
|
|
||||||
'title' => '2. 接入架构',
|
|
||||||
'summary' => '描述主站、彩票端与钱包系统之间的数据流向。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'prerequisites',
|
|
||||||
'title' => '3. 对接前准备',
|
|
||||||
'summary' => '列出域名、密钥、接口地址和测试账号等准备项。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'sso',
|
|
||||||
'title' => '4. SSO 单点登录',
|
|
||||||
'summary' => '说明客户如何生成 token 并跳转进入彩票端。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'wallet',
|
|
||||||
'title' => '5. 钱包接口',
|
|
||||||
'summary' => '说明余额、扣款、加款三个接口的职责与约束。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'signature',
|
|
||||||
'title' => '6. 签名与安全',
|
|
||||||
'summary' => '说明签名算法、密钥保护与重放防护。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'errors',
|
|
||||||
'title' => '7. 错误码与幂等',
|
|
||||||
'summary' => '统一交易状态和重复请求处理方式。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'testing',
|
|
||||||
'title' => '8. 联调与验收',
|
|
||||||
'summary' => '提供联调流程、测试清单和上线前核对项。',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'appendix',
|
|
||||||
'title' => '9. 附录',
|
|
||||||
'summary' => '给出标准报文示例和字段规范。',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="mx-auto flex max-w-7xl gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
|
||||||
<aside class="sticky top-6 hidden h-[calc(100vh-3rem)] w-72 shrink-0 overflow-y-auto rounded-3xl border border-white/10 bg-slate-900/70 p-5 shadow-2xl backdrop-blur lg:block">
|
|
||||||
<div class="mb-6">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-cyan-300">Admin Docs</p>
|
|
||||||
<h1 class="mt-3 text-2xl font-semibold text-white">彩票代理接入文档</h1>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-slate-400">面向客户技术团队的接入说明页,覆盖 SSO、钱包、联调和上线约束。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="space-y-2 text-sm">
|
|
||||||
@foreach ($sections as $section)
|
|
||||||
<a
|
|
||||||
href="#{{ $section['id'] }}"
|
|
||||||
class="block rounded-2xl border border-transparent px-3 py-3 transition hover:border-cyan-400/30 hover:bg-cyan-400/10"
|
|
||||||
>
|
|
||||||
<div class="font-medium text-slate-100">{{ $section['title'] }}</div>
|
|
||||||
<div class="mt-1 text-xs leading-5 text-slate-400">{{ $section['summary'] }}</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="min-w-0 flex-1">
|
|
||||||
<section class="overflow-hidden rounded-[2rem] border border-white/10 bg-linear-to-br from-slate-900 via-slate-900 to-cyan-950/70 shadow-2xl">
|
|
||||||
<div class="border-b border-white/10 px-6 py-8 sm:px-10 lg:px-12">
|
|
||||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div class="max-w-3xl">
|
|
||||||
<p class="text-sm font-medium text-cyan-300">LOTTERY INTEGRATION GUIDE</p>
|
|
||||||
<h1 class="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">彩票代理接入技术文档</h1>
|
|
||||||
<p class="mt-4 text-base leading-7 text-slate-300 sm:text-lg">
|
|
||||||
本页面用于给客户技术团队直接阅读和联调,按文档页方式展示接入说明,包含目录、数据流、接口约束、联调步骤和上线前检查项。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3 rounded-3xl border border-cyan-400/20 bg-slate-950/40 p-4 text-sm text-slate-300 sm:grid-cols-3 lg:min-w-[360px]">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">接入方式</div>
|
|
||||||
<div class="mt-2 font-medium text-white">SSO + 钱包接口</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">适用对象</div>
|
|
||||||
<div class="mt-2 font-medium text-white">客户技术团队</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">文档形态</div>
|
|
||||||
<div class="mt-2 font-medium text-white">后台独立页面</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-8 sm:px-10 lg:px-12 lg:hidden">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
|
||||||
<div class="text-sm font-semibold text-white">目录</div>
|
|
||||||
<div class="mt-4 grid gap-3">
|
|
||||||
@foreach ($sections as $section)
|
|
||||||
<a href="#{{ $section['id'] }}" class="rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200 transition hover:border-cyan-400/40 hover:bg-cyan-400/10">
|
|
||||||
{{ $section['title'] }}
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-12 px-6 py-8 sm:px-10 lg:px-12 lg:py-12">
|
|
||||||
<section id="overview" class="scroll-mt-8 space-y-6 rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">1. 文档概览</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">
|
|
||||||
本文档用于指导客户将自有主站接入我方彩票端,形成完整的登录、跳转、余额、投注扣款与派奖加款链路。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div class="text-sm font-medium text-white">登录接入</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">客户主站登录后,通过 SSO 进入彩票端,无需再次认证。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div class="text-sm font-medium text-white">钱包接入</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">投注与派奖资金动作由我方调用客户钱包接口完成。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div class="text-sm font-medium text-white">联调验收</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">客户、我方技术与测试按照同一套流程完成联调和上线验收。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4">
|
|
||||||
<div class="text-sm font-medium text-white">适用范围</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">适用于 H5、Web、App 内嵌 WebView 等进入彩票端的接入方式。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="architecture" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">2. 接入架构</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">客户系统与彩票端的职责边界如下,登录由主站发起,资金由主站钱包记账,彩票端负责业务过程编排。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-4">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
|
||||||
<div class="text-sm font-semibold text-cyan-300">01 客户主站</div>
|
|
||||||
<div class="mt-3 text-base font-medium text-white">用户登录与身份来源</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">客户负责维护会员账号、登录态和唯一用户标识。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
|
||||||
<div class="text-sm font-semibold text-cyan-300">02 SSO 网关</div>
|
|
||||||
<div class="mt-3 text-base font-medium text-white">生成短期 token</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">客户服务端生成签名凭证,浏览器携带后进入彩票端。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
|
||||||
<div class="text-sm font-semibold text-cyan-300">03 彩票端</div>
|
|
||||||
<div class="mt-3 text-base font-medium text-white">验签并建立会话</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">我方校验 token 后创建会话,承接投注、撤单、派奖等业务流程。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-5">
|
|
||||||
<div class="text-sm font-semibold text-cyan-300">04 客户钱包</div>
|
|
||||||
<div class="mt-3 text-base font-medium text-white">余额与账务真理源</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">余额查询、扣款、加款由客户钱包接口统一响应并负责幂等记账。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/60 p-6">
|
|
||||||
<div class="text-sm font-semibold text-white">端到端链路</div>
|
|
||||||
<ol class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>1. 用户在客户主站完成登录。</li>
|
|
||||||
<li>2. 客户服务端生成 SSO token。</li>
|
|
||||||
<li>3. 浏览器跳转到彩票端入口地址。</li>
|
|
||||||
<li>4. 彩票端校验 token 并建立用户会话。</li>
|
|
||||||
<li>5. 用户查询余额、进行投注或等待派奖。</li>
|
|
||||||
<li>6. 彩票端按业务场景调用客户钱包接口。</li>
|
|
||||||
<li>7. 钱包返回处理结果,彩票端落业务状态并反馈前端。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="prerequisites" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">3. 对接前准备</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">双方开始联调前,需要先完成以下准备项,避免联调阶段反复返工。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">客户需提供</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>• 站点名称、站点编码、测试环境与生产环境标识。</li>
|
|
||||||
<li>• 技术联系人、测试联系人、上线当天紧急联系人。</li>
|
|
||||||
<li>• 客户主站域名、钱包接口域名、可嵌入来源域名。</li>
|
|
||||||
<li>• 测试账号、测试余额和可重复联调的测试场景说明。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">双方需共同确认</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>• SSO 密钥或 JWT 验签密钥。</li>
|
|
||||||
<li>• 钱包 API 鉴权方式、签名算法、请求头规范。</li>
|
|
||||||
<li>• 钱包三类接口地址:余额、扣款、加款。</li>
|
|
||||||
<li>• 请求超时、重试策略、幂等键和错误码表。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sso" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">4. SSO 单点登录</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">客户用户在主站登录后,通过服务端签发的短期 token 进入彩票端,我方校验通过后自动建立会话。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">推荐接入流程</div>
|
|
||||||
<ol class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>1. 客户主站用户完成登录。</li>
|
|
||||||
<li>2. 客户服务端按约定字段组装 SSO 负载。</li>
|
|
||||||
<li>3. 使用共享密钥签发 token,并设置短期过期时间。</li>
|
|
||||||
<li>4. 浏览器跳转我方彩票端地址,携带 token 参数。</li>
|
|
||||||
<li>5. 我方校验签名、时间戳、站点编码和用户标识后建立彩票端登录态。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-3xl border border-cyan-400/20 bg-cyan-400/10 p-6">
|
|
||||||
<div class="text-base font-medium text-white">SSO 关键约束</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-200">
|
|
||||||
<li>• token 必须是短时有效,建议 60 到 300 秒。</li>
|
|
||||||
<li>• `user_id` 在客户站点内必须稳定且唯一。</li>
|
|
||||||
<li>• `site_code` 必须与约定站点保持一致。</li>
|
|
||||||
<li>• 生产密钥与测试密钥必须隔离。</li>
|
|
||||||
<li>• 不可把签名密钥暴露在前端代码里。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/60 p-6">
|
|
||||||
<div class="text-base font-medium text-white">SSO 负载示例</div>
|
|
||||||
<div class="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-slate-950 p-4 text-sm text-slate-200">
|
|
||||||
<pre class="whitespace-pre-wrap break-words">{
|
|
||||||
"user_id": "100001",
|
|
||||||
"username": "demo_user",
|
|
||||||
"site_code": "demo",
|
|
||||||
"timestamp": 1718000000,
|
|
||||||
"nonce": "N8F2X9Q1",
|
|
||||||
"currency": "CNY",
|
|
||||||
"device": "h5"
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="wallet" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">5. 钱包接口</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">我方会调用客户钱包完成账务动作。客户至少需要实现余额查询、扣款、加款三类能力。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 xl:grid-cols-3">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">余额查询</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">用于进入彩票端、下注前或关键账务时机同步可用余额。</div>
|
|
||||||
<div class="mt-4 rounded-2xl border border-white/10 bg-slate-950 p-4 text-xs text-slate-300">POST /wallet/balance</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">扣款接口</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">用于用户下注成功后的资金扣减,必须以交易号作为唯一幂等键。</div>
|
|
||||||
<div class="mt-4 rounded-2xl border border-white/10 bg-slate-950 p-4 text-xs text-slate-300">POST /wallet/debit</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">加款接口</div>
|
|
||||||
<div class="mt-2 text-sm leading-6 text-slate-400">用于派奖、退款或撤单返还资金,重复请求不得重复记账。</div>
|
|
||||||
<div class="mt-4 rounded-2xl border border-white/10 bg-slate-950 p-4 text-xs text-slate-300">POST /wallet/credit</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/60 p-6">
|
|
||||||
<div class="text-base font-medium text-white">扣款报文示例</div>
|
|
||||||
<div class="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-slate-950 p-4 text-sm text-slate-200">
|
|
||||||
<pre class="whitespace-pre-wrap break-words">{
|
|
||||||
"site_code": "demo",
|
|
||||||
"user_id": "100001",
|
|
||||||
"transaction_id": "BET202606100001",
|
|
||||||
"order_id": "TICKET202606100001",
|
|
||||||
"amount": "20.00",
|
|
||||||
"timestamp": 1718000001,
|
|
||||||
"sign": "xxxxxx"
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/60 p-6">
|
|
||||||
<div class="text-base font-medium text-white">成功返回示例</div>
|
|
||||||
<div class="mt-4 overflow-x-auto rounded-2xl border border-white/10 bg-slate-950 p-4 text-sm text-slate-200">
|
|
||||||
<pre class="whitespace-pre-wrap break-words">{
|
|
||||||
"code": 0,
|
|
||||||
"message": "success",
|
|
||||||
"data": {
|
|
||||||
"transaction_id": "BET202606100001",
|
|
||||||
"balance": "980.00"
|
|
||||||
}
|
|
||||||
}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="signature" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">6. 签名与安全</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">签名规则和密钥保护是接入稳定性的基础。推荐统一采用服务端签名并在所有敏感请求中校验。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">推荐签名规则</div>
|
|
||||||
<ol class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>1. 请求字段按字段名升序排序。</li>
|
|
||||||
<li>2. 以 `key=value` 形式拼接原始串。</li>
|
|
||||||
<li>3. 原始串尾部追加共享密钥。</li>
|
|
||||||
<li>4. 使用 `HMAC-SHA256` 计算签名。</li>
|
|
||||||
<li>5. 结果输出为十六进制小写字符串。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">安全要求</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>• 所有请求必须使用 HTTPS。</li>
|
|
||||||
<li>• token 和钱包请求必须校验时间戳与签名。</li>
|
|
||||||
<li>• 建议增加 nonce 或 request_id 防止重放。</li>
|
|
||||||
<li>• 测试环境与生产环境密钥不得共用。</li>
|
|
||||||
<li>• 密钥轮换时必须预留并行切换窗口。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="errors" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">7. 错误码与幂等</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">为了保证交易可重试、可审计,客户钱包接口必须统一错误码并支持强幂等。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">建议错误码</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>• `0`:成功</li>
|
|
||||||
<li>• `1001`:参数错误</li>
|
|
||||||
<li>• `1002`:签名错误</li>
|
|
||||||
<li>• `1003`:用户不存在</li>
|
|
||||||
<li>• `1004`:余额不足</li>
|
|
||||||
<li>• `1006`:重复交易</li>
|
|
||||||
<li>• `1099`:系统异常</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-cyan-400/20 bg-cyan-400/10 p-6">
|
|
||||||
<div class="text-base font-medium text-white">幂等处理要求</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-200">
|
|
||||||
<li>• 扣款和加款必须使用 `transaction_id` 作为唯一交易键。</li>
|
|
||||||
<li>• 同一 `transaction_id` 的重复请求,不得重复扣款或重复加款。</li>
|
|
||||||
<li>• 已成功处理的交易,重复请求必须返回首次处理结果。</li>
|
|
||||||
<li>• 钱包超时或网络抖动时,我方可能发起重试,因此客户必须按幂等方式落账。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="testing" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">8. 联调与验收</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">建议双方按固定节奏联调,先通登录链路,再通钱包链路,最后做异常回归和上线核验。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">联调顺序</div>
|
|
||||||
<ol class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>1. 域名连通与证书校验。</li>
|
|
||||||
<li>2. SSO token 生成与跳转验证。</li>
|
|
||||||
<li>3. 余额查询接口联调。</li>
|
|
||||||
<li>4. 扣款和加款接口联调。</li>
|
|
||||||
<li>5. 超时、重复请求和余额不足场景回归。</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">上线前检查清单</div>
|
|
||||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
|
||||||
<li>• 正式域名、正式密钥和正式白名单均已配置。</li>
|
|
||||||
<li>• 测试环境和生产环境参数已分离。</li>
|
|
||||||
<li>• 核心错误码、日志和告警已对齐。</li>
|
|
||||||
<li>• 关键交易链路已通过验收回归。</li>
|
|
||||||
<li>• 上线当天应急联系人与回滚预案已确认。</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="appendix" class="scroll-mt-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-semibold text-white">9. 附录</h2>
|
|
||||||
<p class="mt-3 leading-7 text-slate-300">为了减少不同系统之间的解析差异,建议双方统一基础字段格式。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-3">
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">金额字段</div>
|
|
||||||
<div class="mt-3 text-sm leading-6 text-slate-400">统一使用字符串传输,例如 `1000.00`,避免浮点精度误差。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">时间字段</div>
|
|
||||||
<div class="mt-3 text-sm leading-6 text-slate-400">建议使用 Unix 时间戳秒级,双方也可统一为 ISO8601。</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-950/40 p-6">
|
|
||||||
<div class="text-base font-medium text-white">报文格式</div>
|
|
||||||
<div class="mt-3 text-sm leading-6 text-slate-400">字符编码统一 UTF-8,请求内容类型统一 `application/json`。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportDailyProfitController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayDimensionController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportPlayerWinLossController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\AdminReportRebateCommissionController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController;
|
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController;
|
||||||
@@ -18,8 +17,6 @@ Route::middleware('admin.api-resource')->group(function (): void {
|
|||||||
->name('api.v1.admin.reports.player-win-loss');
|
->name('api.v1.admin.reports.player-win-loss');
|
||||||
Route::get('reports/play-dimension', AdminReportPlayDimensionController::class)
|
Route::get('reports/play-dimension', AdminReportPlayDimensionController::class)
|
||||||
->name('api.v1.admin.reports.play-dimension');
|
->name('api.v1.admin.reports.play-dimension');
|
||||||
Route::get('reports/rebate-commission', AdminReportRebateCommissionController::class)
|
|
||||||
->name('api.v1.admin.reports.rebate-commission');
|
|
||||||
|
|
||||||
Route::get('report-jobs', ReportJobIndexController::class)
|
Route::get('report-jobs', ReportJobIndexController::class)
|
||||||
->name('api.v1.admin.report-jobs.index');
|
->name('api.v1.admin.report-jobs.index');
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ Route::get('/', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('admin/docs')->group(function (): void {
|
Route::prefix('admin/docs')->group(function (): void {
|
||||||
Route::view('integration-guide', 'admin.integration-guide')
|
Route::get('integration-guide', function () {
|
||||||
->name('admin.docs.integration-guide');
|
$adminDocs = rtrim(
|
||||||
|
(string) env('LOTTERY_ADMIN_DOCS_URL', 'https://lotteryadmin.tanumo.com'),
|
||||||
|
'/',
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->away($adminDocs.'/docs/integration');
|
||||||
|
})->name('admin.docs.integration-guide');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ test('admin ping requires authentication', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('admin auth me returns current admin profile', function () {
|
test('admin auth me returns current admin profile', function () {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
|
||||||
$admin = AdminUser::query()->create([
|
$admin = AdminUser::query()->create([
|
||||||
'username' => 'admin_me',
|
'username' => 'admin_me',
|
||||||
'name' => '管理员本人',
|
'name' => '管理员本人',
|
||||||
@@ -26,13 +28,21 @@ test('admin auth me returns current admin profile', function () {
|
|||||||
|
|
||||||
$token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken;
|
$token = $admin->createToken('admin-api', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
$resp = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
->getJson('/api/v1/admin/auth/me')
|
->getJson('/api/v1/admin/auth/me')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', ErrorCode::Success->value)
|
->assertJsonPath('code', ErrorCode::Success->value)
|
||||||
->assertJsonPath('data.admin.username', 'admin_me')
|
->assertJsonPath('data.admin.username', 'admin_me')
|
||||||
|
->assertJsonPath('data.admin.account_kind', 'super_admin')
|
||||||
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
|
->assertJsonPath('data.admin.navigation.0.segment', 'dashboard')
|
||||||
->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]);
|
->assertJsonStructure(['data' => ['admin' => ['permissions', 'operational_permissions']]]);
|
||||||
|
|
||||||
|
$permissions = $resp->json('data.admin.permissions');
|
||||||
|
$operational = $resp->json('data.admin.operational_permissions');
|
||||||
|
expect($permissions)->toBeArray()->not->toBeEmpty()
|
||||||
|
->and($operational)->toBeArray()->not->toBeEmpty()
|
||||||
|
->and($permissions[0])->toStartWith('prd.')
|
||||||
|
->and($operational[0])->not->toStartWith('prd.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin login returns bearer token when captcha passes validation', function () {
|
test('admin login returns bearer token when captcha passes validation', function () {
|
||||||
|
|||||||
211
tests/Feature/AdminReconcileJobScopeTest.php
Normal file
211
tests/Feature/AdminReconcileJobScopeTest.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Models\ReconcileJob;
|
||||||
|
use App\Models\TransferOrder;
|
||||||
|
use App\Support\SitePlatformRole;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function reconcileScopeSuperToken(): string
|
||||||
|
{
|
||||||
|
$admin = AdminUser::query()->create([
|
||||||
|
'username' => 'reconcile_scope_super',
|
||||||
|
'name' => 'Super',
|
||||||
|
'email' => null,
|
||||||
|
'password' => Hash::make('secret-strong'),
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
grantSuperAdminRole($admin);
|
||||||
|
|
||||||
|
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reconcile job list is scoped to site admin site', function (): void {
|
||||||
|
$superToken = reconcileScopeSuperToken();
|
||||||
|
|
||||||
|
$siteA = $this->withHeader('Authorization', 'Bearer '.$superToken)
|
||||||
|
->postJson('/api/v1/admin/integration-sites', [
|
||||||
|
'code' => 'reconcile-a',
|
||||||
|
'name' => 'Reconcile A',
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'reconcile_a_admin',
|
||||||
|
'nickname' => 'A Admin',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$siteB = $this->withHeader('Authorization', 'Bearer '.$superToken)
|
||||||
|
->postJson('/api/v1/admin/integration-sites', [
|
||||||
|
'code' => 'reconcile-b',
|
||||||
|
'name' => 'Reconcile B',
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'reconcile_b_admin',
|
||||||
|
'nickname' => 'B Admin',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->json('data');
|
||||||
|
|
||||||
|
$playerA = Player::query()->create([
|
||||||
|
'site_code' => 'reconcile-a',
|
||||||
|
'site_player_id' => 'scope-a-1',
|
||||||
|
'username' => null,
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
$playerB = Player::query()->create([
|
||||||
|
'site_code' => 'reconcile-b',
|
||||||
|
'site_player_id' => 'scope-b-1',
|
||||||
|
'username' => null,
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
['TO_scope_a', $playerA->id, (int) $siteA['id']],
|
||||||
|
['TO_scope_b', $playerB->id, (int) $siteB['id']],
|
||||||
|
] as [$transferNo, $playerId, $siteId]) {
|
||||||
|
TransferOrder::query()->create([
|
||||||
|
'transfer_no' => $transferNo,
|
||||||
|
'player_id' => $playerId,
|
||||||
|
'direction' => 'out',
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'amount' => 100,
|
||||||
|
'idempotent_key' => $transferNo.'-key',
|
||||||
|
'status' => 'pending_reconcile',
|
||||||
|
'external_request_payload' => null,
|
||||||
|
'external_response_payload' => null,
|
||||||
|
'external_ref_no' => null,
|
||||||
|
'fail_reason' => 'main_site_timeout',
|
||||||
|
'finished_at' => null,
|
||||||
|
'created_at' => now()->subHours(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$superToken)
|
||||||
|
->postJson('/api/v1/admin/reconcile-jobs', [
|
||||||
|
'reconcile_type' => 'wallet_transfer',
|
||||||
|
'date_from' => now()->subDay()->toDateString(),
|
||||||
|
'date_to' => now()->toDateString(),
|
||||||
|
'player_id' => $playerId,
|
||||||
|
])
|
||||||
|
->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobA = ReconcileJob::query()->where('admin_site_id', (int) $siteA['id'])->latest('id')->first();
|
||||||
|
$jobB = ReconcileJob::query()->where('admin_site_id', (int) $siteB['id'])->latest('id')->first();
|
||||||
|
expect($jobA)->not->toBeNull()
|
||||||
|
->and($jobB)->not->toBeNull()
|
||||||
|
->and((int) $jobA->admin_site_id)->toBe((int) $siteA['id'])
|
||||||
|
->and((int) $jobB->admin_site_id)->toBe((int) $siteB['id']);
|
||||||
|
|
||||||
|
$siteAdminA = AdminUser::query()->where('username', 'reconcile_a_admin')->firstOrFail();
|
||||||
|
$siteAdminB = AdminUser::query()->where('username', 'reconcile_b_admin')->firstOrFail();
|
||||||
|
expect(SitePlatformRole::userHasSiteAdminRole($siteAdminA))->toBeTrue()
|
||||||
|
->and($siteAdminA->isSuperAdmin())->toBeFalse()
|
||||||
|
->and($siteAdminA->accessibleAdminSiteIds())->toBe([(int) $siteA['id']])
|
||||||
|
->and($siteAdminA->primaryAgentNode())->toBeNull()
|
||||||
|
->and($siteAdminB->accessibleAdminSiteIds())->toBe([(int) $siteB['id']]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($siteAdminA, ['*']);
|
||||||
|
$idsA = collect($this->getJson('/api/v1/admin/reconcile-jobs')
|
||||||
|
->assertOk()
|
||||||
|
->json('data.items'))
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
Sanctum::actingAs($siteAdminB, ['*']);
|
||||||
|
$idsB = collect($this->getJson('/api/v1/admin/reconcile-jobs')
|
||||||
|
->assertOk()
|
||||||
|
->json('data.items'))
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($idsA)->toEqual([(int) $jobA->id])
|
||||||
|
->and($idsB)->toEqual([(int) $jobB->id]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($siteAdminA, ['*']);
|
||||||
|
$this->getJson('/api/v1/admin/reconcile-jobs/'.$jobB->id.'/items')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site admin reconcile scan only includes own site transfer orders', function (): void {
|
||||||
|
$superToken = reconcileScopeSuperToken();
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$superToken)
|
||||||
|
->postJson('/api/v1/admin/integration-sites', [
|
||||||
|
'code' => 'scan-scope-a',
|
||||||
|
'name' => 'Scan Scope A',
|
||||||
|
'admin_account' => [
|
||||||
|
'username' => 'scan_scope_a_admin',
|
||||||
|
'nickname' => 'Scan A',
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertCreated();
|
||||||
|
|
||||||
|
$defaultSiteCode = (string) DB::table('admin_sites')->where('is_default', true)->value('code');
|
||||||
|
|
||||||
|
$ownPlayer = Player::query()->create([
|
||||||
|
'site_code' => 'scan-scope-a',
|
||||||
|
'site_player_id' => 'scan-own',
|
||||||
|
'username' => null,
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
$otherPlayer = Player::query()->create([
|
||||||
|
'site_code' => $defaultSiteCode,
|
||||||
|
'site_player_id' => 'scan-other',
|
||||||
|
'username' => null,
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([['TO_scan_own', $ownPlayer->id], ['TO_scan_other', $otherPlayer->id]] as [$transferNo, $playerId]) {
|
||||||
|
TransferOrder::query()->create([
|
||||||
|
'transfer_no' => $transferNo,
|
||||||
|
'player_id' => $playerId,
|
||||||
|
'direction' => 'out',
|
||||||
|
'currency_code' => 'NPR',
|
||||||
|
'amount' => 200,
|
||||||
|
'idempotent_key' => $transferNo.'-key',
|
||||||
|
'status' => 'pending_reconcile',
|
||||||
|
'external_request_payload' => null,
|
||||||
|
'external_response_payload' => null,
|
||||||
|
'external_ref_no' => null,
|
||||||
|
'fail_reason' => 'main_site_timeout',
|
||||||
|
'finished_at' => null,
|
||||||
|
'created_at' => now()->subHours(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteAdmin = AdminUser::query()->where('username', 'scan_scope_a_admin')->firstOrFail();
|
||||||
|
Sanctum::actingAs($siteAdmin, ['*']);
|
||||||
|
|
||||||
|
$this->postJson('/api/v1/admin/reconcile-jobs', [
|
||||||
|
'reconcile_type' => 'wallet_transfer',
|
||||||
|
'date_from' => now()->subDay()->toDateString(),
|
||||||
|
'date_to' => now()->toDateString(),
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.item_count', 1);
|
||||||
|
|
||||||
|
$job = ReconcileJob::query()->latest('id')->firstOrFail();
|
||||||
|
expect($job->items()->value('side_a_ref'))->toBe('TO_scan_own');
|
||||||
|
});
|
||||||
99
tests/Feature/CreditHoldConcurrencyTest.php
Normal file
99
tests/Feature/CreditHoldConcurrencyTest.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Player;
|
||||||
|
use App\Services\Player\PlayerCreditService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('credit hold rejects second hold when available credit exhausted', 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' => 'cl-race',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
|
'username' => 'clr1',
|
||||||
|
'nickname' => null,
|
||||||
|
'default_currency' => 'NPR',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('player_credit_accounts')->insert([
|
||||||
|
'player_id' => $player->id,
|
||||||
|
'credit_limit' => 10,
|
||||||
|
'used_credit' => 0,
|
||||||
|
'frozen_credit' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = app(PlayerCreditService::class);
|
||||||
|
$credit->holdForBet($player, 800);
|
||||||
|
|
||||||
|
expect(fn () => $credit->holdForBet($player, 300))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
|
||||||
|
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))
|
||||||
|
->toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credit preflight rejects hold when overdue bill exists', function (): void {
|
||||||
|
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||||
|
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||||
|
'admin_site_id' => (int) $site->id,
|
||||||
|
'period_start' => now()->subWeek(),
|
||||||
|
'period_end' => now()->subDay(),
|
||||||
|
'status' => 'closed',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$player = Player::query()->create([
|
||||||
|
'site_code' => (string) $site->code,
|
||||||
|
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
|
||||||
|
'site_player_id' => 'cl-od',
|
||||||
|
'auth_source' => 'lottery_native',
|
||||||
|
'funding_mode' => 'credit',
|
||||||
|
'username' => 'clod1',
|
||||||
|
'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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('settlement_bills')->insert([
|
||||||
|
'settlement_period_id' => $periodId,
|
||||||
|
'bill_type' => 'player',
|
||||||
|
'owner_type' => 'player',
|
||||||
|
'owner_id' => $player->id,
|
||||||
|
'counterparty_type' => 'agent',
|
||||||
|
'counterparty_id' => $player->agent_node_id,
|
||||||
|
'gross_win_loss' => 1000,
|
||||||
|
'rebate_amount' => 0,
|
||||||
|
'adjustment_amount' => 0,
|
||||||
|
'net_amount' => 1000,
|
||||||
|
'paid_amount' => 0,
|
||||||
|
'unpaid_amount' => 1000,
|
||||||
|
'status' => 'overdue',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = app(PlayerCreditService::class);
|
||||||
|
|
||||||
|
expect(fn () => $credit->assertCreditPreflight($player, 100))
|
||||||
|
->toThrow(ValidationException::class);
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ test('platform role index lists built-in roles and custom system roles', functio
|
|||||||
->pluck('slug')
|
->pluck('slug')
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($slugs)->toContain('super_admin', 'site_admin', 'agent', 'legacy_custom_ops');
|
expect($slugs)->toContain('super_admin', 'site_admin', 'site_finance', 'site_cs', 'agent', 'legacy_custom_ops');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin can create custom platform role but not reserved slugs', function (): void {
|
test('admin can create custom platform role but not reserved slugs', function (): void {
|
||||||
|
|||||||
92
tests/Feature/SiteOperatorDashboardTest.php
Normal file
92
tests/Feature/SiteOperatorDashboardTest.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Support\PlatformSystemRoles;
|
||||||
|
use App\Support\SiteOperatorRoles;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function dashboardSiteOperatorToken(string $username, string $roleSlug): string
|
||||||
|
{
|
||||||
|
PlatformSystemRoles::ensureAll();
|
||||||
|
|
||||||
|
$user = AdminUser::query()->create([
|
||||||
|
'username' => $username,
|
||||||
|
'name' => ucfirst(str_replace('_', ' ', $username)),
|
||||||
|
'email' => null,
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
|
||||||
|
|
||||||
|
DB::table('admin_user_site_roles')->insert([
|
||||||
|
'admin_user_id' => $user->id,
|
||||||
|
'site_id' => $siteId,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $user->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('site finance dashboard returns finance overview without site admin overview', function (): void {
|
||||||
|
$token = dashboardSiteOperatorToken('dash_site_finance', SiteOperatorRoles::SLUG_SITE_FINANCE);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.site_overview', null)
|
||||||
|
->assertJsonPath('data.site_cs_overview', null)
|
||||||
|
->assertJsonPath('data.site_finance_overview.site_code', fn ($code) => is_string($code) && $code !== '')
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'site_finance_overview' => [
|
||||||
|
'wallet_player_count',
|
||||||
|
'credit_player_count',
|
||||||
|
'pending_confirm_bill_count',
|
||||||
|
'payable_bill_count',
|
||||||
|
'payable_unpaid_minor',
|
||||||
|
'abnormal_transfer_count',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site cs dashboard returns cs overview without finance or admin overview', function (): void {
|
||||||
|
$token = dashboardSiteOperatorToken('dash_site_cs', SiteOperatorRoles::SLUG_SITE_CS);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.site_overview', null)
|
||||||
|
->assertJsonPath('data.site_finance_overview', null)
|
||||||
|
->assertJsonPath('data.site_cs_overview.site_code', fn ($code) => is_string($code) && $code !== '')
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'site_cs_overview' => [
|
||||||
|
'player_count',
|
||||||
|
'ticket_order_count_today',
|
||||||
|
'active_player_count_today',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site admin dashboard still returns site overview only', function (): void {
|
||||||
|
$token = dashboardSiteOperatorToken('dash_site_admin', SiteOperatorRoles::SLUG_SITE_ADMIN);
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/dashboard')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.site_finance_overview', null)
|
||||||
|
->assertJsonPath('data.site_cs_overview', null)
|
||||||
|
->assertJsonPath('data.site_overview.site_code', fn ($code) => is_string($code) && $code !== '');
|
||||||
|
});
|
||||||
75
tests/Feature/SiteOperatorRolesAuthTest.php
Normal file
75
tests/Feature/SiteOperatorRolesAuthTest.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Support\PlatformSystemRoles;
|
||||||
|
use App\Support\SiteOperatorRoles;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function bindSiteOperator(AdminUser $user, string $roleSlug): void
|
||||||
|
{
|
||||||
|
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
|
||||||
|
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
|
||||||
|
|
||||||
|
DB::table('admin_user_site_roles')->insert([
|
||||||
|
'admin_user_id' => $user->id,
|
||||||
|
'site_id' => $siteId,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'granted_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('auth me exposes site role specific account_kind for site operators', function (): void {
|
||||||
|
PlatformSystemRoles::ensureAll();
|
||||||
|
|
||||||
|
$finance = AdminUser::query()->create([
|
||||||
|
'username' => 'site_finance_ops',
|
||||||
|
'name' => 'Site Finance',
|
||||||
|
'email' => null,
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
bindSiteOperator($finance, SiteOperatorRoles::SLUG_SITE_FINANCE);
|
||||||
|
|
||||||
|
$token = $finance->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/auth/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.admin.account_kind', 'site_finance')
|
||||||
|
->assertJsonPath('data.admin.site.code', fn ($code) => is_string($code) && $code !== '');
|
||||||
|
|
||||||
|
expect($finance->fresh()->adminPermissionSlugs())
|
||||||
|
->toContain('prd.report.export')
|
||||||
|
->not->toContain('prd.agent.manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site cs auth me has cs account kind without report permissions', function (): void {
|
||||||
|
PlatformSystemRoles::ensureAll();
|
||||||
|
|
||||||
|
$cs = AdminUser::query()->create([
|
||||||
|
'username' => 'site_cs_ops',
|
||||||
|
'name' => 'Site CS',
|
||||||
|
'email' => null,
|
||||||
|
'password' => 'secret-strong',
|
||||||
|
'status' => 0,
|
||||||
|
]);
|
||||||
|
bindSiteOperator($cs, SiteOperatorRoles::SLUG_SITE_CS);
|
||||||
|
|
||||||
|
$token = $cs->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||||
|
|
||||||
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->getJson('/api/v1/admin/auth/me')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.admin.account_kind', 'site_cs');
|
||||||
|
|
||||||
|
expect($cs->fresh()->adminPermissionSlugs())
|
||||||
|
->toContain('prd.users.view_cs')
|
||||||
|
->not->toContain('prd.report.view');
|
||||||
|
});
|
||||||
@@ -1,13 +1,44 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Support\SiteAdminDefaultRolePermissions;
|
use App\Support\SiteAdminDefaultRolePermissions;
|
||||||
|
use App\Support\SiteCsDefaultRolePermissions;
|
||||||
|
use App\Support\SiteFinanceDefaultRolePermissions;
|
||||||
|
|
||||||
test('site admin template includes dashboard and settlement manage', function (): void {
|
test('site admin template includes draw view export and excludes integration and wallet adjust', function (): void {
|
||||||
$slugs = SiteAdminDefaultRolePermissions::templateSlugs();
|
$slugs = SiteAdminDefaultRolePermissions::templateSlugs();
|
||||||
|
|
||||||
expect($slugs)
|
expect($slugs)
|
||||||
->toContain('prd.dashboard.view')
|
->toContain('prd.dashboard.view')
|
||||||
->toContain('prd.agent.manage')
|
->toContain('prd.agent.manage')
|
||||||
->toContain('prd.settlement.agent.manage')
|
->toContain('prd.settlement.agent.manage')
|
||||||
->toContain('prd.report.view');
|
->toContain('prd.report.view')
|
||||||
|
->toContain('prd.report.export')
|
||||||
|
->toContain('prd.draw_result.view')
|
||||||
|
->toContain('prd.wallet_reconcile.manage')
|
||||||
|
->not->toContain('prd.integration.view')
|
||||||
|
->not->toContain('prd.wallet_adjust.manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site finance template focuses on reconcile reports and settlement without agent manage', function (): void {
|
||||||
|
$slugs = SiteFinanceDefaultRolePermissions::templateSlugs();
|
||||||
|
|
||||||
|
expect($slugs)
|
||||||
|
->toContain('prd.report.export')
|
||||||
|
->toContain('prd.wallet_adjust.manage')
|
||||||
|
->toContain('prd.settlement.agent.manage')
|
||||||
|
->toContain('prd.draw_result.view')
|
||||||
|
->not->toContain('prd.agent.manage')
|
||||||
|
->not->toContain('prd.users.manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site cs template is single-player scoped without global reports', function (): void {
|
||||||
|
$slugs = SiteCsDefaultRolePermissions::templateSlugs();
|
||||||
|
|
||||||
|
expect($slugs)
|
||||||
|
->toContain('prd.dashboard.view')
|
||||||
|
->toContain('prd.users.view_cs')
|
||||||
|
->toContain('prd.tickets.view')
|
||||||
|
->toContain('prd.wallet_reconcile.view_cs')
|
||||||
|
->not->toContain('prd.report.view')
|
||||||
|
->not->toContain('prd.agent.view');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user