feat: 增强环境配置与开发服务,支持局域网访问及币种管理

This commit is contained in:
2026-05-21 16:24:41 +08:00
parent 699d43fbd4
commit 7a6048de10
60 changed files with 1321 additions and 443 deletions

View File

@@ -12,6 +12,10 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
# 应用根 URL生成链接、邮件、部分驱动依赖与 php artisan serve 端口一致时带上 :8000 # 应用根 URL生成链接、邮件、部分驱动依赖与 php artisan serve 端口一致时带上 :8000
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
# 开发服务监听地址:本机开发可保持 127.0.0.1;需要局域网访问时改为 0.0.0.0
APP_BIND_HOST=127.0.0.1
# Vite 监听地址:本机开发可保持 127.0.0.1;需要局域网访问时改为 0.0.0.0
VITE_HOST=0.0.0.0
# ============================================================================= # =============================================================================
# 语言与假数据config/app.php # 语言与假数据config/app.php
@@ -103,6 +107,8 @@ BROADCAST_CONNECTION=reverb
REVERB_APP_ID= REVERB_APP_ID=
REVERB_APP_KEY= REVERB_APP_KEY=
REVERB_APP_SECRET= REVERB_APP_SECRET=
# Reverb 服务监听地址:本机直连可用 127.0.0.1;需要局域网访问时改为 0.0.0.0
REVERB_SERVER_HOST=0.0.0.0
REVERB_HOST=localhost REVERB_HOST=localhost
REVERB_PORT=8080 REVERB_PORT=8080
REVERB_SCHEME=http REVERB_SCHEME=http
@@ -225,4 +231,6 @@ DEV_SEED_WALLET_BALANCE_MINOR=125000
DEV_SEED_WALLET_FROZEN_MINOR=0 DEV_SEED_WALLET_FROZEN_MINOR=0
# Sanctum SPA 场景:与 API 不同端口的前端域名列表,逗号分隔,用于有状态 Cookie 鉴权 # Sanctum SPA 场景:与 API 不同端口的前端域名列表,逗号分隔,用于有状态 Cookie 鉴权
# 如果要走局域网访问,把真实 IP 和端口补进来,例如:
# SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,::1,192.168.0.101:3800,192.168.0.101:3801,192.168.0.101:8000
SANCTUM_STATEFUL_DOMAINS= SANCTUM_STATEFUL_DOMAINS=

View File

@@ -69,6 +69,23 @@ php artisan schedule:work
只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS**三项都开** 只做 HTTP / 降级轮询、不测 WebSocket 时:**终端 2、3 可先不开**;要完整大厅 WS**三项都开**
## 统一配置说明
这套后端把「运行监听地址」和「对外访问地址」分开管理,避免上线时到处改常量:
- `APP_URL`:对外生成链接、邮件、重定向时使用的应用根地址
- `APP_BIND_HOST``php artisan serve` 监听哪块网卡
- `VITE_HOST``npm run dev` / Vite 监听哪块网卡
- `REVERB_SERVER_HOST``php artisan reverb:start` 监听哪块网卡
- `REVERB_HOST`:浏览器连接 Reverb 时看到的主机名或 IP
- `SANCTUM_STATEFUL_DOMAINS`:允许带 Cookie 的前端来源列表
如果你要用局域网地址访问,比如 `http://192.168.0.101:8000`,通常只需要:
1. 把 `APP_BIND_HOST``VITE_HOST``REVERB_SERVER_HOST` 改成 `0.0.0.0`
2. 把 `APP_URL``REVERB_HOST``SANCTUM_STATEFUL_DOMAINS` 改成你的局域网 IP
3. 前端的 `NEXT_PUBLIC_*` 变量也同步改成同一个局域网地址
## 后台权限体检 ## 后台权限体检
后台权限现在提供了一条可直接接入 CI 的体检命令,用来检查: 后台权限现在提供了一条可直接接入 CI 的体检命令,用来检查:
@@ -83,6 +100,21 @@ php artisan schedule:work
php artisan lottery:admin-auth-audit php artisan lottery:admin-auth-audit
``` ```
如果你新增了后台接口、权限动作或资源绑定,推荐按这条标准流程走:
1. 只改 `app/Support/AdminAuthorizationRegistry.php`
- 新接口资源加到 `resources()`
- 如需新的 legacy 权限分组,再补 `permissionDefinitions()` / 导航分组
2. 执行同步命令,把注册表写回数据库:
```bash
php artisan lottery:admin-auth-sync --audit
```
3. 确认体检通过后再提交代码
除非是历史数据修复或首发引导场景,后续不要再为单个后台接口单独写 `admin_api_resources` 补丁 migration优先走“注册表 + 同步命令”这条主路径。
或通过 Composer 脚本执行: 或通过 Composer 脚本执行:
```bash ```bash

View File

@@ -2,14 +2,15 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry; use App\Support\AdminAuthorizationRegistry;
final class SyncAdminAuthorizationCommand extends Command final class SyncAdminAuthorizationCommand extends Command
{ {
protected $signature = 'lottery:admin-auth-sync'; protected $signature = 'lottery:admin-auth-sync
{--audit : 同步完成后立即执行后台权限体检}';
protected $description = '根据后台统一注册表同步 admin_api_resources / bindings / role_api_resources'; protected $description = '根据后台统一注册表同步 admin_api_resources / bindings / role_api_resources';
@@ -55,6 +56,7 @@ final class SyncAdminAuthorizationCommand extends Command
$menuActionId = $menuActionIds[$permissionCode] ?? null; $menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) { if ($menuActionId === null) {
$this->warn(sprintf('跳过未找到的 permission_code: %s', $permissionCode)); $this->warn(sprintf('跳过未找到的 permission_code: %s', $permissionCode));
continue; continue;
} }
@@ -88,6 +90,10 @@ final class SyncAdminAuthorizationCommand extends Command
$roleResourceRows->count(), $roleResourceRows->count(),
)); ));
if ((bool) $this->option('audit')) {
return $this->call('lottery:admin-auth-audit');
}
return self::SUCCESS; return self::SUCCESS;
} }
} }

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Currency;
use App\Models\Currency;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
final class AdminCurrencyDestroyController extends Controller
{
public function __invoke(Currency $currency): JsonResponse
{
$code = strtoupper((string) $currency->code);
if ($code === strtoupper((string) config('lottery.default_currency', 'NPR'))) {
return ApiResponse::error(
'默认币种不可删除',
ErrorCode::ValidationFailed->value,
null,
422,
);
}
$references = $this->referenceSummary($code);
if ($references !== []) {
return ApiResponse::error(
'该币种已被业务数据引用,暂不可删除:'.implode('、', $references),
ErrorCode::ValidationFailed->value,
['references' => $references],
422,
);
}
$id = (int) $currency->id;
$currency->delete();
return ApiResponse::success([
'deleted' => true,
'id' => $id,
'code' => $code,
]);
}
/** @return list<string> */
private function referenceSummary(string $code): array
{
$checks = [
'玩家默认币种' => DB::table('players')->where('default_currency', $code),
'玩家钱包' => DB::table('player_wallets')->where('currency_code', $code),
'转账单' => DB::table('transfer_orders')->where('currency_code', $code),
'注单' => DB::table('ticket_orders')->where('currency_code', $code),
'赔率配置' => DB::table('odds_items')->where('currency_code', $code),
'奖池' => DB::table('jackpot_pools')->where('currency_code', $code),
'Jackpot 贡献记录' => DB::table('jackpot_contributions')->where('currency_code', $code),
];
$references = [];
foreach ($checks as $label => $query) {
if ($query->exists()) {
$references[] = $label;
}
}
return $references;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Currency;
use App\Models\Currency;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
final class AdminCurrencyIndexController extends Controller
{
public function __invoke(): JsonResponse
{
$items = Currency::query()
->orderByDesc('is_enabled')
->orderByDesc('is_bettable')
->orderBy('code')
->get()
->map(fn (Currency $currency): array => $this->serializeCurrency($currency))
->all();
return ApiResponse::success(['items' => $items]);
}
/** @return array<string, mixed> */
private function serializeCurrency(Currency $currency): array
{
return [
'id' => (int) $currency->id,
'code' => $currency->code,
'name' => $currency->name,
'decimal_places' => (int) $currency->decimal_places,
'is_enabled' => (bool) $currency->is_enabled,
'is_bettable' => (bool) $currency->is_bettable,
'created_at' => $currency->created_at?->toIso8601String(),
'updated_at' => $currency->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Currency;
use App\Models\Currency;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Services\Currency\CurrencyActivationService;
use App\Http\Requests\Admin\AdminCurrencyStoreRequest;
final class AdminCurrencyStoreController extends Controller
{
public function __invoke(
AdminCurrencyStoreRequest $request,
CurrencyActivationService $activationService,
): JsonResponse {
$data = $request->validated();
$enabled = (bool) ($data['is_enabled'] ?? true);
$bettable = $enabled && (bool) ($data['is_bettable'] ?? false);
$currency = DB::transaction(function () use ($data, $enabled, $bettable, $activationService): Currency {
$currency = Currency::query()->create([
'code' => $data['code'],
'name' => $data['name'],
'decimal_places' => (int) ($data['decimal_places'] ?? 2),
'is_enabled' => $enabled,
'is_bettable' => $bettable,
]);
$activationService->ensureBettableCurrencyReady($currency);
return $currency->fresh();
});
return ApiResponse::success($this->serializeCurrency($currency))->setStatusCode(201);
}
/** @return array<string, mixed> */
private function serializeCurrency(Currency $currency): array
{
return [
'id' => (int) $currency->id,
'code' => $currency->code,
'name' => $currency->name,
'decimal_places' => (int) $currency->decimal_places,
'is_enabled' => (bool) $currency->is_enabled,
'is_bettable' => (bool) $currency->is_bettable,
'created_at' => $currency->created_at?->toIso8601String(),
'updated_at' => $currency->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Currency;
use App\Models\Currency;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Services\Currency\CurrencyActivationService;
use App\Http\Requests\Admin\AdminCurrencyUpdateRequest;
final class AdminCurrencyUpdateController extends Controller
{
public function __invoke(
AdminCurrencyUpdateRequest $request,
Currency $currency,
CurrencyActivationService $activationService,
): JsonResponse {
$data = $request->validated();
if (array_key_exists('is_enabled', $data) && ! (bool) $data['is_enabled']) {
$data['is_bettable'] = false;
} elseif (array_key_exists('is_bettable', $data)) {
$data['is_bettable'] = (bool) $data['is_bettable'];
}
if (array_key_exists('decimal_places', $data)) {
$data['decimal_places'] = (int) $data['decimal_places'];
}
if (array_key_exists('is_enabled', $data)) {
$data['is_enabled'] = (bool) $data['is_enabled'];
}
$currency = DB::transaction(function () use ($currency, $data, $activationService): Currency {
$currency->fill($data);
$currency->save();
$activationService->ensureBettableCurrencyReady($currency);
return $currency->fresh();
});
return ApiResponse::success($this->serializeCurrency($currency));
}
/** @return array<string, mixed> */
private function serializeCurrency(Currency $currency): array
{
return [
'id' => (int) $currency->id,
'code' => $currency->code,
'name' => $currency->name,
'decimal_places' => (int) $currency->decimal_places,
'is_enabled' => (bool) $currency->is_enabled,
'is_bettable' => (bool) $currency->is_bettable,
'created_at' => $currency->created_at?->toIso8601String(),
'updated_at' => $currency->updated_at?->toIso8601String(),
];
}
}

View File

@@ -6,8 +6,8 @@ use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\PlayerApiPresenter; use App\Support\PlayerApiPresenter;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AdminPlayerStoreRequest; use App\Http\Requests\Admin\AdminPlayerStoreRequest;
/** POST /api/v1/admin/players */ /** POST /api/v1/admin/players */
@@ -38,6 +38,6 @@ final class AdminPlayerStoreController extends Controller
'status' => $request->validated('status', 0), 'status' => $request->validated('status', 0),
]); ]);
return ApiResponse::success(PlayerApiPresenter::listItem($player), 201); return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201);
} }
} }

View File

@@ -22,7 +22,7 @@ final class AdminPlayerTicketItemsIndexController extends Controller
public function __invoke(AdminPlayerTicketItemsRequest $request, Player $player): JsonResponse public function __invoke(AdminPlayerTicketItemsRequest $request, Player $player): JsonResponse
{ {
$perPage = $this->perPage($request, 'per_page', 20, 50); $perPage = $this->perPage($request, 'per_page', 10, 50);
$page = $this->page($request); $page = $this->page($request);
$drawNo = $request->validated('draw_no'); $drawNo = $request->validated('draw_no');
if (is_string($drawNo)) { if (is_string($drawNo)) {

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\ReportJob;
use App\Services\Admin\AdminReportJobService;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportJobDownloadController
{
public function __invoke(ReportJob $report_job, AdminReportJobService $service): StreamedResponse
{
$filterJson = is_array($report_job->filter_json) ? $report_job->filter_json : null;
$dateFrom = (string) ($filterJson['date_from'] ?? now()->toDateString());
$dateTo = (string) ($filterJson['date_to'] ?? $dateFrom);
$label = $service->reportLabel((string) $report_job->report_type);
$filename = $label.'_'.$dateFrom.'_'.$dateTo.'.'.$report_job->export_format;
$rows = $service->reportRows((string) $report_job->report_type, $filterJson);
if ((string) $report_job->export_format === 'xlsx') {
return response()->streamDownload(function () use ($rows): void {
echo "PK\x03\x04";
echo json_encode($rows, JSON_UNESCAPED_UNICODE);
}, $filename, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
return response()->streamDownload(function () use ($rows): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\ReportJob;
use Illuminate\Http\Request;
use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
/** GET /api/v1/admin/report-jobs */
final class ReportJobIndexController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$p = AdminApiList::readPaging($request);
$paginator = ReportJob::query()
->orderByDesc('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j));
}
/** @return array<string, mixed> */
private function row(ReportJob $j): array
{
return [
'id' => (int) $j->id,
'job_no' => $j->job_no,
'admin_user_id' => $j->admin_user_id !== null ? (int) $j->admin_user_id : null,
'report_type' => $j->report_type,
'export_format' => $j->export_format,
'filter_json' => $j->filter_json,
'status' => $j->status,
'output_path' => $j->output_path,
'error_message' => $j->error_message,
'finished_at' => $j->finished_at?->toIso8601String(),
'created_at' => $j->created_at?->toIso8601String(),
];
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\ReportJob;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
/** GET /api/v1/admin/report-jobs/{report_job} */
final class ReportJobShowController extends Controller
{
public function __invoke(ReportJob $report_job): JsonResponse
{
return ApiResponse::success([
'id' => (int) $report_job->id,
'job_no' => $report_job->job_no,
'admin_user_id' => $report_job->admin_user_id !== null ? (int) $report_job->admin_user_id : null,
'report_type' => $report_job->report_type,
'export_format' => $report_job->export_format,
'filter_json' => $report_job->filter_json,
'status' => $report_job->status,
'output_path' => $report_job->output_path,
'error_message' => $report_job->error_message,
'finished_at' => $report_job->finished_at?->toIso8601String(),
'created_at' => $report_job->created_at?->toIso8601String(),
]);
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Reports;
use App\Models\AdminUser;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\Admin\AdminReportJobService;
use App\Http\Requests\Admin\ReportJobStoreRequest;
/** POST /api/v1/admin/report-jobs */
final class ReportJobStoreController extends Controller
{
public function __invoke(ReportJobStoreRequest $request, AdminReportJobService $service): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
$data = $request->validated();
$job = $service->enqueue(
$admin,
$request,
(string) $data['report_type'],
(string) ($data['export_format'] ?? 'csv'),
isset($data['filter_json']) ? (array) $data['filter_json'] : null,
);
return ApiResponse::success([
'id' => (int) $job->id,
'job_no' => $job->job_no,
'report_type' => $job->report_type,
'export_format' => $job->export_format,
'status' => $job->status,
'output_path' => $job->output_path,
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Models\Draw; use App\Models\Draw;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Models\TicketOrder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -49,9 +50,14 @@ final class AdminRiskPoolIndexController extends Controller
/** @var LengthAwarePaginator $paginator */ /** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
$currencyCode = (string) (TicketOrder::query()
->where('draw_id', $draw->id)
->value('currency_code') ?? '');
return AdminApiList::jsonWith($paginator, fn (RiskPool $row) => $this->row($row), [ return AdminApiList::jsonWith($paginator, fn (RiskPool $row) => $this->row($row), [
'draw_id' => (int) $draw->id, 'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
]); ]);
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Risk; namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Models\Draw; use App\Models\Draw;
use App\Models\TicketOrder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
use App\Models\RiskPoolLockLog; use App\Models\RiskPoolLockLog;
@@ -38,9 +39,14 @@ final class AdminRiskPoolLockLogIndexController extends Controller
/** @var LengthAwarePaginator $paginator */ /** @var LengthAwarePaginator $paginator */
$paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']); $paginator = $q->paginate($p['perPage'], ['*'], 'page', $p['page']);
$currencyCode = (string) (TicketOrder::query()
->where('draw_id', $draw->id)
->value('currency_code') ?? '');
return AdminApiList::jsonWith($paginator, fn (RiskPoolLockLog $log) => $this->row($log), [ return AdminApiList::jsonWith($paginator, fn (RiskPoolLockLog $log) => $this->row($log), [
'draw_id' => (int) $draw->id, 'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
]); ]);
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk;
use App\Models\Draw; use App\Models\Draw;
use App\Models\RiskPool; use App\Models\RiskPool;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\TicketOrder;
use App\Support\ApiResponse; use App\Support\ApiResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Support\AdminApiList; use App\Support\AdminApiList;
@@ -56,10 +57,14 @@ final class AdminRiskPoolShowController extends Controller
$cap = (int) $pool->total_cap_amount; $cap = (int) $pool->total_cap_amount;
$locked = (int) $pool->locked_amount; $locked = (int) $pool->locked_amount;
$currencyCode = (string) (TicketOrder::query()
->where('draw_id', $draw->id)
->value('currency_code') ?? '');
return ApiResponse::success([ return ApiResponse::success([
'draw_id' => (int) $draw->id, 'draw_id' => (int) $draw->id,
'draw_no' => $draw->draw_no, 'draw_no' => $draw->draw_no,
'currency_code' => $currencyCode !== '' ? $currencyCode : null,
'pool' => [ 'pool' => [
'normalized_number' => $pool->normalized_number, 'normalized_number' => $pool->normalized_number,
'total_cap_amount' => $cap, 'total_cap_amount' => $cap,

View File

@@ -23,6 +23,7 @@ final class AdminSettlementBatchDetailsController extends Controller
->with([ ->with([
'ticketItem:id,ticket_no,play_code,player_id', 'ticketItem:id,ticket_no,play_code,player_id',
'ticketItem.player:id,username,site_player_id', 'ticketItem.player:id,username,site_player_id',
'ticketItem.order:id,currency_code',
]) ])
->orderBy('id') ->orderBy('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']); ->paginate($p['perPage'], ['*'], 'page', $p['page']);
@@ -31,12 +32,14 @@ final class AdminSettlementBatchDetailsController extends Controller
/** @var TicketSettlementDetail $row */ /** @var TicketSettlementDetail $row */
$item = $row->ticketItem; $item = $row->ticketItem;
$player = $item?->player; $player = $item?->player;
$order = $item?->order;
return [ return [
'id' => (int) $row->id, 'id' => (int) $row->id,
'ticket_item_id' => (int) $row->ticket_item_id, 'ticket_item_id' => (int) $row->ticket_item_id,
'ticket_no' => $item?->ticket_no, 'ticket_no' => $item?->ticket_no,
'play_code' => $item?->play_code, 'play_code' => $item?->play_code,
'currency_code' => $order?->currency_code,
'player_id' => $item?->player_id, 'player_id' => $item?->player_id,
'player_username' => $player?->username, 'player_username' => $player?->username,
'site_player_id' => $player?->site_player_id, 'site_player_id' => $player?->site_player_id,

View File

@@ -46,6 +46,7 @@ final class AdminSettlementBatchIndexController extends Controller
'id' => (int) $b->id, 'id' => (int) $b->id,
'draw_id' => (int) $b->draw_id, 'draw_id' => (int) $b->draw_id,
'draw_no' => $b->draw?->draw_no, 'draw_no' => $b->draw?->draw_no,
'currency_code' => $financial['currency_code'],
'result_batch_id' => (int) $b->result_batch_id, 'result_batch_id' => (int) $b->result_batch_id,
'settle_version' => (int) $b->settle_version, 'settle_version' => (int) $b->settle_version,
'status' => $b->status, 'status' => $b->status,

View File

@@ -22,6 +22,7 @@ final class AdminSettlementBatchShowController extends Controller
'id' => (int) $batch->id, 'id' => (int) $batch->id,
'draw_id' => (int) $batch->draw_id, 'draw_id' => (int) $batch->draw_id,
'draw_no' => $batch->draw?->draw_no, 'draw_no' => $batch->draw?->draw_no,
'currency_code' => $financial['currency_code'],
'draw_status' => $batch->draw?->status, 'draw_status' => $batch->draw?->status,
'result_batch_id' => (int) $batch->result_batch_id, 'result_batch_id' => (int) $batch->result_batch_id,
'result_batch_version' => $batch->resultBatch?->result_version, 'result_batch_version' => $batch->resultBatch?->result_version,

View File

@@ -30,7 +30,7 @@ final class AdminTicketItemIndexController extends Controller
{ {
$validated = $request->validated(); $validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 20, 100); $perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request); $page = $this->page($request);
$query = TicketItem::query() $query = TicketItem::query()

View File

@@ -16,7 +16,7 @@ use App\Http\Requests\Admin\TransferOrderListRequest;
* *
* Query * Query
* - `page`(默认 1 * - `page`(默认 1
* - `per_page` `size`(每页条数,默认 20,最大 100 * - `per_page` `size`(每页条数,默认 10,最大 100
* - `player_id`(可选,按玩家主键) * - `player_id`(可选,按玩家主键)
* - `player_account`(可选,模糊匹配 `players.site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准) * - `player_account`(可选,模糊匹配 `players.site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准)
* - `transfer_no`(可选,模糊匹配本地单号) * - `transfer_no`(可选,模糊匹配本地单号)
@@ -35,7 +35,7 @@ final class TransferOrderListController extends Controller
{ {
$validated = $request->validated(); $validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 20, 100); $perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request); $page = $this->page($request);
$query = TransferOrder::query() $query = TransferOrder::query()

View File

@@ -35,7 +35,7 @@ final class WalletTransactionListController extends Controller
{ {
$validated = $request->validated(); $validated = $request->validated();
$perPage = $this->perPage($request, 'per_page', 20, 100); $perPage = $this->perPage($request, 'per_page', 10, 100);
$page = $this->page($request); $page = $this->page($request);
$query = WalletTxn::query() $query = WalletTxn::query()

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Api\V1\Currency;
use App\Models\Currency;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
final class CurrencyIndexController extends Controller
{
public function __invoke(): JsonResponse
{
$items = Currency::query()
->where('is_enabled', true)
->orderByDesc('is_bettable')
->orderBy('code')
->get()
->map(fn (Currency $currency): array => [
'code' => $currency->code,
'name' => $currency->name,
'decimal_places' => (int) $currency->decimal_places,
'is_bettable' => (bool) $currency->is_bettable,
])
->all();
return ApiResponse::success(['items' => $items]);
}
}

View File

@@ -19,6 +19,11 @@ final class JackpotSummaryController extends Controller
public function __invoke(Request $request): JsonResponse public function __invoke(Request $request): JsonResponse
{ {
return ApiResponse::success($this->summary->summary((string) $request->query('currency_code', 'NPR'))); $currencyCode = strtoupper(trim((string) $request->query(
'currency_code',
(string) config('lottery.default_currency', 'NPR'),
)));
return ApiResponse::success($this->summary->summary($currencyCode));
} }
} }

View File

@@ -24,7 +24,7 @@ final class TicketItemsIndexController extends Controller
/** @var Player $player */ /** @var Player $player */
$player = $request->attributes->get('lottery_player'); $player = $request->attributes->get('lottery_player');
$perPage = $this->perPage($request, 'per_page', 20, 50); $perPage = $this->perPage($request, 'per_page', 10, 50);
$page = $this->page($request); $page = $this->page($request);
$drawNo = $request->query('draw_no'); $drawNo = $request->query('draw_no');
$statusInput = $request->query('status', []); $statusInput = $request->query('status', []);

View File

@@ -77,7 +77,7 @@ final class WalletBalanceController extends Controller
{ {
$code = CurrencyResolver::resolve($request, $player, 'currency'); $code = CurrencyResolver::resolve($request, $player, 'currency');
if (! CurrencyResolver::isValid($code)) { if (! CurrencyResolver::isEnabled($code)) {
return ApiResponse::error( return ApiResponse::error(
__('wallet.invalid_currency'), __('wallet.invalid_currency'),
ErrorCode::WalletInvalidCurrency->value, ErrorCode::WalletInvalidCurrency->value,

View File

@@ -57,7 +57,7 @@ final class WalletTransferInController extends Controller
{ {
$code = CurrencyResolver::resolve($request, $player, 'currency'); $code = CurrencyResolver::resolve($request, $player, 'currency');
if (! CurrencyResolver::isValid($code)) { if (! CurrencyResolver::isEnabled($code)) {
return ApiResponse::error( return ApiResponse::error(
__('wallet.invalid_currency'), __('wallet.invalid_currency'),
ErrorCode::WalletInvalidCurrency->value, ErrorCode::WalletInvalidCurrency->value,

View File

@@ -57,7 +57,7 @@ final class WalletTransferOutController extends Controller
{ {
$code = CurrencyResolver::resolve($request, $player, 'currency'); $code = CurrencyResolver::resolve($request, $player, 'currency');
if (! CurrencyResolver::isValid($code)) { if (! CurrencyResolver::isEnabled($code)) {
return ApiResponse::error( return ApiResponse::error(
__('wallet.invalid_currency'), __('wallet.invalid_currency'),
ErrorCode::WalletInvalidCurrency->value, ErrorCode::WalletInvalidCurrency->value,

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest;
final class AdminCurrencyStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:16', 'regex:/^[A-Z0-9]{1,16}$/', Rule::unique('currencies', 'code')],
'name' => ['required', 'string', 'max:64'],
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:12'],
'is_enabled' => ['sometimes', 'boolean'],
'is_bettable' => ['sometimes', 'boolean'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('code')) {
$this->merge([
'code' => strtoupper(substr(trim((string) $this->input('code')), 0, 16)),
]);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
final class AdminCurrencyUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:64'],
'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:12'],
'is_enabled' => ['sometimes', 'boolean'],
'is_bettable' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
/** /**
@@ -23,8 +24,18 @@ final class AdminPlayerStoreRequest extends FormRequest
'site_player_id' => ['required', 'string', 'max:128'], 'site_player_id' => ['required', 'string', 'max:128'],
'username' => ['nullable', 'string', 'max:128'], 'username' => ['nullable', 'string', 'max:128'],
'nickname' => ['nullable', 'string', 'max:128'], 'nickname' => ['nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16'], 'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', 'in:0,1,2'], 'status' => ['sometimes', 'integer', 'in:0,1,2'],
]; ];
} }
protected function prepareForValidation(): void
{
if (! $this->has('default_currency')) {
return;
}
$code = strtoupper(substr(trim((string) $this->input('default_currency')), 0, 16));
$this->merge(['default_currency' => $code]);
}
} }

View File

@@ -2,8 +2,8 @@
namespace App\Http\Requests\Admin; namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Foundation\Http\FormRequest;
/** /**
* 玩家更新请求。 * 玩家更新请求。
@@ -22,7 +22,18 @@ final class AdminPlayerUpdateRequest extends FormRequest
return [ return [
'username' => ['sometimes', 'string', 'max:128'], 'username' => ['sometimes', 'string', 'max:128'],
'nickname' => ['sometimes', 'nullable', 'string', 'max:128'], 'nickname' => ['sometimes', 'nullable', 'string', 'max:128'],
'default_currency' => ['sometimes', 'string', 'max:16', Rule::exists('currencies', 'code')],
'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])], 'status' => ['sometimes', 'integer', Rule::in([0, 1, 2])],
]; ];
} }
protected function prepareForValidation(): void
{
if (! $this->has('default_currency')) {
return;
}
$code = strtoupper(substr(trim((string) $this->input('default_currency')), 0, 16));
$this->merge(['default_currency' => $code]);
}
} }

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 报表任务创建请求。
*
* @see ReportJobStoreController
*/
final class ReportJobStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'report_type' => ['required', 'string', Rule::in(self::reportTypes())],
'export_format' => ['sometimes', 'string', Rule::in(['csv', 'xlsx'])],
'parameters' => ['sometimes', 'array'],
'parameters.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'parameters.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:parameters.date_from'],
'filter_json' => ['sometimes', 'array'],
'filter_json.date_from' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'filter_json.date_to' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'after_or_equal:filter_json.date_from'],
];
}
/**
* @return list<string>
*/
public static function reportTypes(): array
{
return [
'draw_profit_summary',
'daily_profit_summary',
'player_win_loss',
'wallet_transfer_report',
'hot_number_risk_report',
'play_dimension_report',
'sold_out_number_report',
'rebate_commission_report',
'audit_operation_report',
'wallet_txns_daily',
'transfer_orders_daily',
];
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ReportJob extends Model
{
protected $table = 'report_jobs';
protected $fillable = [
'job_no',
'admin_user_id',
'report_type',
'export_format',
'filter_json',
'status',
'output_path',
'error_message',
'finished_at',
];
protected function casts(): array
{
return [
'filter_json' => 'array',
'finished_at' => 'datetime',
];
}
/** @return BelongsTo<AdminUser, ReportJob> */
public function adminUser(): BelongsTo
{
return $this->belongsTo(AdminUser::class, 'admin_user_id');
}
}

View File

@@ -1,116 +0,0 @@
<?php
namespace App\Services\Admin;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
/**
* 报表导出任务:落库 `report_jobs`(阶段 7;异步生成可后续接队列)。
*/
final class AdminReportJobService
{
/**
* @param array<string, mixed>|null $filterJson
*/
public function enqueue(AdminUser $admin, Request $request, string $reportType, string $exportFormat, ?array $filterJson): ReportJob
{
return DB::transaction(function () use ($admin, $request, $reportType, $exportFormat, $filterJson): ReportJob {
$jobNo = 'RPT'.now()->format('YmdHis').strtoupper(Str::random(4));
$params = $this->extractReportParameters($request, $filterJson);
$dateFrom = (string) ($params['date_from'] ?? now()->toDateString());
$dateTo = (string) ($params['date_to'] ?? $dateFrom);
$job = ReportJob::query()->create([
'job_no' => $jobNo,
'admin_user_id' => (int) $admin->getKey(),
'report_type' => $reportType,
'export_format' => $exportFormat,
'filter_json' => $filterJson,
'status' => 'completed',
'output_path' => 'reports/'.$this->reportLabel($reportType).'_'.$dateFrom.'_'.$dateTo.'.'.$exportFormat,
'error_message' => null,
'finished_at' => now(),
]);
AuditLogger::recordForAdmin(
$admin,
$request,
'report_jobs',
'enqueue',
'report_job',
(string) $job->getKey(),
null,
[
'job_no' => $jobNo,
'report_type' => $reportType,
'export_format' => $exportFormat,
],
);
return $job;
});
}
public function reportLabel(string $reportType): string
{
return match ($reportType) {
'draw_profit_summary' => '期号盈亏',
'daily_profit_summary' => '每日盈亏汇总',
'player_win_loss' => '玩家输赢报表',
'wallet_transfer_report', 'wallet_txns_daily', 'transfer_orders_daily' => '玩家转入转出报表',
'hot_number_risk_report' => '热门号码风险报表',
'play_dimension_report' => '玩法维度报表',
'sold_out_number_report' => '售罄号码报表',
'rebate_commission_report' => '佣金回水报表',
'audit_operation_report' => '后台操作审计报表',
default => $reportType,
};
}
/**
* @return list<array<int, string|int|float|null>>
*/
public function reportRows(string $reportType, ?array $filterJson): array
{
$dateFrom = (string) ($filterJson['date_from'] ?? now()->toDateString());
$dateTo = (string) ($filterJson['date_to'] ?? $dateFrom);
return match ($reportType) {
'daily_profit_summary' => [
['日期', '下注', '派彩', '盈亏'],
[$dateFrom, 1000, 600, 400],
[$dateTo, 1200, 500, 700],
],
'audit_operation_report' => [
['模块', '操作', '操作者', '时间', 'IP'],
['report_jobs', 'enqueue', 'admin', now()->toIso8601String(), '127.0.0.1'],
],
default => [
['报表类型', '开始日期', '结束日期'],
[$this->reportLabel($reportType), $dateFrom, $dateTo],
],
};
}
/**
* @return array<string, mixed>
*/
private function extractReportParameters(Request $request, ?array $filterJson): array
{
$parameters = $request->input('parameters');
if (is_array($parameters)) {
return $parameters;
}
if (is_array($filterJson)) {
return $filterJson;
}
return [];
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Services\Currency;
use App\Models\Currency;
use App\Models\OddsItem;
use App\Models\PlayType;
use App\Models\JackpotPool;
use App\Models\OddsVersion;
use Illuminate\Support\Facades\DB;
use App\Support\OddsStandardScopes;
/**
* 为新开通下注能力的币种补齐最小可运营数据。
*/
final class CurrencyActivationService
{
public function ensureBettableCurrencyReady(Currency $currency): void
{
if (! $currency->is_enabled || ! $currency->is_bettable) {
return;
}
DB::transaction(function () use ($currency): void {
$currencyCode = strtoupper((string) $currency->code);
$this->ensureJackpotPool($currencyCode);
$this->ensureOddsItems($currencyCode);
});
}
private function ensureJackpotPool(string $currencyCode): void
{
$exists = JackpotPool::query()
->where('currency_code', $currencyCode)
->exists();
if ($exists) {
return;
}
$source = JackpotPool::query()
->where('currency_code', '!=', $currencyCode)
->orderByDesc('status')
->orderBy('currency_code')
->first();
JackpotPool::query()->create([
'currency_code' => $currencyCode,
'current_amount' => 0,
'contribution_rate' => (string) ($source?->contribution_rate ?? '0.0200'),
'trigger_threshold' => (int) ($source?->trigger_threshold ?? 100_000_000),
'payout_rate' => (string) ($source?->payout_rate ?? '0.5000'),
'force_trigger_draw_gap' => (int) ($source?->force_trigger_draw_gap ?? 100),
'min_bet_amount' => (int) ($source?->min_bet_amount ?? 100),
'combo_trigger_play_codes' => $source?->combo_trigger_play_codes,
'status' => (int) ($source?->status ?? 0),
'last_trigger_draw_id' => null,
]);
}
private function ensureOddsItems(string $currencyCode): void
{
OddsVersion::query()
->orderBy('id')
->each(function (OddsVersion $version) use ($currencyCode): void {
$this->ensureOddsItemsForVersion($version, $currencyCode);
});
}
private function ensureOddsItemsForVersion(OddsVersion $version, string $currencyCode): void
{
$hasTargetCurrency = OddsItem::query()
->where('version_id', $version->id)
->where('currency_code', $currencyCode)
->exists();
if ($hasTargetCurrency) {
OddsStandardScopes::syncMissingForVersion($version);
return;
}
$sourceCurrency = OddsItem::query()
->where('version_id', $version->id)
->where('currency_code', '!=', $currencyCode)
->orderBy('currency_code')
->value('currency_code');
if (is_string($sourceCurrency) && $sourceCurrency !== '') {
OddsItem::query()
->where('version_id', $version->id)
->where('currency_code', $sourceCurrency)
->orderBy('play_code')
->orderBy('prize_scope')
->get()
->each(function (OddsItem $item) use ($version, $currencyCode): void {
OddsItem::query()->firstOrCreate(
[
'version_id' => $version->id,
'play_code' => $item->play_code,
'prize_scope' => $item->prize_scope,
'currency_code' => $currencyCode,
],
[
'odds_value' => (int) $item->odds_value,
'rebate_rate' => (float) $item->rebate_rate,
'commission_rate' => (float) $item->commission_rate,
'extra_config_json' => $item->extra_config_json,
],
);
});
OddsStandardScopes::syncMissingForVersion($version);
return;
}
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->get() as $playType) {
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
OddsItem::query()->firstOrCreate(
[
'version_id' => $version->id,
'play_code' => $playType->play_code,
'prize_scope' => $scope,
'currency_code' => $currencyCode,
],
[
'odds_value' => $oddsValue,
'rebate_rate' => 0,
'commission_rate' => 0,
'extra_config_json' => null,
],
);
}
}
}
}

View File

@@ -12,7 +12,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator;
*/ */
final class AdminApiList final class AdminApiList
{ {
public const DEFAULT_PER_PAGE = 25; public const DEFAULT_PER_PAGE = 10;
public const MAX_PER_PAGE = 100; public const MAX_PER_PAGE = 100;

View File

@@ -27,6 +27,7 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']], ['slug' => 'prd.users.view_finance', 'name' => '用户管理·财务查看', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.wallet.view']],
['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']], ['slug' => 'prd.users.view_cs', 'name' => '用户管理·客服单用户', 'nav_segment' => 'players', 'permission_codes' => ['service.players.view', 'service.tickets.view']],
['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']], ['slug' => 'prd.player_freeze.manage', 'name' => '冻结/解冻玩家·可管理', 'nav_segment' => 'players', 'permission_codes' => ['service.players.freeze']],
['slug' => 'prd.currency.manage', 'name' => '币种管理·可管理', 'nav_segment' => 'settings', 'permission_codes' => ['service.currency.manage']],
['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']], ['slug' => 'prd.wallet_reconcile.manage', 'name' => '钱包对账·可管理', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage']],
['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']], ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看', 'nav_segment' => 'wallet', 'permission_codes' => ['service.wallet.view', 'service.reconcile.view']],
@@ -50,11 +51,6 @@ final class AdminAuthorizationRegistry
['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']], ['slug' => 'prd.payout.review', 'name' => '派彩确认·可审核', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.review', 'settlement.batch.view']],
['slug' => 'prd.payout.view', 'name' => '派彩确认·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.view']], ['slug' => 'prd.payout.view', 'name' => '派彩确认·查看', 'nav_segment' => 'settlement', 'permission_codes' => ['settlement.batch.view']],
['slug' => 'prd.report.all', 'name' => '报表·全部', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']],
['slug' => 'prd.report.risk', 'name' => '报表·风控', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view']],
['slug' => 'prd.report.finance', 'name' => '报表·财务', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view', 'service.reports.export']],
['slug' => 'prd.report.player', 'name' => '报表·单用户', 'nav_segment' => 'reports', 'permission_codes' => ['service.reports.view']],
['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ['slug' => 'prd.audit.all', 'name' => '审计日志·全部', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']], ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关', 'nav_segment' => 'audit', 'permission_codes' => ['service.audit.view']],
@@ -74,6 +70,7 @@ final class AdminAuthorizationRegistry
'admin_users' => '管理列表', 'admin_users' => '管理列表',
'admin_roles' => '角色管理', 'admin_roles' => '角色管理',
'players' => '玩家列表', 'players' => '玩家列表',
'currencies' => '币种管理',
'wallet' => '钱包流水', 'wallet' => '钱包流水',
'draws' => '期号列表', 'draws' => '期号列表',
'config' => '运营配置', 'config' => '运营配置',
@@ -82,7 +79,6 @@ final class AdminAuthorizationRegistry
'jackpot' => 'Jackpot', 'jackpot' => 'Jackpot',
'reconcile' => '对账', 'reconcile' => '对账',
'tickets' => '玩家注单', 'tickets' => '玩家注单',
'reports' => '报表导出',
'audit' => '审计日志', 'audit' => '审计日志',
'settings' => '系统设置', 'settings' => '系统设置',
]; ];
@@ -113,17 +109,17 @@ final class AdminAuthorizationRegistry
['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'],
['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'requiredAny' => ['prd.admin_user.manage']],
['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'requiredAny' => ['prd.admin_role.manage']],
['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']],
['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.users.manage', 'prd.users.view_finance']], ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.manage']],
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['segment' => 'wallet', 'label' => 'Wallet', 'href' => '/admin/wallet/transactions', 'activeMatchPrefix' => '/admin/wallet', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.manage', 'prd.users.view_finance']],
['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']],
['segment' => 'config', 'label' => 'Configuration', 'href' => '/admin/config', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view']], ['segment' => 'config', 'label' => 'Configuration', 'href' => '/admin/config', 'requiredAny' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view']],
['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']], ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'requiredAny' => ['prd.draw_result.view', 'prd.draw_result.manage']],
['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']], ['segment' => 'settlement', 'label' => 'Settlement', 'href' => '/admin/settlement-batches', 'requiredAny' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view']],
['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['segment' => 'reconcile', 'label' => 'Reconcile', 'href' => '/admin/reconcile', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage', 'prd.report.player']], ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'requiredAny' => ['prd.users.view_cs', 'prd.users.manage', 'prd.users.view_finance', 'prd.draw_result.view', 'prd.draw_result.manage', 'prd.payout.view', 'prd.payout.review', 'prd.payout.manage']],
['segment' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'requiredAny' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance']],
['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings'], ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.currency.manage']],
]; ];
} }
@@ -180,15 +176,16 @@ final class AdminAuthorizationRegistry
'admin_users' => ['prd.admin_user.manage'], 'admin_users' => ['prd.admin_user.manage'],
'admin_roles' => ['prd.admin_role.manage'], 'admin_roles' => ['prd.admin_role.manage'],
'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'], 'players' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage'],
'currencies' => ['prd.currency.manage'],
'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'], 'wallet' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage', 'prd.users.view_finance'],
'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'], 'draws' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage'],
'config' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view'], 'config' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.risk_cap.view', 'prd.rebate.manage', 'prd.rebate.view', 'prd.jackpot.manage', 'prd.jackpot.view'],
'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'], 'risk' => ['prd.draw_result.manage', 'prd.draw_result.view'],
'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'], 'settlement' => ['prd.payout.manage', 'prd.payout.review', 'prd.payout.view'],
'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'], 'reconcile' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs'],
'tickets' => ['prd.users.view_cs', 'prd.users.manage', 'prd.report.player'], 'tickets' => ['prd.users.view_cs', 'prd.users.manage'],
'reports' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'],
'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'], 'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'],
'settings' => [],
]; ];
if (isset($explicit[$segment])) { if (isset($explicit[$segment])) {
@@ -358,6 +355,10 @@ final class AdminAuthorizationRegistry
['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']], ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.play_switch.manage', 'prd.odds.manage', 'prd.risk_cap.manage', 'prd.rebate.manage', 'prd.jackpot.manage']],
['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.currencies.store', 'module_code' => 'settings', 'name' => '创建币种', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.currencies.update', 'module_code' => 'settings', 'name' => '更新币种', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.currencies.destroy', 'module_code' => 'settings', 'name' => '删除币种', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']],
['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.index', 'module_code' => 'draw', 'name' => '期开奖列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws', 'route_name' => 'api.v1.admin.draws.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.draws.show', 'module_code' => 'draw', 'name' => '期开奖详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/draws/{draw}', 'route_name' => 'api.v1.admin.draws.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.draw_result.manage', 'prd.draw_result.view']],
@@ -410,10 +411,6 @@ final class AdminAuthorizationRegistry
['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']],
['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], ['code' => 'admin.reconcile-jobs.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']],
['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['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, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表任务', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player']],
]; ];
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Support; namespace App\Support;
use App\Models\Player; use App\Models\Player;
use App\Models\Currency;
use Illuminate\Http\Request; use Illuminate\Http\Request;
/** /**
@@ -52,6 +53,27 @@ final class CurrencyResolver
return preg_match('/^[A-Z0-9]{1,16}$/', $code) === 1; return preg_match('/^[A-Z0-9]{1,16}$/', $code) === 1;
} }
/**
* 币种是否已配置于主数据表。
*/
public static function exists(string $code): bool
{
return self::isValid($code)
&& Currency::query()->where('code', strtoupper($code))->exists();
}
/**
* 币种是否处于启用状态。
*/
public static function isEnabled(string $code): bool
{
return self::isValid($code)
&& Currency::query()
->where('code', strtoupper($code))
->where('is_enabled', true)
->exists();
}
/** /**
* 解析并验证币种码,无效时返回 null * 解析并验证币种码,无效时返回 null
*/ */

View File

@@ -21,7 +21,7 @@ trait PaginationTrait
* @param int $default 默认值 * @param int $default 默认值
* @param int $max 最大值上限 * @param int $max 最大值上限
*/ */
protected function perPage(Request $request, string $key = 'per_page', int $default = 20, int $max = 50): int protected function perPage(Request $request, string $key = 'per_page', int $default = 10, int $max = 50): int
{ {
$value = (int) $request->query($key, $request->query('size', $default)); $value = (int) $request->query($key, $request->query('size', $default));
@@ -41,7 +41,7 @@ trait PaginationTrait
* *
* @return array{page: int, per_page: int} * @return array{page: int, per_page: int}
*/ */
protected function paginationMeta(Request $request, int $defaultPerPage = 20, int $maxPerPage = 50): array protected function paginationMeta(Request $request, int $defaultPerPage = 10, int $maxPerPage = 50): array
{ {
return [ return [
'page' => $this->page($request), 'page' => $this->page($request),

View File

@@ -7,14 +7,16 @@ use App\Models\SettlementBatch;
final class SettlementBatchFinancialSummary final class SettlementBatchFinancialSummary
{ {
/** /**
* @return array{total_bet_amount: int, total_actual_deduct: int, platform_profit: int} * @return array{total_bet_amount: int, total_actual_deduct: int, platform_profit: int, currency_code: ?string}
*/ */
public static function forBatch(SettlementBatch $batch): array public static function forBatch(SettlementBatch $batch): array
{ {
$totals = $batch->details() $totals = $batch->details()
->join('ticket_items', 'ticket_items.id', '=', 'ticket_settlement_details.ticket_item_id') ->join('ticket_items', 'ticket_items.id', '=', 'ticket_settlement_details.ticket_item_id')
->join('ticket_orders', 'ticket_orders.id', '=', 'ticket_items.order_id')
->selectRaw('COALESCE(SUM(ticket_items.total_bet_amount), 0) as total_bet_amount') ->selectRaw('COALESCE(SUM(ticket_items.total_bet_amount), 0) as total_bet_amount')
->selectRaw('COALESCE(SUM(ticket_items.actual_deduct_amount), 0) as total_actual_deduct') ->selectRaw('COALESCE(SUM(ticket_items.actual_deduct_amount), 0) as total_actual_deduct')
->selectRaw('MIN(ticket_orders.currency_code) as currency_code')
->first(); ->first();
$totalBet = (int) ($totals?->total_bet_amount ?? 0); $totalBet = (int) ($totals?->total_bet_amount ?? 0);
@@ -25,6 +27,9 @@ final class SettlementBatchFinancialSummary
'total_bet_amount' => $totalBet, 'total_bet_amount' => $totalBet,
'total_actual_deduct' => $totalActualDeduct, 'total_actual_deduct' => $totalActualDeduct,
'platform_profit' => $totalActualDeduct - $totalPayout, 'platform_profit' => $totalActualDeduct - $totalPayout,
'currency_code' => is_string($totals?->currency_code) && $totals->currency_code !== ''
? $totals->currency_code
: null,
]; ];
} }
} }

View File

@@ -49,11 +49,11 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#f472b6\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#f472b6\" \"php artisan serve --host=\\\"${APP_BIND_HOST:-127.0.0.1}\\\"\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
], ],
"dev:realtime": [ "dev:realtime": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#34d399,#f472b6\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"php artisan reverb:start\" \"php artisan schedule:work\" \"npm run dev\" --names=server,queue,logs,reverb,schedule,vite --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#34d399,#f472b6\" \"php artisan serve --host=\\\"${APP_BIND_HOST:-127.0.0.1}\\\"\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"php artisan reverb:start --host=\\\"${REVERB_SERVER_HOST:-0.0.0.0}\\\" --hostname=\\\"${REVERB_HOST:-localhost}\\\" --port=\\\"${REVERB_PORT:-8080}\\\"\" \"php artisan schedule:work\" \"npm run dev\" --names=server,queue,logs,reverb,schedule,vite --kill-others"
], ],
"dev:schedule": [ "dev:schedule": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",

View File

@@ -267,7 +267,6 @@ return new class extends Migration
['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.tickets', 'name' => '玩家注单', 'path' => '/admin/tickets', 'route_name' => 'admin.tickets.index', 'component' => 'service/tickets', 'icon' => null, 'sort_order' => 20], ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.tickets', 'name' => '玩家注单', 'path' => '/admin/tickets', 'route_name' => 'admin.tickets.index', 'component' => 'service/tickets', 'icon' => null, 'sort_order' => 20],
['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.wallet', 'name' => '钱包流水', 'path' => '/admin/wallet/transactions', 'route_name' => 'admin.wallet.transactions', 'component' => 'service/wallet', 'icon' => null, 'sort_order' => 30], ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.wallet', 'name' => '钱包流水', 'path' => '/admin/wallet/transactions', 'route_name' => 'admin.wallet.transactions', 'component' => 'service/wallet', 'icon' => null, 'sort_order' => 30],
['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.reconcile', 'name' => '对账管理', 'path' => '/admin/reconcile', 'route_name' => 'admin.reconcile.index', 'component' => 'service/reconcile', 'icon' => null, 'sort_order' => 40], ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.reconcile', 'name' => '对账管理', 'path' => '/admin/reconcile', 'route_name' => 'admin.reconcile.index', 'component' => 'service/reconcile', 'icon' => null, 'sort_order' => 40],
['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.reports', 'name' => '报表导出', 'path' => '/admin/reports', 'route_name' => 'admin.reports.index', 'component' => 'service/reports', 'icon' => null, 'sort_order' => 50],
['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.audit', 'name' => '审计日志', 'path' => '/admin/audit-logs', 'route_name' => 'admin.audit.index', 'component' => 'service/audit', 'icon' => null, 'sort_order' => 60], ['parent_code' => 'service', 'menu_type' => 'page', 'code' => 'service.audit', 'name' => '审计日志', 'path' => '/admin/audit-logs', 'route_name' => 'admin.audit.index', 'component' => 'service/audit', 'icon' => null, 'sort_order' => 60],
['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_user', 'name' => '管理员权限', 'path' => '/admin/admin-users', 'route_name' => 'admin.system.admin-users', 'component' => 'system/admin-users', 'icon' => 'users-round', 'sort_order' => 70], ['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_user', 'name' => '管理员权限', 'path' => '/admin/admin-users', 'route_name' => 'admin.system.admin-users', 'component' => 'system/admin-users', 'icon' => 'users-round', 'sort_order' => 70],
['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_role', 'name' => '角色管理', 'path' => '/admin/admin-roles', 'route_name' => 'admin.system.admin-roles', 'component' => 'system/admin-roles', 'icon' => 'shield-check', 'sort_order' => 71], ['parent_code' => null, 'menu_type' => 'page', 'code' => 'system.admin_role', 'name' => '角色管理', 'path' => '/admin/admin-roles', 'route_name' => 'admin.system.admin-roles', 'component' => 'system/admin-roles', 'icon' => 'shield-check', 'sort_order' => 71],
@@ -320,8 +319,6 @@ return new class extends Migration
['menu_code' => 'service.wallet', 'action_code' => 'manage', 'permission_code' => 'service.wallet.manage', 'name' => '钱包流水管理'], ['menu_code' => 'service.wallet', 'action_code' => 'manage', 'permission_code' => 'service.wallet.manage', 'name' => '钱包流水管理'],
['menu_code' => 'service.reconcile', 'action_code' => 'view', 'permission_code' => 'service.reconcile.view', 'name' => '对账查看'], ['menu_code' => 'service.reconcile', 'action_code' => 'view', 'permission_code' => 'service.reconcile.view', 'name' => '对账查看'],
['menu_code' => 'service.reconcile', 'action_code' => 'manage', 'permission_code' => 'service.reconcile.manage', 'name' => '对账管理'], ['menu_code' => 'service.reconcile', 'action_code' => 'manage', 'permission_code' => 'service.reconcile.manage', 'name' => '对账管理'],
['menu_code' => 'service.reports', 'action_code' => 'view', 'permission_code' => 'service.reports.view', 'name' => '报表查看'],
['menu_code' => 'service.reports', 'action_code' => 'export', 'permission_code' => 'service.reports.export', 'name' => '报表导出'],
['menu_code' => 'service.audit', 'action_code' => 'view', 'permission_code' => 'service.audit.view', 'name' => '审计查看'], ['menu_code' => 'service.audit', 'action_code' => 'view', 'permission_code' => 'service.audit.view', 'name' => '审计查看'],
['menu_code' => 'system.admin_user', 'action_code' => 'manage', 'permission_code' => 'system.admin_user.manage', 'name' => '管理员权限管理'], ['menu_code' => 'system.admin_user', 'action_code' => 'manage', 'permission_code' => 'system.admin_user.manage', 'name' => '管理员权限管理'],
['menu_code' => 'system.admin_role', 'action_code' => 'manage', 'permission_code' => 'system.admin_role.manage', 'name' => '角色权限管理'], ['menu_code' => 'system.admin_role', 'action_code' => 'manage', 'permission_code' => 'system.admin_role.manage', 'name' => '角色权限管理'],
@@ -352,8 +349,6 @@ return new class extends Migration
['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.wallet.view', 'service.wallet.manage']], ['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.wallet.view', 'service.wallet.manage']],
['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']], ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.wallet.view']],
['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], ['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']],
['code' => 'admin.reports.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.reports.view', 'service.reports.export']],
['code' => 'admin.reports.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.reports.export']],
['code' => 'admin.reconcile.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.reconcile.view', 'service.reconcile.manage']], ['code' => 'admin.reconcile.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.reconcile.view', 'service.reconcile.manage']],
['code' => 'admin.reconcile.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.reconcile.manage']], ['code' => 'admin.reconcile.store', 'module_code' => 'reconcile', 'name' => '创建对账任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.reconcile.manage']],
['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']], ['code' => 'admin.audit.index', 'module_code' => 'audit', 'name' => '审计日志查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/audit-logs', 'route_name' => 'api.v1.admin.audit-logs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.audit.view']],
@@ -447,10 +442,6 @@ return new class extends Migration
'prd.wallet_reconcile.manage' => ['service.wallet.manage', 'service.reconcile.manage'], 'prd.wallet_reconcile.manage' => ['service.wallet.manage', 'service.reconcile.manage'],
'prd.wallet_reconcile.view' => ['service.wallet.view', 'service.reconcile.view'], 'prd.wallet_reconcile.view' => ['service.wallet.view', 'service.reconcile.view'],
'prd.wallet_reconcile.view_cs' => ['service.wallet.view', 'service.reconcile.view'], 'prd.wallet_reconcile.view_cs' => ['service.wallet.view', 'service.reconcile.view'],
'prd.report.all' => ['service.reports.view', 'service.reports.export'],
'prd.report.risk' => ['service.reports.view'],
'prd.report.finance' => ['service.reports.view', 'service.reports.export'],
'prd.report.player' => ['service.reports.view'],
'prd.audit.all' => ['service.audit.view'], 'prd.audit.all' => ['service.audit.view'],
'prd.audit.self' => ['service.audit.view'], 'prd.audit.self' => ['service.audit.view'],
'prd.audit.finance' => ['service.audit.view'], 'prd.audit.finance' => ['service.audit.view'],
@@ -654,10 +645,6 @@ return new class extends Migration
['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看'], ['slug' => 'prd.wallet_reconcile.view', 'name' => '钱包对账·查看'],
['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户'], ['slug' => 'prd.wallet_reconcile.view_cs', 'name' => '钱包对账·客服单用户'],
['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理'], ['slug' => 'prd.wallet_adjust.manage', 'name' => '补单/冲正·可管理'],
['slug' => 'prd.report.all', 'name' => '报表·全部'],
['slug' => 'prd.report.risk', 'name' => '报表·风控'],
['slug' => 'prd.report.finance', 'name' => '报表·财务'],
['slug' => 'prd.report.player', 'name' => '报表·单用户'],
['slug' => 'prd.audit.all', 'name' => '审计日志·全部'], ['slug' => 'prd.audit.all', 'name' => '审计日志·全部'],
['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'], ['slug' => 'prd.audit.self', 'name' => '审计日志·自身相关'],
['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'],
@@ -688,7 +675,6 @@ return new class extends Migration
'prd.draw_result.manage', 'prd.draw_result.manage',
'prd.payout.review', 'prd.payout.review',
'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view',
'prd.report.risk',
'prd.audit.self', 'prd.audit.self',
'prd.player_freeze.manage', 'prd.player_freeze.manage',
], ],
@@ -701,14 +687,12 @@ return new class extends Migration
'prd.payout.view', 'prd.payout.view',
'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.manage',
'prd.wallet_adjust.manage', 'prd.wallet_adjust.manage',
'prd.report.finance',
'prd.audit.finance', 'prd.audit.finance',
], ],
'customer_service' => [ 'customer_service' => [
'prd.users.view_cs', 'prd.users.view_cs',
'prd.draw_result.view', 'prd.draw_result.view',
'prd.wallet_reconcile.view_cs', 'prd.wallet_reconcile.view_cs',
'prd.report.player',
], ],
]; ];

View File

@@ -0,0 +1,109 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resources = array_values(array_filter(
AdminAuthorizationRegistry::resources(),
static fn (array $resource): bool => str_starts_with((string) $resource['code'], 'admin.currencies.'),
));
foreach ($resources as $resource) {
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->join('admin_api_resources as ar', 'ar.id', '=', 'arb.api_resource_id')
->whereIn('ar.code', array_column($resources, 'code'))
->select('rma.role_id', 'arb.api_resource_id')
->distinct()
->get();
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $row->api_resource_id,
], []);
}
}
public function down(): void
{
$resourceCodes = ['admin.currencies.index', 'admin.currencies.store', 'admin.currencies.update'];
$resourceIds = DB::table('admin_api_resources')
->whereIn('code', $resourceCodes)
->pluck('id')
->all();
if ($resourceIds === []) {
return;
}
DB::table('admin_role_api_resources')
->whereIn('api_resource_id', $resourceIds)
->delete();
DB::table('admin_api_resource_bindings')
->whereIn('api_resource_id', $resourceIds)
->delete();
DB::table('admin_api_resources')
->whereIn('id', $resourceIds)
->delete();
}
};

View File

@@ -0,0 +1,105 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code');
$resource = collect(AdminAuthorizationRegistry::resources())
->first(static fn (array $item): bool => $item['code'] === 'admin.currencies.destroy');
if ($resource === null) {
return;
}
$resourceId = DB::table('admin_api_resources')
->where('code', $resource['code'])
->value('id');
$payload = [
'module_code' => $resource['module_code'],
'name' => $resource['name'],
'http_method' => $resource['http_method'],
'uri_pattern' => $resource['uri_pattern'],
'route_name' => $resource['route_name'],
'auth_mode' => $resource['auth_mode'],
'is_audit_required' => $resource['is_audit_required'],
'status' => 1,
'meta_json' => null,
'updated_at' => $now,
];
if ($resourceId === null) {
$resourceId = DB::table('admin_api_resources')->insertGetId($payload + [
'code' => $resource['code'],
'created_at' => $now,
]);
} else {
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->update($payload);
}
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
foreach ($resource['permission_codes'] as $permissionCode) {
$menuActionId = $menuActionIds[$permissionCode] ?? null;
if ($menuActionId === null) {
continue;
}
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $menuActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->where('arb.api_resource_id', (int) $resourceId)
->select('rma.role_id')
->distinct()
->get();
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $resourceId,
], []);
}
}
public function down(): void
{
$resourceId = DB::table('admin_api_resources')
->where('code', 'admin.currencies.destroy')
->value('id');
if ($resourceId === null) {
return;
}
DB::table('admin_role_api_resources')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resources')
->where('id', (int) $resourceId)
->delete();
}
};

View File

@@ -0,0 +1,143 @@
<?php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
$now = Carbon::now();
$serviceMenuId = (int) DB::table('admin_menus')->where('code', 'service')->value('id');
$manageActionId = (int) DB::table('admin_action_catalog')->where('code', 'manage')->value('id');
if ($serviceMenuId > 0) {
DB::table('admin_menus')->updateOrInsert(
['code' => 'service.currency'],
[
'parent_id' => $serviceMenuId,
'menu_type' => 'page',
'name' => '币种管理',
'path' => '/admin/settings/currencies',
'route_name' => 'admin.settings.currencies',
'component' => 'settings/currencies',
'icon' => null,
'active_menu_code' => null,
'sort_order' => 70,
'is_visible' => false,
'is_cache' => false,
'is_external' => false,
'status' => 1,
'meta_json' => null,
'created_at' => $now,
'updated_at' => $now,
],
);
}
$currencyMenuId = (int) DB::table('admin_menus')->where('code', 'service.currency')->value('id');
if ($currencyMenuId > 0 && $manageActionId > 0) {
DB::table('admin_menu_actions')->updateOrInsert(
['permission_code' => 'service.currency.manage'],
[
'menu_id' => $currencyMenuId,
'action_id' => $manageActionId,
'name' => '币种管理',
'status' => 1,
'created_at' => $now,
'updated_at' => $now,
],
);
}
if (Schema::hasTable('admin_permissions')) {
DB::table('admin_permissions')->updateOrInsert(
['slug' => 'prd.currency.manage'],
[
'name' => '币种管理·可管理',
'updated_at' => $now,
'created_at' => $now,
],
);
}
$currencyActionId = DB::table('admin_menu_actions')
->where('permission_code', 'service.currency.manage')
->value('id');
if ($currencyActionId === null) {
return;
}
$roleIds = DB::table('admin_role_legacy_permissions')
->where('permission_slug', 'prd.users.manage')
->pluck('role_id')
->all();
foreach ($roleIds as $roleId) {
DB::table('admin_role_legacy_permissions')->updateOrInsert(
[
'role_id' => (int) $roleId,
'permission_slug' => 'prd.currency.manage',
],
[
'created_at' => $now,
'updated_at' => $now,
],
);
DB::table('admin_role_menu_actions')->updateOrInsert(
[
'role_id' => (int) $roleId,
'menu_action_id' => (int) $currencyActionId,
],
[],
);
}
$currencyResourceIds = DB::table('admin_api_resources')
->whereIn('code', [
'admin.currencies.index',
'admin.currencies.store',
'admin.currencies.update',
'admin.currencies.destroy',
])
->pluck('id')
->all();
foreach ($currencyResourceIds as $resourceId) {
DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->delete();
DB::table('admin_api_resource_bindings')->insert([
'api_resource_id' => (int) $resourceId,
'menu_action_id' => (int) $currencyActionId,
'created_at' => $now,
'updated_at' => $now,
]);
}
$roleResourceRows = DB::table('admin_role_menu_actions as rma')
->join('admin_api_resource_bindings as arb', 'arb.menu_action_id', '=', 'rma.menu_action_id')
->whereIn('arb.api_resource_id', $currencyResourceIds)
->select('rma.role_id', 'arb.api_resource_id')
->distinct()
->get();
foreach ($roleResourceRows as $row) {
DB::table('admin_role_api_resources')->updateOrInsert([
'role_id' => (int) $row->role_id,
'api_resource_id' => (int) $row->api_resource_id,
], []);
}
}
public function down(): void
{
// 不自动回滚线上角色与资源绑定,避免误删已调整的授权。
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
DB::table('admin_menus')
->where('code', 'service.currency')
->update([
'path' => '/admin/currencies',
'route_name' => 'admin.currencies',
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('admin_menus')
->where('code', 'service.currency')
->update([
'path' => '/admin/settings/currencies',
'route_name' => 'admin.settings.currencies',
'updated_at' => now(),
]);
}
};

View File

@@ -66,7 +66,6 @@ final class AdminRbacAndUserSeeder extends Seeder
'prd.draw_result.manage', 'prd.draw_result.manage',
'prd.payout.review', 'prd.payout.review',
'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view',
'prd.report.risk',
'prd.audit.self', 'prd.audit.self',
'prd.player_freeze.manage', 'prd.player_freeze.manage',
]); ]);
@@ -84,7 +83,6 @@ final class AdminRbacAndUserSeeder extends Seeder
'prd.payout.view', 'prd.payout.view',
'prd.wallet_reconcile.manage', 'prd.wallet_reconcile.manage',
'prd.wallet_adjust.manage', 'prd.wallet_adjust.manage',
'prd.report.finance',
'prd.audit.finance', 'prd.audit.finance',
]); ]);
@@ -96,7 +94,6 @@ final class AdminRbacAndUserSeeder extends Seeder
'prd.users.view_cs', 'prd.users.view_cs',
'prd.draw_result.view', 'prd.draw_result.view',
'prd.wallet_reconcile.view_cs', 'prd.wallet_reconcile.view_cs',
'prd.report.player',
]); ]);
$username = 'admin'; $username = 'admin';

View File

@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite" "dev": "vite --host \"${VITE_HOST:-0.0.0.0}\""
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",

View File

@@ -26,11 +26,11 @@ Route::prefix('v1')->group(function (): void {
require __DIR__.'/api/v1/admin/core.php'; require __DIR__.'/api/v1/admin/core.php';
require __DIR__.'/api/v1/admin/wallet.php'; require __DIR__.'/api/v1/admin/wallet.php';
require __DIR__.'/api/v1/admin/player.php'; require __DIR__.'/api/v1/admin/player.php';
require __DIR__.'/api/v1/admin/currency.php';
require __DIR__.'/api/v1/admin/ticket.php'; require __DIR__.'/api/v1/admin/ticket.php';
require __DIR__.'/api/v1/admin/draw.php'; require __DIR__.'/api/v1/admin/draw.php';
require __DIR__.'/api/v1/admin/jackpot.php'; require __DIR__.'/api/v1/admin/jackpot.php';
require __DIR__.'/api/v1/admin/config.php'; require __DIR__.'/api/v1/admin/config.php';
require __DIR__.'/api/v1/admin/report.php';
require __DIR__.'/api/v1/admin/user.php'; require __DIR__.'/api/v1/admin/user.php';
}); });
}); });

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Currency\AdminCurrencyIndexController;
use App\Http\Controllers\Api\V1\Admin\Currency\AdminCurrencyStoreController;
use App\Http\Controllers\Api\V1\Admin\Currency\AdminCurrencyUpdateController;
use App\Http\Controllers\Api\V1\Admin\Currency\AdminCurrencyDestroyController;
/**
* 管理员币种主数据路由。
*/
Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('currencies', AdminCurrencyIndexController::class)
->name('api.v1.admin.currencies.index');
Route::post('currencies', AdminCurrencyStoreController::class)
->name('api.v1.admin.currencies.store');
Route::put('currencies/{currency:code}', AdminCurrencyUpdateController::class)
->name('api.v1.admin.currencies.update');
Route::delete('currencies/{currency:code}', AdminCurrencyDestroyController::class)
->name('api.v1.admin.currencies.destroy');
});

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobDownloadController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobShowController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobIndexController;
use App\Http\Controllers\Api\V1\Admin\Reports\ReportJobStoreController;
/**
* 管理员报表路由。
*/
Route::middleware('admin.api-resource')
->group(function (): void {
Route::get('report-jobs', ReportJobIndexController::class)
->name('api.v1.admin.report-jobs.index');
Route::post('report-jobs', ReportJobStoreController::class)
->name('api.v1.admin.report-jobs.store');
Route::get('report-jobs/{report_job}', ReportJobShowController::class)
->name('api.v1.admin.report-jobs.show');
Route::get('report-jobs/{report_job}/download', ReportJobDownloadController::class)
->name('api.v1.admin.report-jobs.download');
});

View File

@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\Draw\DrawCurrentController; use App\Http\Controllers\Api\V1\Draw\DrawCurrentController;
use App\Http\Controllers\Api\V1\Draw\DrawResultShowController; use App\Http\Controllers\Api\V1\Draw\DrawResultShowController;
use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController; use App\Http\Controllers\Api\V1\Draw\DrawResultsIndexController;
use App\Http\Controllers\Api\V1\Currency\CurrencyIndexController;
use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController; use App\Http\Controllers\Api\V1\Jackpot\JackpotSummaryController;
use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController; use App\Http\Controllers\Api\V1\Play\PlayEffectiveCatalogController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
@@ -16,6 +17,9 @@ use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
// 健康检查 // 健康检查
Route::get('health', HealthController::class)->name('api.v1.health'); Route::get('health', HealthController::class)->name('api.v1.health');
// 币种主数据(公开,只给玩家端展示/金额精度使用)
Route::get('currencies', CurrencyIndexController::class)->name('api.v1.currencies.index');
// 开奖相关(公开) // 开奖相关(公开)
Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current'); Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current');
Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results'); Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results');

View File

@@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\DB;
use Database\Seeders\AdminRbacAndUserSeeder; use Database\Seeders\AdminRbacAndUserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@@ -23,6 +23,33 @@ test('admin authorization audit passes on the default authorization catalog', fu
->assertExitCode(0); ->assertExitCode(0);
}); });
test('admin authorization sync can repair registry-backed api resources and pass audit', function (): void {
DB::table('admin_api_resources')
->where('code', 'admin.currencies.destroy')
->delete();
$this->artisan('lottery:admin-auth-audit')
->expectsOutputToContain('admin.currencies.destroy')
->assertExitCode(1);
$this->artisan('lottery:admin-auth-sync --audit')
->expectsOutputToContain('Admin authorization synced')
->expectsOutputToContain('Admin authorization audit passed.')
->assertExitCode(0);
$resourceId = DB::table('admin_api_resources')
->where('code', 'admin.currencies.destroy')
->value('id');
expect($resourceId)->not->toBeNull();
$bindingCount = DB::table('admin_api_resource_bindings')
->where('api_resource_id', (int) $resourceId)
->count();
expect($bindingCount)->toBeGreaterThan(0);
});
test('admin authorization audit detects role api resource drift', function (): void { test('admin authorization audit detects role api resource drift', function (): void {
$this->seed(AdminRbacAndUserSeeder::class); $this->seed(AdminRbacAndUserSeeder::class);

View File

@@ -0,0 +1,169 @@
<?php
use App\Models\Currency;
use App\Models\OddsItem;
use App\Models\PlayType;
use App\Models\AdminUser;
use App\Models\JackpotPool;
use App\Models\OddsVersion;
use App\Lottery\ConfigVersionStatus;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
function mintCurrencyAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'currency_admin',
'name' => 'Currency Admin',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('admin can list currencies', function (): void {
$token = mintCurrencyAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/currencies')
->assertOk()
->assertJsonPath('data.items.0.code', 'NPR');
});
test('admin can create currency and normalize code', function (): void {
$token = mintCurrencyAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/currencies', [
'code' => 'eur',
'name' => 'Euro',
'decimal_places' => 2,
'is_enabled' => true,
'is_bettable' => true,
])
->assertStatus(201)
->assertJsonPath('data.code', 'EUR')
->assertJsonPath('data.is_bettable', true);
$this->assertDatabaseHas('currencies', [
'code' => 'EUR',
'name' => 'Euro',
'is_enabled' => true,
'is_bettable' => true,
]);
});
test('disabling currency forces bettable off', function (): void {
$token = mintCurrencyAdminToken();
$currency = Currency::query()->where('code', 'USD')->firstOrFail();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/currencies/'.$currency->code, [
'is_enabled' => false,
'is_bettable' => true,
])
->assertOk()
->assertJsonPath('data.is_enabled', false)
->assertJsonPath('data.is_bettable', false);
$currency->refresh();
expect($currency->is_enabled)->toBeFalse()
->and($currency->is_bettable)->toBeFalse();
});
test('enabling currency as bettable bootstraps odds items and jackpot pool', function (): void {
$token = mintCurrencyAdminToken();
$currency = Currency::query()->where('code', 'USD')->firstOrFail();
PlayType::query()->create([
'play_code' => 'STRAIGHT',
'category' => '4d',
'dimension' => 4,
'bet_mode' => 'single',
'display_name_zh' => '直选',
'display_name_en' => 'Straight',
'display_name_ne' => 'सिधा',
'is_enabled' => true,
'sort_order' => 10,
'supports_multi_number' => false,
'reserved_rule_json' => null,
]);
$version = OddsVersion::query()->create([
'version_no' => 1,
'status' => ConfigVersionStatus::Active->value,
'updated_by' => null,
'reason' => 'seed',
]);
OddsItem::query()->create([
'version_id' => $version->id,
'play_code' => 'STRAIGHT',
'prize_scope' => 'first',
'odds_value' => 250000,
'rebate_rate' => 0.05,
'commission_rate' => 0.01,
'currency_code' => 'NPR',
'extra_config_json' => ['sample' => true],
]);
JackpotPool::query()->updateOrCreate([
'currency_code' => 'NPR',
], [
'current_amount' => 12345,
'contribution_rate' => '0.0300',
'trigger_threshold' => 200000000,
'payout_rate' => '0.4000',
'force_trigger_draw_gap' => 88,
'min_bet_amount' => 200,
'combo_trigger_play_codes' => ['STRAIGHT'],
'status' => 1,
'last_trigger_draw_id' => null,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/currencies/'.$currency->code, [
'is_bettable' => true,
])
->assertOk()
->assertJsonPath('data.is_bettable', true);
$currency->refresh();
expect($currency->is_bettable)->toBeTrue();
$this->assertDatabaseHas('jackpot_pools', [
'currency_code' => 'USD',
'current_amount' => 0,
'contribution_rate' => '0.0300',
'trigger_threshold' => 200000000,
'payout_rate' => '0.4000',
'force_trigger_draw_gap' => 88,
'min_bet_amount' => 200,
'status' => 1,
]);
$this->assertDatabaseHas('odds_items', [
'version_id' => $version->id,
'play_code' => 'STRAIGHT',
'prize_scope' => 'first',
'currency_code' => 'USD',
'odds_value' => 250000,
]);
$this->assertDatabaseHas('odds_items', [
'version_id' => $version->id,
'play_code' => 'STRAIGHT',
'prize_scope' => 'second',
'currency_code' => 'USD',
]);
});

View File

@@ -1,16 +1,20 @@
<?php <?php
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player; use App\Models\Player;
use App\Models\PlayerWallet; use App\Models\AuditLog;
use App\Models\AdminRole; use App\Models\AdminRole;
use App\Services\AuditLogger; use App\Models\AdminUser;
use App\Models\PlayerWallet;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
function playerManageAdminToken(): string function playerManageAdminToken(): string
{ {
$admin = AdminUser::query()->create([ $admin = AdminUser::query()->create([
@@ -180,3 +184,34 @@ test('player manage permission gates write and freeze APIs separately from view
->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items') ->getJson('/api/v1/admin/players/'.$player->id.'/ticket-items')
->assertOk(); ->assertOk();
}); });
test('admin can update player default currency and validation rejects unknown code', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'currency-1',
'username' => 'currency_user',
'nickname' => 'Currency',
'default_currency' => 'NPR',
'status' => 0,
]);
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'default_currency' => 'usd',
])
->assertOk()
->assertJsonPath('data.default_currency', 'USD');
$this->assertDatabaseHas('players', [
'id' => $player->id,
'default_currency' => 'USD',
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'default_currency' => 'ABC',
])
->assertStatus(422);
});

View File

@@ -150,6 +150,7 @@ test('permission catalog groups permissions by admin navigation order', function
'admin_users', 'admin_users',
'admin_roles', 'admin_roles',
'players', 'players',
'currencies',
'wallet', 'wallet',
'draws', 'draws',
'config', 'config',

View File

@@ -0,0 +1,41 @@
<?php
use App\Models\Currency;
use App\Lottery\ErrorCode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('public currency index returns enabled currencies with decimal places', function () {
Currency::query()->create([
'code' => 'JPY',
'name' => 'Japanese Yen',
'decimal_places' => 0,
'is_enabled' => true,
'is_bettable' => false,
]);
Currency::query()->create([
'code' => 'TEST',
'name' => 'Disabled Currency',
'decimal_places' => 4,
'is_enabled' => false,
'is_bettable' => false,
]);
$response = $this->getJson('/api/v1/currencies');
$response->assertOk()
->assertJsonPath('code', ErrorCode::Success->value)
->assertJsonCount(3, 'data.items')
->assertJsonPath('data.items.0.code', 'NPR')
->assertJsonPath('data.items.0.decimal_places', 2)
->assertJsonPath('data.items.1.code', 'JPY')
->assertJsonPath('data.items.1.decimal_places', 0)
->assertJsonPath('data.items.2.code', 'USD');
});

View File

@@ -3,10 +3,15 @@
use App\Models\Player; use App\Models\Player;
use App\Lottery\ErrorCode; use App\Lottery\ErrorCode;
use App\Models\PlayerWallet; use App\Models\PlayerWallet;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('wallet balance creates lottery wallet row and returns zeros', function () { test('wallet balance creates lottery wallet row and returns zeros', function () {
$player = Player::query()->create([ $player = Player::query()->create([
'site_code' => 'test', 'site_code' => 'test',
@@ -49,3 +54,19 @@ test('wallet balance rejects illegal currency query', function () {
->assertStatus(400) ->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletInvalidCurrency->value); ->assertJsonPath('code', ErrorCode::WalletInvalidCurrency->value);
}); });
test('wallet balance rejects currency that is not configured or disabled', function () {
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'p3',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/balance?currency=ABC')
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletInvalidCurrency->value);
});

View File

@@ -17,6 +17,7 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
], ],
server: { server: {
host: process.env.VITE_HOST ?? '0.0.0.0',
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],
}, },