From 3f04b4ebe3075c1efd61e9ec4db1a5081a1fc440 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 26 Jun 2026 14:39:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=AF=86=E7=A0=81=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E7=BB=9F=E4=B8=80=E4=B8=8E=E5=90=8E=E5=8F=B0=20API/?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理端账号/代理/站点等密码最短长度统一为 6 位 - LotterySettings、审计日志展示、币种与报表接口等小修复 - 补充设置批量更新、注单、校验错误等相关测试 --- .env.example | 8 ++ .../Api/V1/Admin/AdminSettingController.php | 5 + .../Admin/Audit/AuditLogIndexController.php | 2 +- .../Currency/AdminCurrencyStoreController.php | 2 +- .../AdminCurrencyUpdateController.php | 4 - .../Reports/ReportJobStoreController.php | 2 +- .../V1/Ticket/TicketItemsIndexController.php | 5 + .../Admin/AdminCurrencyStoreRequest.php | 1 - .../Admin/AdminCurrencyUpdateRequest.php | 1 - .../AdminIntegrationSiteStoreRequest.php | 2 +- .../Requests/Admin/AdminUserStoreRequest.php | 2 +- .../Requests/Admin/AdminUserUpdateRequest.php | 2 +- .../Requests/Admin/AgentNodeStoreRequest.php | 2 +- .../Admin/Concerns/AdminAccountFieldRules.php | 2 +- app/Services/LotterySettings.php | 87 +++++++++++--- app/Support/AdminAuthorizationRegistry.php | 10 +- app/Support/AdminSettingPolicy.php | 10 ++ app/Support/AuditLogApiPresenter.php | 108 +++++++++++++++--- config/lottery.php | 4 +- routes/api/v1/admin/config.php | 2 + tests/Feature/AdminAuditLogDedupTest.php | 13 ++- tests/Feature/AdminAuthLoginTest.php | 2 +- tests/Feature/AdminCurrencyApiTest.php | 2 +- tests/Feature/AdminSettingBatchUpdateTest.php | 52 +++++++-- tests/Feature/AdminUserPermissionApiTest.php | 2 +- tests/Feature/LotterySettingsTest.php | 7 ++ tests/Feature/TicketItemsApiTest.php | 9 ++ tests/Unit/ApiValidationErrorsTest.php | 4 +- 28 files changed, 283 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 5ce1f77..23e09d7 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,14 @@ CORS_SUPPORTS_CREDENTIALS=false CORS_MAX_AGE=0 LOTTERY_SETTINGS_CACHE_TTL=60 + +# 部署参数(后台系统设置只读展示,勿在运营后台修改) +LOTTERY_DEFAULT_CURRENCY=NPR +LOTTERY_DRAW_TIMEZONE=UTC +LOTTERY_DRAW_INTERVAL_MINUTES=5 +LOTTERY_DRAW_BETTING_WINDOW_SECONDS=270 +LOTTERY_DRAW_CLOSE_BEFORE_SECONDS=30 +LOTTERY_DRAW_BUFFER_AHEAD=8 LOTTERY_PLAYER_AUTH_DEV_BYPASS=false ADMIN_API_TOKEN_TTL_DAYS=7 diff --git a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php index 80e2f0f..04ac8d2 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php @@ -19,6 +19,11 @@ use Illuminate\Support\Facades\DB; */ final class AdminSettingController extends Controller { + public function deployment(): JsonResponse + { + return ApiResponse::success(LotterySettings::deploymentSnapshot()); + } + public function index(AdminSettingIndexRequest $request): JsonResponse { $validated = $request->validated(); diff --git a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php index ee77e08..9867d0d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Audit/AuditLogIndexController.php @@ -50,4 +50,4 @@ final class AuditLogIndexController extends Controller return ApiResponse::success(AuditLogApiPresenter::listPayload($paginator)); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php index 9094283..74096e6 100644 --- a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyStoreController.php @@ -24,7 +24,7 @@ final class AdminCurrencyStoreController extends Controller $currency = Currency::query()->create([ 'code' => $data['code'], 'name' => $data['name'], - 'decimal_places' => (int) ($data['decimal_places'] ?? 2), + 'decimal_places' => 2, 'is_enabled' => $enabled, 'is_bettable' => $bettable, ]); diff --git a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php index 3ffc8eb..6e995d5 100644 --- a/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Currency/AdminCurrencyUpdateController.php @@ -25,10 +25,6 @@ final class AdminCurrencyUpdateController extends Controller $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']; } diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php index 697fa89..030ec16 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobStoreController.php @@ -35,4 +35,4 @@ final class ReportJobStoreController extends Controller 'status' => $job->status, ]); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index ba2eb8b..72e1307 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -37,6 +37,7 @@ final class TicketItemsIndexController extends Controller $statusInput, ))) : []; $number = trim((string) $request->query('number', '')); + $orderNo = trim((string) $request->query('order_no', '')); $startDate = $this->normalizeDate((string) $request->query('start_date', '')); $endDate = $this->normalizeDate((string) $request->query('end_date', '')); @@ -57,6 +58,10 @@ final class TicketItemsIndexController extends Controller $query->whereIn('ticket_items.status', $statusValues); } + if ($orderNo !== '') { + $query->whereHas('order', fn ($q) => $q->where('order_no', $orderNo)); + } + $this->applyTicketItemNumberSearch($query, $number); $this->applyOrderPlacedDateRange($query, $startDate, $endDate); diff --git a/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php b/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php index 80b60b3..531ea10 100644 --- a/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php +++ b/app/Http/Requests/Admin/AdminCurrencyStoreRequest.php @@ -17,7 +17,6 @@ final class AdminCurrencyStoreRequest extends ApiFormRequest 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'], ]; diff --git a/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php b/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php index 1268805..258c969 100644 --- a/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminCurrencyUpdateRequest.php @@ -15,7 +15,6 @@ final class AdminCurrencyUpdateRequest extends ApiFormRequest { return [ 'name' => ['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/AdminIntegrationSiteStoreRequest.php b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php index 6865a86..9cec67e 100644 --- a/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php +++ b/app/Http/Requests/Admin/AdminIntegrationSiteStoreRequest.php @@ -33,7 +33,7 @@ final class AdminIntegrationSiteStoreRequest extends ApiFormRequest 'admin_account' => ['required', 'array'], 'admin_account.username' => ['required', 'string', 'max:64', Rule::unique('admin_users', 'username')], 'admin_account.nickname' => ['required', 'string', 'max:64'], - 'admin_account.password' => ['required', 'string', 'min:8', 'max:255'], + 'admin_account.password' => ['required', 'string', 'min:6', 'max:255'], 'admin_account.email' => ['nullable', 'email', 'max:255'], ]; } diff --git a/app/Http/Requests/Admin/AdminUserStoreRequest.php b/app/Http/Requests/Admin/AdminUserStoreRequest.php index b04589a..a00dfd2 100644 --- a/app/Http/Requests/Admin/AdminUserStoreRequest.php +++ b/app/Http/Requests/Admin/AdminUserStoreRequest.php @@ -50,7 +50,7 @@ final class AdminUserStoreRequest extends ApiFormRequest 'username' => ['required', 'string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u', 'unique:admin_users,username'], 'nickname' => ['required', 'string', 'max:128'], 'email' => ['nullable', 'string', 'email', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'max:256'], + 'password' => ['required', 'string', 'min:6', 'max:256'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'admin_site_id' => ['required', 'integer', 'exists:admin_sites,id'], 'role_slugs' => ['required', 'array', 'min:1'], diff --git a/app/Http/Requests/Admin/AdminUserUpdateRequest.php b/app/Http/Requests/Admin/AdminUserUpdateRequest.php index 46c8f46..fc960c5 100644 --- a/app/Http/Requests/Admin/AdminUserUpdateRequest.php +++ b/app/Http/Requests/Admin/AdminUserUpdateRequest.php @@ -34,7 +34,7 @@ final class AdminUserUpdateRequest extends ApiFormRequest return [ 'nickname' => ['sometimes', 'string', 'max:128'], 'email' => ['sometimes', 'nullable', 'string', 'email', 'max:255', Rule::unique('admin_users', 'email')->ignore($adminUser?->id)], - 'password' => ['sometimes', 'nullable', 'string', 'min:8', 'max:256'], + 'password' => ['sometimes', 'nullable', 'string', 'min:6', 'max:256'], 'status' => ['sometimes', 'integer', Rule::in([0, 1])], ]; } diff --git a/app/Http/Requests/Admin/AgentNodeStoreRequest.php b/app/Http/Requests/Admin/AgentNodeStoreRequest.php index cd916d0..08f4f91 100644 --- a/app/Http/Requests/Admin/AgentNodeStoreRequest.php +++ b/app/Http/Requests/Admin/AgentNodeStoreRequest.php @@ -31,7 +31,7 @@ final class AgentNodeStoreRequest extends ApiFormRequest 'name' => ['required', 'string', 'max:128'], 'username' => ['sometimes', 'nullable', ...$this->adminOperatorUsernameRules(required: false), Rule::unique('admin_users', 'username')], 'email' => ['nullable', 'email', 'max:255', Rule::unique('admin_users', 'email')], - 'password' => ['required', 'string', 'min:8', 'max:128'], + 'password' => ['required', 'string', 'min:6', 'max:128'], 'status' => ['sometimes', 'integer', 'in:0,1'], ...$this->agentProfileFieldRules(), ]; diff --git a/app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php b/app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php index 6d837fa..08389e8 100644 --- a/app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php +++ b/app/Http/Requests/Admin/Concerns/AdminAccountFieldRules.php @@ -13,7 +13,7 @@ trait AdminAccountFieldRules } /** @return array */ - protected function adminLoginPasswordRules(bool $required = true, int $min = 8, int $max = 256): array + protected function adminLoginPasswordRules(bool $required = true, int $min = 6, int $max = 256): array { $rules = ['string', 'min:'.$min, 'max:'.$max]; diff --git a/app/Services/LotterySettings.php b/app/Services/LotterySettings.php index 5c8b7ce..fdaa911 100644 --- a/app/Services/LotterySettings.php +++ b/app/Services/LotterySettings.php @@ -13,49 +13,106 @@ use Illuminate\Support\Facades\Cache; */ final class LotterySettings { + /** 仅由 config/.env 控制,后台不可写入;旧 DB 行不再生效。 */ + public const ENV_LOCKED_KEYS = [ + 'currency.default_code', + 'draw.timezone', + 'draw.interval_minutes', + 'draw.betting_window_seconds', + 'draw.close_before_draw_seconds', + 'draw.buffer_draws_ahead', + ]; + private const CURRENCY_DISPLAY_DECIMALS = 2; private const CURRENCY_DECIMAL_SEPARATOR = '.'; private const CURRENCY_THOUSANDS_SEPARATOR = ','; + public static function isEnvLocked(string $key): bool + { + return in_array($key, self::ENV_LOCKED_KEYS, true); + } + public static function defaultCurrency(): string { - $fallback = (string) config('lottery.default_currency', 'NPR'); - $value = self::get('currency.default_code', $fallback); + $value = (string) config('lottery.default_currency', 'NPR'); - return strtoupper(substr(trim((string) $value), 0, 16)); + return strtoupper(substr(trim($value), 0, 16)); } public static function drawTimezone(): string { - return (string) self::get('draw.timezone', (string) config('lottery.draw.timezone', 'UTC')); + return (string) config('lottery.draw.timezone', 'UTC'); } public static function drawIntervalMinutes(): int { - $fallback = (int) config('lottery.draw.interval_minutes', 5); - - return max(1, min(1440, (int) self::get('draw.interval_minutes', $fallback))); + return max(1, min(1440, (int) config('lottery.draw.interval_minutes', 5))); } public static function drawBufferDrawsAhead(): int { - $fallback = (int) config('lottery.draw.buffer_draws_ahead', 8); - - return max(1, (int) self::get('draw.buffer_draws_ahead', $fallback)); + return max(1, (int) config('lottery.draw.buffer_draws_ahead', 8)); } public static function drawBettingWindowSeconds(): int { - $fallback = (int) config('lottery.draw.betting_window_seconds', 270); - - return max(10, (int) self::get('draw.betting_window_seconds', $fallback)); + return max(10, (int) config('lottery.draw.betting_window_seconds', 270)); } public static function drawCloseBeforeDrawSeconds(): int { - $fallback = (int) config('lottery.draw.close_before_draw_seconds', 30); + return max(5, (int) config('lottery.draw.close_before_draw_seconds', 30)); + } - return max(5, (int) self::get('draw.close_before_draw_seconds', $fallback)); + /** + * 后台「部署参数」只读快照:供运营查看当前生效值与对应 env 变量名。 + * + * @return array{ + * items: list + * } + */ + public static function deploymentSnapshot(): array + { + return [ + 'items' => [ + [ + 'key' => 'currency.default_code', + 'label_key' => 'default_currency', + 'value' => self::defaultCurrency(), + 'env_var' => 'LOTTERY_DEFAULT_CURRENCY', + ], + [ + 'key' => 'draw.timezone', + 'label_key' => 'draw_timezone', + 'value' => self::drawTimezone(), + 'env_var' => 'LOTTERY_DRAW_TIMEZONE', + ], + [ + 'key' => 'draw.interval_minutes', + 'label_key' => 'draw_interval_minutes', + 'value' => self::drawIntervalMinutes(), + 'env_var' => 'LOTTERY_DRAW_INTERVAL_MINUTES', + ], + [ + 'key' => 'draw.betting_window_seconds', + 'label_key' => 'draw_betting_window_seconds', + 'value' => self::drawBettingWindowSeconds(), + 'env_var' => 'LOTTERY_DRAW_BETTING_WINDOW_SECONDS', + ], + [ + 'key' => 'draw.close_before_draw_seconds', + 'label_key' => 'draw_close_before_draw_seconds', + 'value' => self::drawCloseBeforeDrawSeconds(), + 'env_var' => 'LOTTERY_DRAW_CLOSE_BEFORE_SECONDS', + ], + [ + 'key' => 'draw.buffer_draws_ahead', + 'label_key' => 'draw_buffer_draws_ahead', + 'value' => self::drawBufferDrawsAhead(), + 'env_var' => 'LOTTERY_DRAW_BUFFER_AHEAD', + ], + ], + ]; } public static function drawRequireManualReview(): bool diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 7134759..f0331af 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -24,7 +24,7 @@ final class AdminAuthorizationRegistry return [ ['slug' => 'prd.dashboard.view', 'name' => '仪表盘·查看', 'nav_segment' => 'dashboard', 'permission_codes' => ['dashboard.view']], - ['slug' => 'prd.admin_user.manage', 'name' => '管理员列表·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']], + ['slug' => 'prd.admin_user.manage', 'name' => '平台账号·可管理', 'nav_segment' => 'admin_users', 'permission_codes' => ['system.admin_user.manage']], ['slug' => 'prd.admin_role.manage', 'name' => '角色管理·可管理', 'nav_segment' => 'admin_roles', 'permission_codes' => ['system.admin_role.manage']], ['slug' => 'prd.agent.view', 'name' => '代理管理·查看', 'nav_segment' => 'agents', 'permission_codes' => ['agent.node.view']], @@ -92,7 +92,7 @@ final class AdminAuthorizationRegistry { $labels = [ 'dashboard' => '仪表盘', - 'admin_users' => '管理列表', + 'admin_users' => '平台账号', 'admin_roles' => '角色管理', 'players' => '玩家列表', 'currencies' => '币种管理', @@ -110,7 +110,7 @@ final class AdminAuthorizationRegistry 'audit' => '审计日志', 'settings' => '系统设置', 'integration' => '接入站点', - 'agents' => '代理线路', + 'agents' => '代理管理', ]; return array_map( @@ -147,8 +147,7 @@ final class AdminAuthorizationRegistry { return [ ['segment' => 'dashboard', 'label' => 'Dashboard', 'href' => '/admin', 'nav_group' => 'overview', 'requiredAny' => ['prd.dashboard.view']], - ['segment' => 'agents', 'label' => 'Agent lines', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeExact' => true, 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))], - ['segment' => 'agent_list', 'label' => 'Agent list', 'href' => '/admin/agents/list', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents/list', 'requiredAny' => ['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent.profile.manage']], + ['segment' => 'agents', 'label' => 'Agent management', 'href' => '/admin/agents', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/agents', 'requiredAny' => array_values(array_unique(array_merge(['prd.agent.view', 'prd.agent.manage', 'prd.agent.role.view', 'prd.agent.role.manage', 'prd.agent.user.view', 'prd.agent.user.manage', 'prd.agent-line.provision', 'prd.agent.profile.manage'], AdminPermissionLanguage::requiredAnyPrdSlugs('integration-sites'))))], ['segment' => 'settlement_center', 'label' => 'Credit settlement', 'href' => '/admin/settlement-center', 'nav_group' => 'agent', 'activeMatchPrefix' => '/admin/settlement-center', 'requiredAny' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['segment' => 'draws', 'label' => 'Draws', 'href' => '/admin/draws', 'nav_group' => 'operations', 'requiredAny' => ['prd.draw_result.manage', 'prd.draw_result.view', 'prd.draw_reopen.manage']], ['segment' => 'tickets', 'label' => 'Tickets', 'href' => '/admin/tickets', 'nav_group' => 'operations', 'requiredAny' => ['prd.tickets.view']], @@ -483,6 +482,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.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.risk_cap.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', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], + ['code' => 'admin.settings.deployment', 'module_code' => 'settings', 'name' => '部署参数只读', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings/deployment', 'route_name' => 'api.v1.admin.settings.deployment', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage', 'prd.draw_result.manage', 'prd.draw_result.view']], ['code' => 'admin.settings.batch-update', 'module_code' => 'settings', 'name' => '系统设置批量更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/batch', 'route_name' => 'api.v1.admin.settings.batch-update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust', 'config.odds.manage', 'settlement.batch.manage', 'draw.review.review', 'draw.review.publish']], ['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, 'permission_codes' => ['service.wallet.manage', 'service.reconcile.manage', 'service.wallet.adjust', 'config.odds.manage', 'settlement.batch.manage', 'draw.review.review', 'draw.review.publish']], ['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']], diff --git a/app/Support/AdminSettingPolicy.php b/app/Support/AdminSettingPolicy.php index 32a9380..fceecd2 100644 --- a/app/Support/AdminSettingPolicy.php +++ b/app/Support/AdminSettingPolicy.php @@ -17,6 +17,10 @@ final class AdminSettingPolicy public static function canUpdate(AdminUser $admin, string $key): bool { + if (LotterySettings::isEnvLocked($key)) { + return false; + } + if (str_starts_with($key, 'settlement.')) { return $admin->hasAdminPermission('prd.payout.manage'); } @@ -46,6 +50,12 @@ final class AdminSettingPolicy $values = []; foreach ($items as $index => $item) { $key = (string) $item['key']; + if (LotterySettings::isEnvLocked($key)) { + throw ValidationException::withMessages([ + "items.$index.key" => ['该参数由服务器环境变量配置,不能在后台修改。'], + ]); + } + if (! in_array($key, self::WALLET_LIMIT_KEYS, true)) { continue; } diff --git a/app/Support/AuditLogApiPresenter.php b/app/Support/AuditLogApiPresenter.php index 4f15ffb..d2ce0e1 100644 --- a/app/Support/AuditLogApiPresenter.php +++ b/app/Support/AuditLogApiPresenter.php @@ -16,21 +16,24 @@ final class AuditLogApiPresenter { /** @var array */ private const MODULE_LABELS = [ - 'agent' => '代理', - 'system' => '系统', + 'agent' => '代理管理', + 'system' => '系统管理', 'settings' => '系统设置', - 'integration' => '对接站点', + 'integration' => '接入站点', 'player_manage' => '玩家管理', - 'risk_cap' => '风险池', - 'odds' => '赔率', + 'player_service' => '玩家服务', + 'risk_cap' => '限额规则', + 'odds' => '赔率配置', 'play_config' => '玩法配置', - 'settlement' => '结算', - 'report_jobs' => '报表任务', + 'settlement' => '派彩结算', + 'report_jobs' => '报表导出', 'reconcile_jobs' => '对账任务', - 'draw' => '开奖', - 'wallet' => '钱包', - 'reconcile' => '对账', - 'job' => '任务', + 'draw' => '开奖期号', + 'wallet' => '钱包资金', + 'reconcile' => '对账核对', + 'jackpot' => '奖池', + 'dashboard' => '工作台', + 'job' => '后台任务', 'bootstrap' => '系统', ]; @@ -58,7 +61,17 @@ final class AuditLogApiPresenter 'payout_adjustment' => '派彩调整', 'rotate_secrets' => '轮换密钥', 'toggle_active' => '切换启用状态', - 'enqueue' => '提交报表任务', + 'enqueue' => '提交报表导出', + 'freeze' => '冻结玩家', + 'unfreeze' => '解冻玩家', + 'publish' => '发布开奖结果', + 'reopen' => '重新开奖', + 'run' => '执行结算', + 'confirm' => '确认账单', + 'close' => '关账', + 'payment' => '登记回款', + 'write_off' => '坏账核销', + 'adjustment' => '调整账单', ]; /** @var array */ @@ -80,6 +93,30 @@ final class AuditLogApiPresenter 'transfer_no' => '转账单', 'draw' => '期次', 'batch' => '批次', + 'agent_settlement_bill' => '代理结算单', + 'agent_settlement_period' => '代理结算期', + 'integration_site' => '接入站点', + 'jackpot_pool' => '奖池', + ]; + + /** @var array */ + private const API_RESOURCE_ENTITY_LABELS = [ + 'admin-users' => '管理员', + 'admin-roles' => '角色', + 'agent-nodes' => '代理', + 'agent-roles' => '代理角色', + 'agent-admin-users' => '代理账号', + 'agent-lines' => '代理线路', + 'agent-delegation-grants' => '代理授权', + 'draws' => '期次', + 'players' => '玩家', + 'settings' => '系统设置', + 'integration-sites' => '接入站点', + 'reconcile-jobs' => '对账任务', + 'report-jobs' => '报表任务', + 'settlement-batches' => '结算批次', + 'jackpot' => '奖池', + 'risk' => '风控', ]; /** @var array */ @@ -138,6 +175,7 @@ final class AuditLogApiPresenter 'module_label' => self::moduleLabel($r->module_code), 'action_label' => self::actionLabel($r, $resourceNames), 'target_label' => self::targetLabel($r, $resourceNames), + 'summary_label' => self::summaryLabel($r, $resourceNames), 'before_json' => $r->before_json, 'after_json' => $r->after_json, 'ip' => $r->ip, @@ -311,9 +349,9 @@ final class AuditLogApiPresenter } if (self::isApiResourceCode($type)) { - $name = $resourceNames[$type] ?? $type; + $entity = self::entityLabelFromResourceCode($type, $resourceNames); - return $id !== '' ? sprintf('%s #%s', $name, $id) : $name; + return $id !== '' ? sprintf('%s #%s', $entity, $id) : $entity; } $typeLabel = self::TARGET_TYPE_LABELS[$type] ?? $type; @@ -321,6 +359,48 @@ final class AuditLogApiPresenter return $id !== '' ? sprintf('%s #%s', $typeLabel, $id) : $typeLabel; } + /** + * @param array $resourceNames + */ + private static function summaryLabel(AuditLog $r, array $resourceNames): string + { + $module = self::moduleLabel($r->module_code); + $action = self::actionLabel($r, $resourceNames); + $target = self::targetLabel($r, $resourceNames); + + if ($action === '—') { + return $module; + } + + if ($target === '—') { + return sprintf('%s · %s', $module, $action); + } + + if ($target === $action || str_starts_with($target, $action.' #')) { + return sprintf('%s · %s', $module, $action); + } + + return sprintf('%s · %s(%s)', $module, $action, $target); + } + + /** + * @param array $resourceNames + */ + private static function entityLabelFromResourceCode(string $code, array $resourceNames): string + { + $segments = explode('.', $code); + if (count($segments) < 2) { + return $resourceNames[$code] ?? $code; + } + + $resourceSegment = $segments[1] ?? ''; + if (isset(self::API_RESOURCE_ENTITY_LABELS[$resourceSegment])) { + return self::API_RESOURCE_ENTITY_LABELS[$resourceSegment]; + } + + return $resourceNames[$code] ?? $resourceSegment; + } + private static function isApiResourceCode(?string $code): bool { return is_string($code) && str_starts_with($code, 'admin.'); diff --git a/config/lottery.php b/config/lottery.php index 1b0d4af..971de9b 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -99,8 +99,8 @@ return [ | 生成计划、封盘判定、API ISO 时刻、前后台 YYYY-MM-DD HH:mm:ss 展示均按 UTC 解释,勿改为本地时区。 */ 'draw' => [ - /** 期号「业务日」切分与计划时刻(固定 UTC,与 {@see docs/01-界面文档.md} 一致) */ - 'timezone' => 'UTC', + /** 期号「业务日」切分与计划时刻(默认 UTC,与 {@see docs/01-界面文档.md} 一致) */ + 'timezone' => env('LOTTERY_DRAW_TIMEZONE', 'UTC'), /** 开奖时间间隔(分钟),整日从 00:00 起排槽 */ 'interval_minutes' => max(1, min(1440, (int) env('LOTTERY_DRAW_INTERVAL_MINUTES', 5))), /** 下注开放时长(秒):start_time = close_time - betting_window_seconds */ diff --git a/routes/api/v1/admin/config.php b/routes/api/v1/admin/config.php index bccb9c8..bd4cc1f 100644 --- a/routes/api/v1/admin/config.php +++ b/routes/api/v1/admin/config.php @@ -124,6 +124,8 @@ Route::middleware('admin.api-resource') ->prefix('settings') ->name('api.v1.admin.settings.') ->group(function (): void { + Route::get('deployment', [AdminSettingController::class, 'deployment']) + ->name('deployment'); Route::get('/', AdminSettingController::class.'@index') ->name('index'); Route::put('batch', AdminSettingController::class.'@batchUpdate') diff --git a/tests/Feature/AdminAuditLogDedupTest.php b/tests/Feature/AdminAuditLogDedupTest.php index 0b46e34..0bbbd24 100644 --- a/tests/Feature/AdminAuditLogDedupTest.php +++ b/tests/Feature/AdminAuditLogDedupTest.php @@ -30,9 +30,10 @@ test('audit log presenter maps business action and entity target', function (): expect($payload['operator_label'])->toBe('管理员 #1') ->and($payload['operator_subtitle'])->toBeNull() - ->and($payload['module_label'])->toBe('代理') + ->and($payload['module_label'])->toBe('代理管理') ->and($payload['action_label'])->toBe('同步代理角色权限') - ->and($payload['target_label'])->toBe('角色 #5'); + ->and($payload['target_label'])->toBe('角色 #5') + ->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5)'); }); test('audit log presenter uses admin api resource name for middleware style rows', function (): void { @@ -55,7 +56,8 @@ test('audit log presenter uses admin api resource name for middleware style rows $payload = AuditLogApiPresenter::row($row, $resourceNames); expect($payload['action_label'])->toBe($resourceName) - ->and($payload['target_label'])->toBe($resourceName.' #5'); + ->and($payload['target_label'])->toBe('代理角色 #5') + ->and($payload['summary_label'])->toBe('代理管理 · '.$resourceName.'(代理角色 #5)'); }); test('agent role permission sync records one business audit and skips middleware duplicate', function (): void { @@ -133,7 +135,8 @@ test('audit log index returns chinese display labels', function (): void { ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.items.0.operator_label', '超管甲') ->assertJsonPath('data.items.0.operator_subtitle', '管理员 #'.$admin->id) - ->assertJsonPath('data.items.0.module_label', '代理') + ->assertJsonPath('data.items.0.module_label', '代理管理') ->assertJsonPath('data.items.0.action_label', '同步代理角色权限') - ->assertJsonPath('data.items.0.target_label', '角色 #9'); + ->assertJsonPath('data.items.0.target_label', '角色 #9') + ->assertJsonPath('data.items.0.summary_label', '代理管理 · 同步代理角色权限(角色 #9)'); }); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 99589dc..043ee4e 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -79,7 +79,7 @@ test('admin login returns bearer token when captcha passes validation', function ->assertJsonPath('data.admin.navigation.0.nav_group', 'overview') ->assertJsonPath('data.admin.navigation.1.segment', 'agents') ->assertJsonPath('data.admin.navigation.1.nav_group', 'agent') - ->assertJsonPath('data.admin.navigation.2.segment', 'agent_list') + ->assertJsonPath('data.admin.navigation.2.segment', 'settlement_center') ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email', 'permissions', 'operational_permissions', 'navigation']]]); $token = $resp->json('data.token'); diff --git a/tests/Feature/AdminCurrencyApiTest.php b/tests/Feature/AdminCurrencyApiTest.php index 03937db..45d88ff 100644 --- a/tests/Feature/AdminCurrencyApiTest.php +++ b/tests/Feature/AdminCurrencyApiTest.php @@ -47,12 +47,12 @@ test('admin can create currency and normalize code', function (): void { ->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.decimal_places', 2) ->assertJsonPath('data.is_bettable', true); $this->assertDatabaseHas('currencies', [ diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php index a3a8032..b08d54c 100644 --- a/tests/Feature/AdminSettingBatchUpdateTest.php +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -71,8 +71,28 @@ function settingsReadOnlyToken(): string } test('admin can batch update settings in one request', function (): void { - LotterySettings::put('draw.interval_minutes', 5, 'draw'); LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); + LotterySettings::put('draw.require_manual_review', true, 'draw'); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'draw.cooldown_minutes', 'value' => 20], + ['key' => 'draw.require_manual_review', 'value' => false], + ], + ]) + ->assertOk() + ->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes'); + + expect(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20) + ->and(LotterySetting::query()->where('setting_key', 'draw.require_manual_review')->value('value_json'))->toBeFalse(); +}); + +test('admin cannot batch update env locked deployment settings', function (): void { + config(['lottery.draw.interval_minutes' => 5]); + LotterySettings::put('draw.interval_minutes', 5, 'draw'); $token = settingsAdminToken(); @@ -80,14 +100,28 @@ test('admin can batch update settings in one request', function (): void { ->putJson('/api/v1/admin/settings/batch', [ 'items' => [ ['key' => 'draw.interval_minutes', 'value' => 10], - ['key' => 'draw.cooldown_minutes', 'value' => 20], ], ]) - ->assertOk() - ->assertJsonPath('data.items.0.key', 'draw.cooldown_minutes'); + ->assertForbidden(); - expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(10) - ->and(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(20); + expect(LotterySettings::drawIntervalMinutes())->toBe(5); +}); + +test('admin settings deployment returns read only snapshot', function (): void { + config([ + 'lottery.default_currency' => 'USD', + 'lottery.draw.interval_minutes' => 7, + ]); + + $token = settingsAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settings/deployment') + ->assertOk() + ->assertJsonPath('data.items.0.key', 'currency.default_code') + ->assertJsonPath('data.items.0.value', 'USD') + ->assertJsonPath('data.items.0.env_var', 'LOTTERY_DEFAULT_CURRENCY') + ->assertJsonFragment(['key' => 'draw.interval_minutes', 'value' => 7]); }); test('admin settings batch update rejects empty items', function (): void { @@ -199,13 +233,13 @@ test('wallet max limit cannot be less than current min limit', function (): void }); test('settings updates require permission for their setting group', function (): void { - LotterySettings::put('draw.interval_minutes', 5, 'draw'); + LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); $token = settingsReadOnlyToken(); $this->withHeader('Authorization', 'Bearer '.$token) - ->putJson('/api/v1/admin/settings/draw.interval_minutes', [ + ->putJson('/api/v1/admin/settings/draw.cooldown_minutes', [ 'value' => 10, ]) ->assertForbidden(); @@ -216,6 +250,6 @@ test('settings updates require permission for their setting group', function (): ]) ->assertOk(); - expect(LotterySetting::query()->where('setting_key', 'draw.interval_minutes')->value('value_json'))->toBe(5) + expect(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(15) ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
new
'); }); diff --git a/tests/Feature/AdminUserPermissionApiTest.php b/tests/Feature/AdminUserPermissionApiTest.php index 6c8d05f..f50d2a8 100644 --- a/tests/Feature/AdminUserPermissionApiTest.php +++ b/tests/Feature/AdminUserPermissionApiTest.php @@ -260,7 +260,7 @@ test('permission catalog groups permissions by admin navigation order', function expect($groups[2]['key'])->toBe('draws'); $groupsByKey = collect($groups)->keyBy('key'); - expect($groupsByKey['admin_users']['label'])->toBe('管理列表'); + expect($groupsByKey['admin_users']['label'])->toBe('平台账号'); expect(array_column($groupsByKey['admin_users']['permissions'], 'slug'))->toBe(['prd.admin_user.manage']); expect($groupsByKey['admin_roles']['label'])->toBe('角色管理'); expect(array_column($groupsByKey['admin_roles']['permissions'], 'slug'))->toBe(['prd.admin_role.manage']); diff --git a/tests/Feature/LotterySettingsTest.php b/tests/Feature/LotterySettingsTest.php index ac348f7..06211cf 100644 --- a/tests/Feature/LotterySettingsTest.php +++ b/tests/Feature/LotterySettingsTest.php @@ -13,6 +13,13 @@ test('lottery settings get returns seeded value', function (): void { expect(LotterySettings::get('fixture.flag'))->toBeTrue(); }); +test('env locked draw settings ignore database overrides', function (): void { + config(['lottery.draw.interval_minutes' => 9]); + LotterySettings::put('draw.interval_minutes', 99, 'draw'); + + expect(LotterySettings::drawIntervalMinutes())->toBe(9); +}); + test('lottery settings misses are not cached', function (): void { Cache::flush(); diff --git a/tests/Feature/TicketItemsApiTest.php b/tests/Feature/TicketItemsApiTest.php index 5f1c0d6..a837b08 100644 --- a/tests/Feature/TicketItemsApiTest.php +++ b/tests/Feature/TicketItemsApiTest.php @@ -271,6 +271,15 @@ test('ticket items index filters by status number and date range', function (): ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.draw_no', '20260512-780'); + + $orderNo = TicketOrder::query()->where('draw_id', $draw2->id)->value('order_no'); + expect($orderNo)->not->toBeEmpty(); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/ticket/items?order_no='.urlencode((string) $orderNo)) + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.order_no', $orderNo); }); test('ticket item show returns match result and timeline', function (): void { diff --git a/tests/Unit/ApiValidationErrorsTest.php b/tests/Unit/ApiValidationErrorsTest.php index 8da5294..636b4e7 100644 --- a/tests/Unit/ApiValidationErrorsTest.php +++ b/tests/Unit/ApiValidationErrorsTest.php @@ -47,11 +47,11 @@ test('keeps already localized chinese messages', function (): void { test('normalizes english min length for password in zh', function (): void { $errors = ApiValidationErrors::normalize( - ['password' => ['The password field must be at least 8 characters.']], + ['password' => ['The password field must be at least 6 characters.']], 'zh', ); - expect($errors['password'][0])->toBe('密码至少需要 8 个字符。'); + expect($errors['password'][0])->toBe('密码至少需要 6 个字符。'); }); test('normalizes exact draw items message in zh', function (): void {