Files
lotteryLaravel/bootstrap/app.php
kang f0e0966a73
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(lottery): 增加广播任务时效保护并强化命令异常处理
- 为多个广播事件添加 retryUntil 方法,设置派发后 30 秒内必须执行,否则丢弃
- 修改 LotteryDrawTickCommand handle,添加异常捕获及错误日志,保证失败时返回 FAILURE
- 修改 LotteryHallCountdownCommand handle,添加异常捕获及错误日志,超时情况写入警告日志
- 针对 SettlementOrchestrator 和 DrawHallSnapshotBuilder 查询结果添加限制,防止数据量过大
- 调整计划任务 withoutOverlapping 调用,增加 expiresAt 参数避免任务锁死
- 更新 .env.example,完善本地开发与生产环境部署说明及日志配置说明
- 移除 e2e 相关测试代码、配置及依赖,精简项目体积
2026-07-07 13:37:05 +08:00

269 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/*
|--------------------------------------------------------------------------
| 【应用入口】路由与中间件
|--------------------------------------------------------------------------
| api 分组 prepend了 NegotiateLotteryLocale
| 保证任意 API 在未进入控制器前已确定 lottery_locale / app locale便于统一翻译 msg。
|--------------------------------------------------------------------------
*/
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use App\Support\ApiMessage;
use App\Support\ApiValidationErrors;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Support\LotteryLocale;
use Illuminate\Foundation\Application;
use App\Http\Middleware\EnsureAdminApi;
use App\Http\Middleware\EnsurePlayerApi;
use App\Http\Middleware\RecordAdminApiAudit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\EnsureAdminApiResourcePermission;
use Illuminate\Validation\ValidationException;
use App\Http\Middleware\NegotiateLotteryLocale;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// 自动加前缀 `api` + middleware `api`,见 routes/api.php
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// 多语言:必须在其他 api 中间件之前执行,以便鉴权失败时也能按语言返回 msg
$middleware->api(prepend: [
NegotiateLotteryLocale::class,
]);
$middleware->redirectGuestsTo(static function (Request $request): ?string {
if ($request->is('api/*')) {
return null;
}
return '/login';
});
$middleware->convertEmptyStringsToNull([
static fn (Request $request): bool => $request->is('api/v1/admin/settings')
|| $request->is('api/v1/admin/settings/*'),
]);
$middleware->alias([
// 玩家端需登录路由使用;解析 Bearer → Player
'lottery.player' => EnsurePlayerApi::class,
// 后台 API 预留Sanctum / RBAC
'lottery.admin' => EnsureAdminApi::class,
'admin.api-resource' => EnsureAdminApiResourcePermission::class,
'admin.audit' => RecordAdminApiAudit::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
/**
* 统一 JSON与 {@see ApiResponse} 一致,供三端消费;多语言与 {@see LotteryLocale} 对齐。
* 覆盖:校验失败、模型/路由 404、限流、未处理异常生产不泄露堆栈文案
*/
$locale = static function (Request $request): string {
return (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request));
};
$exceptions->render(function (AuthenticationException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
return ApiResponse::error(
trans('admin.unauthenticated', [], $locale($request)),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
});
$exceptions->render(function (ValidationException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
$loc = $locale($request);
$errors = ApiValidationErrors::normalize($e->errors(), $loc);
$msg = ApiValidationErrors::summary($errors)
?? trans('api.validation_failed', [], $loc);
return ApiResponse::error(
$msg,
ErrorCode::ValidationFailed->value,
['errors' => $errors],
422,
);
});
$exceptions->render(function (\InvalidArgumentException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
$reason = trim($e->getMessage());
if ($reason === '' || str_contains($reason, ' ')) {
return null;
}
$loc = $locale($request);
$msg = ApiMessage::reason($request, $reason);
if ($msg === $reason) {
$business = trans('validation.business.'.$reason, [], $loc);
if ($business === 'validation.business.'.$reason) {
return null;
}
$msg = $business;
}
return ApiResponse::error(
$msg,
ErrorCode::ValidationFailed->value,
null,
422,
);
});
$exceptions->render(function (ModelNotFoundException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
return ApiResponse::error(
trans('api.not_found', [], $locale($request)),
ErrorCode::NotFound->value,
null,
404,
);
});
$exceptions->render(function (NotFoundHttpException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
return ApiResponse::error(
trans('api.not_found', [], $locale($request)),
ErrorCode::NotFound->value,
null,
404,
);
});
$exceptions->render(function (TooManyRequestsHttpException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
return ApiResponse::error(
trans('api.too_many_requests', [], $locale($request)),
ErrorCode::TooManyRequests->value,
null,
429,
);
});
$exceptions->render(function (HttpException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
if ($e instanceof NotFoundHttpException || $e instanceof TooManyRequestsHttpException) {
return null;
}
$status = $e->getStatusCode();
$msg = ApiMessage::httpExceptionMessage($request, $status, (string) $e->getMessage());
$code = $status >= 500 ? ErrorCode::InternalError->value : ErrorCode::ClientHttpError->value;
return ApiResponse::error($msg, $code, null, $status);
});
$exceptions->render(function (Throwable $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
if ($e instanceof ValidationException
|| $e instanceof ModelNotFoundException
|| $e instanceof NotFoundHttpException
|| $e instanceof TooManyRequestsHttpException
|| $e instanceof HttpException
) {
return null;
}
$showDetails = (bool) config('app.debug')
|| (bool) env('API_ERROR_DETAILS', false)
|| app()->environment(['local', 'testing']);
$errorId = (string) Str::uuid();
report($e);
$msg = $showDetails ? $e->getMessage() : trans('api.server_error', [], $locale($request));
$details = [
'error_id' => $errorId,
];
if ($showDetails) {
$details['exception'] = $e::class;
$details['message'] = $e->getMessage();
$details['file'] = $e->getFile();
$details['line'] = $e->getLine();
$details['trace'] = collect($e->getTrace())
->take(10)
->map(static function (array $frame): array {
return [
'file' => $frame['file'] ?? null,
'line' => $frame['line'] ?? null,
'class' => $frame['class'] ?? null,
'function' => $frame['function'] ?? null,
];
})
->values()
->all();
}
return ApiResponse::error(
$msg !== '' ? $msg : trans('api.server_error', [], $locale($request)),
ErrorCode::InternalError->value,
$details,
500,
);
});
})
->withSchedule(function (Schedule $schedule): void {
/** 开奖时刻后尽快跑 RNG/冷静期,避免大厅在 0:00 卡住最多 1 分钟 */
$schedule->command('lottery:draw-tick')
->everyTenSeconds()
->withoutOverlapping(expiresAt: 10)
->onOneServer();
$schedule->command('lottery:wallet-transfer-reconcile --lookback-hours=24 --stale-minutes=15 --limit=1000')
->everyTenMinutes()
->withoutOverlapping()
->onOneServer();
$schedule->command('lottery:ticket-pending-confirm-reconcile --stale-minutes=5 --limit=500')
->everyMinute()
->withoutOverlapping()
->onOneServer();
$schedule->command('settlement:mark-overdue-bills --days=7')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer();
/** @see docs/01-界面文档.md §2.1 `draw.countdown` */
if (config('lottery.realtime_hall_countdown', true)) {
$schedule->command('lottery:hall-countdown')
->everySecond()
->withoutOverlapping(expiresAt: 5)
->onOneServer();
}
})
->create();