diff --git a/app/Services/Ticket/RiskPoolService.php b/app/Services/Ticket/RiskPoolService.php index b68713b..50e5c6a 100644 --- a/app/Services/Ticket/RiskPoolService.php +++ b/app/Services/Ticket/RiskPoolService.php @@ -151,9 +151,24 @@ final class RiskPoolService $pool = $this->firstOrMakePool($drawId, $number4d); $key = $this->redisPoolKey($drawId, $number4d); - Redis::eval($this->initLua(), 1, $key, (int) $pool->total_cap_amount, (int) $pool->locked_amount, (int) $pool->version); + Redis::eval( + $this->initLua(), + 1, + $key, + (int) $pool->total_cap_amount, + (int) $pool->locked_amount, + (int) $pool->version, + $this->redisPoolTtlSeconds(), + ); - $result = $this->normalizeLuaResult(Redis::eval($this->acquireLua(), 1, $key, $amount, (int) $pool->version)); + $result = $this->normalizeLuaResult(Redis::eval( + $this->acquireLua(), + 1, + $key, + $amount, + (int) $pool->version, + $this->redisPoolTtlSeconds(), + )); if (($result['code'] ?? null) !== 'OK') { throw new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } @@ -211,6 +226,7 @@ final class RiskPoolService $locked, $remaining, (int) $pool->version, + $this->redisPoolTtlSeconds(), ); } @@ -228,11 +244,17 @@ final class RiskPoolService return "risk_pool:draw:{$drawId}:number:{$number4d}"; } + private function redisPoolTtlSeconds(): int + { + return max(60, (int) config('lottery.risk_pool.redis_ttl_seconds', 86400)); + } + private function initLua(): string { return <<<'LUA' if redis.call('EXISTS', KEYS[1]) == 0 then redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[1] - ARGV[2], 'version', ARGV[3]) + redis.call('EXPIRE', KEYS[1], tonumber(ARGV[4])) end return 1 LUA; @@ -242,6 +264,7 @@ LUA; { return <<<'LUA' redis.call('HMSET', KEYS[1], 'total', ARGV[1], 'locked', ARGV[2], 'remaining', ARGV[3], 'version', ARGV[4]) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5])) return 1 LUA; } @@ -268,6 +291,7 @@ end local locked = redis.call('HINCRBY', KEYS[1], 'locked', amount) remaining = redis.call('HINCRBY', KEYS[1], 'remaining', -amount) version = redis.call('HINCRBY', KEYS[1], 'version', 1) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) return {'OK', remaining, locked, version} LUA; } @@ -283,6 +307,7 @@ if locked < releaseAmount then end redis.call('HINCRBY', KEYS[1], 'locked', -releaseAmount) redis.call('HINCRBY', KEYS[1], 'remaining', releaseAmount) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])) return releaseAmount LUA; } @@ -336,7 +361,13 @@ LUA; private function releaseRedisLocks(int $drawId, array $locks): void { foreach ($locks as $lock) { - Redis::eval($this->releaseLua(), 1, $this->redisPoolKey($drawId, $lock['number_4d']), (int) $lock['amount']); + Redis::eval( + $this->releaseLua(), + 1, + $this->redisPoolKey($drawId, $lock['number_4d']), + (int) $lock['amount'], + $this->redisPoolTtlSeconds(), + ); } } diff --git a/app/Support/ApiResponse.php b/app/Support/ApiResponse.php index 171306f..3e1e097 100644 --- a/app/Support/ApiResponse.php +++ b/app/Support/ApiResponse.php @@ -14,13 +14,15 @@ use Illuminate\Http\Request; */ final class ApiResponse { + private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + public static function success(mixed $data = null, ?string $msg = null, int $code = 0, ?Request $request = null): JsonResponse { return response()->json([ 'code' => $code, 'msg' => $msg ?? ApiMessage::successMessage($request), 'data' => $data, - ]); + ], 200, [], self::JSON_FLAGS); } public static function error(string $msg, int $code, mixed $data = null, int $httpStatus = 400): JsonResponse @@ -29,6 +31,6 @@ final class ApiResponse 'code' => $code, 'msg' => $msg, 'data' => $data, - ], $httpStatus); + ], $httpStatus, [], self::JSON_FLAGS); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index b4bf860..0a57c72 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,7 @@ use App\Lottery\ErrorCode; use App\Support\ApiResponse; 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; @@ -174,13 +175,40 @@ return Application::configure(basePath: dirname(__DIR__)) return null; } - $showDetails = (bool) config('app.debug'); + $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, - $showDetails ? ['exception' => $e::class] : null, + $details, 500, ); }); diff --git a/config/lottery.php b/config/lottery.php index 3db23cc..d881926 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -26,6 +26,8 @@ return [ 'risk_pool' => [ /** 生产默认使用 Redis Lua 做号码赔付池原子扣减;testing 自动回退 DB,便于无 Redis 跑测试。 */ 'use_redis_lua' => filter_var(env('LOTTERY_RISK_POOL_USE_REDIS_LUA', true), FILTER_VALIDATE_BOOLEAN), + /** 风险池 Redis 快照键 TTL(秒);每次初始化/扣减/释放/同步都会续期。 */ + 'redis_ttl_seconds' => max(60, (int) env('LOTTERY_RISK_POOL_REDIS_TTL_SECONDS', 86400)), ], 'main_site' => [ diff --git a/tests/Feature/ApiExceptionResponseTest.php b/tests/Feature/ApiExceptionResponseTest.php new file mode 100644 index 0000000..5d122ec --- /dev/null +++ b/tests/Feature/ApiExceptionResponseTest.php @@ -0,0 +1,25 @@ +getJson('/api/v1/test-error-detail'); + + $response + ->assertStatus(500) + ->assertJsonPath('code', ErrorCode::InternalError->value) + ->assertJsonPath('msg', '测试异常明细') + ->assertJsonPath('data.exception', RuntimeException::class) + ->assertJsonPath('data.message', '测试异常明细'); + + expect($response->json('data.error_id'))->not->toBeEmpty() + ->and($response->json('data.file'))->toContain('ApiExceptionResponseTest.php') + ->and($response->json('data.line'))->toBeInt() + ->and($response->getContent())->toContain('测试异常明细') + ->and($response->getContent())->not->toContain('\u6d4b\u8bd5\u5f02\u5e38\u660e\u7ec6'); +}); diff --git a/tests/Feature/RiskPoolLuaScriptTest.php b/tests/Feature/RiskPoolLuaScriptTest.php index fd7cb62..ee5a63a 100644 --- a/tests/Feature/RiskPoolLuaScriptTest.php +++ b/tests/Feature/RiskPoolLuaScriptTest.php @@ -19,5 +19,25 @@ test('risk pool lua acquire script returns structured status and pool counters', ->and($lua)->toContain('INSUFFICIENT_CAP') ->and($lua)->toContain('remaining') ->and($lua)->toContain('locked') - ->and($lua)->toContain('version'); + ->and($lua)->toContain('version') + ->and($lua)->toContain("redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))"); +}); + +test('risk pool lua init overwrite and release scripts refresh redis ttl', function (): void { + $service = app(RiskPoolService::class); + + $initMethod = new ReflectionMethod($service, 'initLua'); + $initMethod->setAccessible(true); + $overwriteMethod = new ReflectionMethod($service, 'overwriteStateLua'); + $overwriteMethod->setAccessible(true); + $releaseMethod = new ReflectionMethod($service, 'releaseLua'); + $releaseMethod->setAccessible(true); + + $initLua = (string) $initMethod->invoke($service); + $overwriteLua = (string) $overwriteMethod->invoke($service); + $releaseLua = (string) $releaseMethod->invoke($service); + + expect($initLua)->toContain("redis.call('EXPIRE', KEYS[1], tonumber(ARGV[4]))") + ->and($overwriteLua)->toContain("redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))") + ->and($releaseLua)->toContain("redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))"); });