diff --git a/.env.example b/.env.example index a4ecfad..3329438 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ APP_KEY= APP_DEBUG=true # 应用根 URL(生成链接、邮件、部分驱动依赖;与 php artisan serve 端口一致时带上 :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) @@ -103,6 +107,8 @@ BROADCAST_CONNECTION=reverb REVERB_APP_ID= REVERB_APP_KEY= REVERB_APP_SECRET= +# Reverb 服务监听地址:本机直连可用 127.0.0.1;需要局域网访问时改为 0.0.0.0 +REVERB_SERVER_HOST=0.0.0.0 REVERB_HOST=localhost REVERB_PORT=8080 REVERB_SCHEME=http @@ -225,4 +231,6 @@ DEV_SEED_WALLET_BALANCE_MINOR=125000 DEV_SEED_WALLET_FROZEN_MINOR=0 # 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= diff --git a/README.md b/README.md index 54a3687..cd0e73d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,23 @@ php artisan schedule:work 只做 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 的体检命令,用来检查: @@ -83,6 +100,21 @@ php artisan schedule:work 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 脚本执行: ```bash diff --git a/app/Console/Commands/SyncAdminAuthorizationCommand.php b/app/Console/Commands/SyncAdminAuthorizationCommand.php index 633c884..290091d 100644 --- a/app/Console/Commands/SyncAdminAuthorizationCommand.php +++ b/app/Console/Commands/SyncAdminAuthorizationCommand.php @@ -2,14 +2,15 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; use Illuminate\Support\Carbon; +use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use App\Support\AdminAuthorizationRegistry; 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'; @@ -55,6 +56,7 @@ final class SyncAdminAuthorizationCommand extends Command $menuActionId = $menuActionIds[$permissionCode] ?? null; if ($menuActionId === null) { $this->warn(sprintf('跳过未找到的 permission_code: %s', $permissionCode)); + continue; } @@ -88,6 +90,10 @@ final class SyncAdminAuthorizationCommand extends Command $roleResourceRows->count(), )); + if ((bool) $this->option('audit')) { + return $this->call('lottery:admin-auth-audit'); + } + return self::SUCCESS; } } diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyDestroyController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyDestroyController.php new file mode 100644 index 0000000..a382236 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyDestroyController.php @@ -0,0 +1,69 @@ +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 */ + 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; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyIndexController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyIndexController.php new file mode 100644 index 0000000..29970f9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyIndexController.php @@ -0,0 +1,39 @@ +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 */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php new file mode 100644 index 0000000..9094283 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php @@ -0,0 +1,54 @@ +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 */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php new file mode 100644 index 0000000..3ffc8eb --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php @@ -0,0 +1,62 @@ +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 */ + 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(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 7be835d..f20ebfa 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -6,8 +6,8 @@ use App\Models\Player; use App\Lottery\ErrorCode; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; -use App\Http\Controllers\Controller; use App\Support\PlayerApiPresenter; +use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AdminPlayerStoreRequest; /** POST /api/v1/admin/players */ @@ -38,6 +38,6 @@ final class AdminPlayerStoreController extends Controller 'status' => $request->validated('status', 0), ]); - return ApiResponse::success(PlayerApiPresenter::listItem($player), 201); + return ApiResponse::success(PlayerApiPresenter::listItem($player))->setStatusCode(201); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php index 780e93e..5928ea5 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerTicketItemsIndexController.php @@ -22,7 +22,7 @@ final class AdminPlayerTicketItemsIndexController extends Controller 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); $drawNo = $request->validated('draw_no'); if (is_string($drawNo)) { diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php deleted file mode 100644 index 765846c..0000000 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php +++ /dev/null @@ -1,36 +0,0 @@ -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']); - } -} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php deleted file mode 100644 index 18b997d..0000000 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobIndexController.php +++ /dev/null @@ -1,42 +0,0 @@ -orderByDesc('id') - ->paginate($p['perPage'], ['*'], 'page', $p['page']); - - return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j)); - } - - /** @return array */ - 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(), - ]; - } -} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php deleted file mode 100644 index 87ec83f..0000000 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobShowController.php +++ /dev/null @@ -1,29 +0,0 @@ - (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(), - ]); - } -} diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php deleted file mode 100644 index 03f45d5..0000000 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php +++ /dev/null @@ -1,39 +0,0 @@ -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, - ]); - } -} diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php index 0e8bcf2..644aa04 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolIndexController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; use App\Models\Draw; use App\Models\RiskPool; +use App\Models\TicketOrder; use Illuminate\Http\Request; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; @@ -49,9 +50,14 @@ final class AdminRiskPoolIndexController extends Controller /** @var LengthAwarePaginator $paginator */ $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), [ 'draw_id' => (int) $draw->id, 'draw_no' => $draw->draw_no, + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, ]); } diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php index 9fa36d4..b35af32 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolLockLogIndexController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; use App\Models\Draw; +use App\Models\TicketOrder; use Illuminate\Http\Request; use App\Support\AdminApiList; use App\Models\RiskPoolLockLog; @@ -38,9 +39,14 @@ final class AdminRiskPoolLockLogIndexController extends Controller /** @var LengthAwarePaginator $paginator */ $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), [ 'draw_id' => (int) $draw->id, 'draw_no' => $draw->draw_no, + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, ]); } diff --git a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php index 2b68a2e..387cfc6 100644 --- a/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Risk/AdminRiskPoolShowController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Risk; use App\Models\Draw; use App\Models\RiskPool; use App\Lottery\ErrorCode; +use App\Models\TicketOrder; use App\Support\ApiResponse; use Illuminate\Http\Request; use App\Support\AdminApiList; @@ -56,10 +57,14 @@ final class AdminRiskPoolShowController extends Controller $cap = (int) $pool->total_cap_amount; $locked = (int) $pool->locked_amount; + $currencyCode = (string) (TicketOrder::query() + ->where('draw_id', $draw->id) + ->value('currency_code') ?? ''); return ApiResponse::success([ 'draw_id' => (int) $draw->id, 'draw_no' => $draw->draw_no, + 'currency_code' => $currencyCode !== '' ? $currencyCode : null, 'pool' => [ 'normalized_number' => $pool->normalized_number, 'total_cap_amount' => $cap, diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php index 411768f..8a58fd2 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchDetailsController.php @@ -23,6 +23,7 @@ final class AdminSettlementBatchDetailsController extends Controller ->with([ 'ticketItem:id,ticket_no,play_code,player_id', 'ticketItem.player:id,username,site_player_id', + 'ticketItem.order:id,currency_code', ]) ->orderBy('id') ->paginate($p['perPage'], ['*'], 'page', $p['page']); @@ -31,12 +32,14 @@ final class AdminSettlementBatchDetailsController extends Controller /** @var TicketSettlementDetail $row */ $item = $row->ticketItem; $player = $item?->player; + $order = $item?->order; return [ 'id' => (int) $row->id, 'ticket_item_id' => (int) $row->ticket_item_id, 'ticket_no' => $item?->ticket_no, 'play_code' => $item?->play_code, + 'currency_code' => $order?->currency_code, 'player_id' => $item?->player_id, 'player_username' => $player?->username, 'site_player_id' => $player?->site_player_id, diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php index 9e191a3..30db5e6 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchIndexController.php @@ -46,6 +46,7 @@ final class AdminSettlementBatchIndexController extends Controller 'id' => (int) $b->id, 'draw_id' => (int) $b->draw_id, 'draw_no' => $b->draw?->draw_no, + 'currency_code' => $financial['currency_code'], 'result_batch_id' => (int) $b->result_batch_id, 'settle_version' => (int) $b->settle_version, 'status' => $b->status, diff --git a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php index d7b0aa4..cd070e2 100644 --- a/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/Settlement/AdminSettlementBatchShowController.php @@ -22,6 +22,7 @@ final class AdminSettlementBatchShowController extends Controller 'id' => (int) $batch->id, 'draw_id' => (int) $batch->draw_id, 'draw_no' => $batch->draw?->draw_no, + 'currency_code' => $financial['currency_code'], 'draw_status' => $batch->draw?->status, 'result_batch_id' => (int) $batch->result_batch_id, 'result_batch_version' => $batch->resultBatch?->result_version, diff --git a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php index 68dae3d..993aaca 100644 --- a/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Ticket/AdminTicketItemIndexController.php @@ -30,7 +30,7 @@ final class AdminTicketItemIndexController extends Controller { $validated = $request->validated(); - $perPage = $this->perPage($request, 'per_page', 20, 100); + $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); $query = TicketItem::query() diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 1675faa..8aa5775 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -16,7 +16,7 @@ use App\Http\Requests\Admin\TransferOrderListRequest; * * Query: * - `page`(默认 1) - * - `per_page` 或 `size`(每页条数,默认 20,最大 100) + * - `per_page` 或 `size`(每页条数,默认 10,最大 100) * - `player_id`(可选,按玩家主键) * - `player_account`(可选,模糊匹配 `players.site_player_id` / `username`;与 `player_id` 同时传时以 `player_id` 为准) * - `transfer_no`(可选,模糊匹配本地单号) @@ -35,7 +35,7 @@ final class TransferOrderListController extends Controller { $validated = $request->validated(); - $perPage = $this->perPage($request, 'per_page', 20, 100); + $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); $query = TransferOrder::query() diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php index 9fbb258..27832e9 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/WalletTransactionListController.php @@ -35,7 +35,7 @@ final class WalletTransactionListController extends Controller { $validated = $request->validated(); - $perPage = $this->perPage($request, 'per_page', 20, 100); + $perPage = $this->perPage($request, 'per_page', 10, 100); $page = $this->page($request); $query = WalletTxn::query() diff --git a/app/Http/Controllers/Api/V1/Currency/CurrencyIndexController.php b/app/Http/Controllers/Api/V1/Currency/CurrencyIndexController.php new file mode 100644 index 0000000..a9492f5 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Currency/CurrencyIndexController.php @@ -0,0 +1,29 @@ +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]); + } +} diff --git a/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php index b7f03e0..f4cafdd 100644 --- a/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php +++ b/app/Http/Controllers/Api/V1/Jackpot/JackpotSummaryController.php @@ -19,6 +19,11 @@ final class JackpotSummaryController extends Controller 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)); } } diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index a9ad0ea..e453f26 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -24,7 +24,7 @@ final class TicketItemsIndexController extends Controller /** @var Player $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); $drawNo = $request->query('draw_no'); $statusInput = $request->query('status', []); diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php index a965830..85a1f03 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php @@ -77,7 +77,7 @@ final class WalletBalanceController extends Controller { $code = CurrencyResolver::resolve($request, $player, 'currency'); - if (! CurrencyResolver::isValid($code)) { + if (! CurrencyResolver::isEnabled($code)) { return ApiResponse::error( __('wallet.invalid_currency'), ErrorCode::WalletInvalidCurrency->value, diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php b/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php index e896f45..dfdcaa0 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletTransferInController.php @@ -57,7 +57,7 @@ final class WalletTransferInController extends Controller { $code = CurrencyResolver::resolve($request, $player, 'currency'); - if (! CurrencyResolver::isValid($code)) { + if (! CurrencyResolver::isEnabled($code)) { return ApiResponse::error( __('wallet.invalid_currency'), ErrorCode::WalletInvalidCurrency->value, diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php b/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php index 07c3ee1..406153d 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletTransferOutController.php @@ -57,7 +57,7 @@ final class WalletTransferOutController extends Controller { $code = CurrencyResolver::resolve($request, $player, 'currency'); - if (! CurrencyResolver::isValid($code)) { + if (! CurrencyResolver::isEnabled($code)) { return ApiResponse::error( __('wallet.invalid_currency'), ErrorCode::WalletInvalidCurrency->value, diff --git a/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php b/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php new file mode 100644 index 0000000..d01a9c1 --- /dev/null +++ b/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php @@ -0,0 +1,34 @@ + ['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)), + ]); + } + } +} diff --git a/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php b/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php new file mode 100644 index 0000000..a82a085 --- /dev/null +++ b/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php @@ -0,0 +1,23 @@ + ['sometimes', 'string', 'max:64'], + 'decimal_places' => ['sometimes', 'integer', 'min:0', 'max:12'], + 'is_enabled' => ['sometimes', 'boolean'], + 'is_bettable' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php index 63126f9..490413b 100644 --- a/app/Http/Requests/Admin/AdminPlayerStoreRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerStoreRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Admin; +use Illuminate\Validation\Rule; use Illuminate\Foundation\Http\FormRequest; /** @@ -23,8 +24,18 @@ final class AdminPlayerStoreRequest extends FormRequest 'site_player_id' => ['required', 'string', 'max:128'], 'username' => ['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'], ]; } + + 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]); + } } diff --git a/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php b/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php index 0df804f..346b65c 100644 --- a/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminPlayerUpdateRequest.php @@ -2,8 +2,8 @@ namespace App\Http\Requests\Admin; -use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; +use Illuminate\Foundation\Http\FormRequest; /** * 玩家更新请求。 @@ -22,7 +22,18 @@ final class AdminPlayerUpdateRequest extends FormRequest return [ 'username' => ['sometimes', '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])], ]; } + + 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]); + } } diff --git a/app/Http/Requests/Admin/ReportJobStoreRequest.php b/app/Http/Requests/Admin/ReportJobStoreRequest.php deleted file mode 100644 index 0ba2a02..0000000 --- a/app/Http/Requests/Admin/ReportJobStoreRequest.php +++ /dev/null @@ -1,56 +0,0 @@ -> - */ - 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 - */ - 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', - ]; - } -} diff --git a/app/Models/ReportJob.php b/app/Models/ReportJob.php deleted file mode 100644 index 2314812..0000000 --- a/app/Models/ReportJob.php +++ /dev/null @@ -1,37 +0,0 @@ - 'array', - 'finished_at' => 'datetime', - ]; - } - - /** @return BelongsTo */ - public function adminUser(): BelongsTo - { - return $this->belongsTo(AdminUser::class, 'admin_user_id'); - } -} diff --git a/app/Services/Admin/AdminReportJobService.php b/app/Services/Admin/AdminReportJobService.php deleted file mode 100644 index 11dbdc8..0000000 --- a/app/Services/Admin/AdminReportJobService.php +++ /dev/null @@ -1,116 +0,0 @@ -|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> - */ - 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 - */ - 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 []; - } -} diff --git a/app/Services/Currency/CurrencyActivationService.php b/app/Services/Currency/CurrencyActivationService.php new file mode 100644 index 0000000..dcba5e2 --- /dev/null +++ b/app/Services/Currency/CurrencyActivationService.php @@ -0,0 +1,138 @@ +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, + ], + ); + } + } + } +} diff --git a/app/Support/AdminApiList.php b/app/Support/AdminApiList.php index 0c019d1..6f31033 100644 --- a/app/Support/AdminApiList.php +++ b/app/Support/AdminApiList.php @@ -12,7 +12,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; */ final class AdminApiList { - public const DEFAULT_PER_PAGE = 25; + public const DEFAULT_PER_PAGE = 10; public const MAX_PER_PAGE = 100; diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 1bb4bca..9a204be 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -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_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.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.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.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.self', '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_roles' => '角色管理', 'players' => '玩家列表', + 'currencies' => '币种管理', 'wallet' => '钱包流水', 'draws' => '期号列表', 'config' => '运营配置', @@ -82,7 +79,6 @@ final class AdminAuthorizationRegistry 'jackpot' => 'Jackpot', 'reconcile' => '对账', 'tickets' => '玩家注单', - 'reports' => '报表导出', 'audit' => '审计日志', 'settings' => '系统设置', ]; @@ -113,17 +109,17 @@ final class AdminAuthorizationRegistry ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin'], ['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' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs']], - ['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' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view']], + ['segment' => 'players', 'label' => 'Players', 'href' => '/admin/players', 'requiredAny' => ['prd.users.manage', 'prd.users.view_finance', 'prd.users.view_cs', 'prd.player_freeze.manage']], + ['segment' => 'currencies', 'label' => 'Currencies', 'href' => '/admin/currencies', 'requiredAny' => ['prd.currency.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.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' => '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' => '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' => 'reports', 'label' => 'Reports', 'href' => '/admin/reports', 'requiredAny' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', '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' => '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_roles' => ['prd.admin_role.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'], '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'], 'risk' => ['prd.draw_result.manage', 'prd.draw_result.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'], - 'tickets' => ['prd.users.view_cs', 'prd.users.manage', 'prd.report.player'], - 'reports' => ['prd.report.all', 'prd.report.risk', 'prd.report.finance', 'prd.report.player'], + 'tickets' => ['prd.users.view_cs', 'prd.users.manage'], 'audit' => ['prd.audit.all', 'prd.audit.self', 'prd.audit.finance'], + 'settings' => [], ]; 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.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.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.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.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']], ]; } } diff --git a/app/Support/CurrencyResolver.php b/app/Support/CurrencyResolver.php index e4657a4..1c018f7 100644 --- a/app/Support/CurrencyResolver.php +++ b/app/Support/CurrencyResolver.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Support; use App\Models\Player; +use App\Models\Currency; use Illuminate\Http\Request; /** @@ -52,6 +53,27 @@ final class CurrencyResolver 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。 */ diff --git a/app/Support/PaginationTrait.php b/app/Support/PaginationTrait.php index 2e6c267..a577fd0 100644 --- a/app/Support/PaginationTrait.php +++ b/app/Support/PaginationTrait.php @@ -21,7 +21,7 @@ trait PaginationTrait * @param int $default 默认值 * @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)); @@ -41,7 +41,7 @@ trait PaginationTrait * * @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 [ 'page' => $this->page($request), diff --git a/app/Support/SettlementBatchFinancialSummary.php b/app/Support/SettlementBatchFinancialSummary.php index 4d5a4c0..5cc45bb 100644 --- a/app/Support/SettlementBatchFinancialSummary.php +++ b/app/Support/SettlementBatchFinancialSummary.php @@ -7,14 +7,16 @@ use App\Models\SettlementBatch; 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 { $totals = $batch->details() ->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.actual_deduct_amount), 0) as total_actual_deduct') + ->selectRaw('MIN(ticket_orders.currency_code) as currency_code') ->first(); $totalBet = (int) ($totals?->total_bet_amount ?? 0); @@ -25,6 +27,9 @@ final class SettlementBatchFinancialSummary 'total_bet_amount' => $totalBet, 'total_actual_deduct' => $totalActualDeduct, 'platform_profit' => $totalActualDeduct - $totalPayout, + 'currency_code' => is_string($totals?->currency_code) && $totals->currency_code !== '' + ? $totals->currency_code + : null, ]; } } diff --git a/composer.json b/composer.json index 155201e..1112585 100644 --- a/composer.json +++ b/composer.json @@ -49,11 +49,11 @@ ], "dev": [ "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": [ "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": [ "Composer\\Config::disableProcessTimeout", diff --git a/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php b/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php index dd1fff9..36c47f6 100644 --- a/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php +++ b/database/migrations/2026_05_13_100000_rebuild_admin_authorization_system.php @@ -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.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.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' => 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], @@ -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.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.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' => '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' => '角色权限管理'], @@ -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.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.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.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']], @@ -447,10 +442,6 @@ return new class extends Migration 'prd.wallet_reconcile.manage' => ['service.wallet.manage', 'service.reconcile.manage'], 'prd.wallet_reconcile.view' => ['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.self' => ['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_cs', '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.self', 'name' => '审计日志·自身相关'], ['slug' => 'prd.audit.finance', 'name' => '审计日志·资金相关'], @@ -688,7 +675,6 @@ return new class extends Migration 'prd.draw_result.manage', 'prd.payout.review', 'prd.wallet_reconcile.view', - 'prd.report.risk', 'prd.audit.self', 'prd.player_freeze.manage', ], @@ -701,14 +687,12 @@ return new class extends Migration 'prd.payout.view', 'prd.wallet_reconcile.manage', 'prd.wallet_adjust.manage', - 'prd.report.finance', 'prd.audit.finance', ], 'customer_service' => [ 'prd.users.view_cs', 'prd.draw_result.view', 'prd.wallet_reconcile.view_cs', - 'prd.report.player', ], ]; diff --git a/database/migrations/2026_05_21_000002_add_admin_currency_api_resources.php b/database/migrations/2026_05_21_000002_add_admin_currency_api_resources.php new file mode 100644 index 0000000..e6f01da --- /dev/null +++ b/database/migrations/2026_05_21_000002_add_admin_currency_api_resources.php @@ -0,0 +1,109 @@ +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(); + } +}; diff --git a/database/migrations/2026_05_21_150000_add_admin_currency_destroy_api_resource.php b/database/migrations/2026_05_21_150000_add_admin_currency_destroy_api_resource.php new file mode 100644 index 0000000..654a433 --- /dev/null +++ b/database/migrations/2026_05_21_150000_add_admin_currency_destroy_api_resource.php @@ -0,0 +1,105 @@ +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(); + } +}; diff --git a/database/migrations/2026_05_21_160000_add_currency_manage_legacy_permission.php b/database/migrations/2026_05_21_160000_add_currency_manage_legacy_permission.php new file mode 100644 index 0000000..613f6a5 --- /dev/null +++ b/database/migrations/2026_05_21_160000_add_currency_manage_legacy_permission.php @@ -0,0 +1,143 @@ +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 + { + // 不自动回滚线上角色与资源绑定,避免误删已调整的授权。 + } +}; diff --git a/database/migrations/2026_05_21_170000_move_currency_menu_to_top_level_route.php b/database/migrations/2026_05_21_170000_move_currency_menu_to_top_level_route.php new file mode 100644 index 0000000..e196fa6 --- /dev/null +++ b/database/migrations/2026_05_21_170000_move_currency_menu_to_top_level_route.php @@ -0,0 +1,29 @@ +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(), + ]); + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index aa3e7ac..1710c64 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -66,7 +66,6 @@ final class AdminRbacAndUserSeeder extends Seeder 'prd.draw_result.manage', 'prd.payout.review', 'prd.wallet_reconcile.view', - 'prd.report.risk', 'prd.audit.self', 'prd.player_freeze.manage', ]); @@ -84,7 +83,6 @@ final class AdminRbacAndUserSeeder extends Seeder 'prd.payout.view', 'prd.wallet_reconcile.manage', 'prd.wallet_adjust.manage', - 'prd.report.finance', 'prd.audit.finance', ]); @@ -96,7 +94,6 @@ final class AdminRbacAndUserSeeder extends Seeder 'prd.users.view_cs', 'prd.draw_result.view', 'prd.wallet_reconcile.view_cs', - 'prd.report.player', ]); $username = 'admin'; diff --git a/package.json b/package.json index 49c869e..c61650f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite --host \"${VITE_HOST:-0.0.0.0}\"" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/routes/api.php b/routes/api.php index c00eaa3..7cac1bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,11 +26,11 @@ Route::prefix('v1')->group(function (): void { require __DIR__.'/api/v1/admin/core.php'; require __DIR__.'/api/v1/admin/wallet.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/draw.php'; require __DIR__.'/api/v1/admin/jackpot.php'; require __DIR__.'/api/v1/admin/config.php'; - require __DIR__.'/api/v1/admin/report.php'; require __DIR__.'/api/v1/admin/user.php'; }); }); diff --git a/routes/api/v1/admin/currency.php b/routes/api/v1/admin/currency.php new file mode 100644 index 0000000..8ec4d01 --- /dev/null +++ b/routes/api/v1/admin/currency.php @@ -0,0 +1,22 @@ +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'); + }); diff --git a/routes/api/v1/admin/report.php b/routes/api/v1/admin/report.php deleted file mode 100644 index 56a6e1f..0000000 --- a/routes/api/v1/admin/report.php +++ /dev/null @@ -1,22 +0,0 @@ -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'); - }); diff --git a/routes/api/v1/public.php b/routes/api/v1/public.php index f295b88..e4dc1e5 100644 --- a/routes/api/v1/public.php +++ b/routes/api/v1/public.php @@ -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\DrawResultShowController; 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\Play\PlayEffectiveCatalogController; 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('currencies', CurrencyIndexController::class)->name('api.v1.currencies.index'); + // 开奖相关(公开) Route::get('draw/current', DrawCurrentController::class)->name('api.v1.draw.current'); Route::get('draw/results', DrawResultsIndexController::class)->name('api.v1.draw.results'); diff --git a/tests/Feature/AdminAuthorizationAuditCommandTest.php b/tests/Feature/AdminAuthorizationAuditCommandTest.php index 75e62b5..2b1ee2f 100644 --- a/tests/Feature/AdminAuthorizationAuditCommandTest.php +++ b/tests/Feature/AdminAuthorizationAuditCommandTest.php @@ -1,8 +1,8 @@ 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 { $this->seed(AdminRbacAndUserSeeder::class); diff --git a/tests/Feature/AdminCurrencyApiTest.php b/tests/Feature/AdminCurrencyApiTest.php new file mode 100644 index 0000000..84961fd --- /dev/null +++ b/tests/Feature/AdminCurrencyApiTest.php @@ -0,0 +1,169 @@ +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', + ]); +}); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 8e106d9..36408f5 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -1,16 +1,20 @@ seed(CurrencySeeder::class); +}); + function playerManageAdminToken(): string { $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') ->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); +}); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 3c4e96f..921cc33 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -150,6 +150,7 @@ test('permission catalog groups permissions by admin navigation order', function 'admin_users', 'admin_roles', 'players', + 'currencies', 'wallet', 'draws', 'config', diff --git a/tests/Feature/PublicCurrencyIndexTest.php b/tests/Feature/PublicCurrencyIndexTest.php new file mode 100644 index 0000000..6a0feec --- /dev/null +++ b/tests/Feature/PublicCurrencyIndexTest.php @@ -0,0 +1,41 @@ +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'); +}); diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php index ed2ab40..1d2a82f 100644 --- a/tests/Feature/WalletBalanceTest.php +++ b/tests/Feature/WalletBalanceTest.php @@ -3,10 +3,15 @@ use App\Models\Player; use App\Lottery\ErrorCode; use App\Models\PlayerWallet; +use Database\Seeders\CurrencySeeder; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); +beforeEach(function (): void { + $this->seed(CurrencySeeder::class); +}); + test('wallet balance creates lottery wallet row and returns zeros', function () { $player = Player::query()->create([ 'site_code' => 'test', @@ -49,3 +54,19 @@ test('wallet balance rejects illegal currency query', function () { ->assertStatus(400) ->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); +}); diff --git a/vite.config.js b/vite.config.js index 1fd66d5..5bf9833 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,6 +17,7 @@ export default defineConfig({ tailwindcss(), ], server: { + host: process.env.VITE_HOST ?? '0.0.0.0', watch: { ignored: ['**/storage/framework/views/**'], },