feat(admin): 密码校验统一与后台 API/设置增强
- 管理端账号/代理/站点等密码最短长度统一为 6 位 - LotterySettings、审计日志展示、币种与报表接口等小修复 - 补充设置批量更新、注单、校验错误等相关测试
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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])],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -13,7 +13,7 @@ trait AdminAccountFieldRules
|
||||
}
|
||||
|
||||
/** @return array<int, mixed> */
|
||||
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];
|
||||
|
||||
|
||||
@@ -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<array{key: string, label_key: string, value: mixed, env_var: string}>
|
||||
* }
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,21 +16,24 @@ final class AuditLogApiPresenter
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
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<string, string> */
|
||||
@@ -80,6 +93,30 @@ final class AuditLogApiPresenter
|
||||
'transfer_no' => '转账单',
|
||||
'draw' => '期次',
|
||||
'batch' => '批次',
|
||||
'agent_settlement_bill' => '代理结算单',
|
||||
'agent_settlement_period' => '代理结算期',
|
||||
'integration_site' => '接入站点',
|
||||
'jackpot_pool' => '奖池',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
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<string, string> */
|
||||
@@ -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<string, string> $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<string, string> $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.');
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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', '<div>old</div>', '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('<div>new</div>');
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user