From 8954325194bffce26383a268e6e9898f4f2254e9 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 8 May 2026 16:02:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=9C=A8=20local=20=E5=92=8C=20testing=20=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E7=8E=A9=E5=AE=B6=E8=BA=AB=E4=BB=BD=E7=BB=95?= =?UTF-8?q?=E8=BF=87=EF=BC=8C=E6=96=B0=E5=A2=9E=E9=92=B1=E5=8C=85=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=20API=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/V1/Wallet/WalletBalanceController.php | 86 +++++++++++++++++++ app/Services/PlayerTokenResolver.php | 6 +- lang/en/wallet.php | 6 ++ lang/ne/wallet.php | 5 ++ lang/zh/wallet.php | 5 ++ phpunit.xml | 1 + routes/api.php | 31 +++++-- tests/Feature/WalletBalanceTest.php | 49 +++++++++++ 8 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php create mode 100644 lang/en/wallet.php create mode 100644 lang/ne/wallet.php create mode 100644 lang/zh/wallet.php create mode 100644 tests/Feature/WalletBalanceTest.php diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php new file mode 100644 index 0000000..98a35cb --- /dev/null +++ b/app/Http/Controllers/Api/V1/Wallet/WalletBalanceController.php @@ -0,0 +1,86 @@ +lotteryPlayer(); + abort_if($player === null, 500, 'lottery_player missing'); + + $currencyCode = $this->resolveCurrencyCode($request, $player); + if ($currencyCode instanceof JsonResponse) { + return $currencyCode; + } + + $wallet = PlayerWallet::query()->firstOrCreate( + [ + 'player_id' => $player->id, + 'wallet_type' => self::WALLET_TYPE_LOTTERY, + 'currency_code' => $currencyCode, + ], + [ + 'balance' => 0, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ], + ); + + return ApiResponse::success([ + 'balance' => $wallet->balance, + 'main_balance' => null, + 'currency_code' => $wallet->currency_code, + 'wallet_type' => $wallet->wallet_type, + 'frozen_balance' => $wallet->frozen_balance, + ]); + } + + /** + * @return string|JsonResponse 合法币种码或错误响应(code 1003:参数非法) + */ + private function resolveCurrencyCode(Request $request, Player $player): string|JsonResponse + { + $raw = $request->query('currency'); + if (is_string($raw) && $raw !== '') { + $code = strtoupper(substr(trim($raw), 0, 16)); + } else { + $fallback = $player->default_currency ?? config('lottery.default_currency', 'NPR'); + $code = strtoupper(substr(trim((string) $fallback), 0, 16)); + } + + // 币种码:字母数字,长度 1–16,与 migrations 字段一致 + if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) { + // 业务码占用 1000–1999 钱包段;1003 已在 PRD 保留为「金额超出限制」,币种非法单用 1005 + return ApiResponse::error( + __('wallet.invalid_currency'), + 1005, + null, + 400, + ); + } + + return $code; + } +} diff --git a/app/Services/PlayerTokenResolver.php b/app/Services/PlayerTokenResolver.php index 81964c9..4d7ded4 100644 --- a/app/Services/PlayerTokenResolver.php +++ b/app/Services/PlayerTokenResolver.php @@ -12,8 +12,8 @@ use Illuminate\Http\Request; * 从请求头解析玩家身份,返回已落库的 {@see Player}。 * * 两种模式(互斥优先级:先判断 dev,再走 JWT): - * 1) 开发绕过:仅当 `APP_ENV=local` 且 `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true`, - * 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(勿上生产)。 + * 1) 开发绕过:当 `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且运行环境为 `local` 或 **`testing`**(PHPUnit)时, + * 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(**禁止在生产开启**)。 * 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT(默认 HS256), * 从 payload 读取 `site_code`、`site_player_id`(字段名可 env 覆盖)再查 players 表。 * @@ -55,7 +55,7 @@ final class PlayerTokenResolver private function devBypassAllowed(): bool { return (bool) config('lottery.player_auth.dev_bypass') - && app()->environment('local'); + && app()->environment(['local', 'testing']); } private function resolveDevToken(string $token): Player diff --git a/lang/en/wallet.php b/lang/en/wallet.php new file mode 100644 index 0000000..ef9eff4 --- /dev/null +++ b/lang/en/wallet.php @@ -0,0 +1,6 @@ + 'Invalid currency code', +]; diff --git a/lang/ne/wallet.php b/lang/ne/wallet.php new file mode 100644 index 0000000..43e68c3 --- /dev/null +++ b/lang/ne/wallet.php @@ -0,0 +1,5 @@ + 'मुद्रा कोड अमान्य', +]; diff --git a/lang/zh/wallet.php b/lang/zh/wallet.php new file mode 100644 index 0000000..be32209 --- /dev/null +++ b/lang/zh/wallet.php @@ -0,0 +1,5 @@ + '币种参数不合法', +]; diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..8bd6ffe 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,5 +32,6 @@ + diff --git a/routes/api.php b/routes/api.php index 70f7d1d..f66e831 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,12 +4,14 @@ use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController; use App\Http\Controllers\Api\V1\HealthController; use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; +use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; use Illuminate\Support\Facades\Route; /* -| Laravel 默认在 bootstrap 中为 api 文件加前缀 `api`,故实际 URL: +| Laravel 在 bootstrap 中为本文件自动加前缀 `api` + 中间件组 `api`,故实际 URL: | /api/v1/health | /api/v1/player/... +| /api/v1/wallet/... | /api/v1/admin/... */ @@ -17,19 +19,32 @@ Route::prefix('v1')->group(function (): void { // 探活:无鉴权 Route::get('health', HealthController::class)->name('api.v1.health'); + // 玩家前缀下:仅 ping 公开;me 与 wallet/* 共用 lottery.player(见下方大组) Route::prefix('player') ->name('api.v1.player.') ->group(function (): void { - // 不需 Bearer Route::get('ping', PlayerPingController::class)->name('ping'); - - // 需 Bearer:PlayerTokenResolver + EnsurePlayerApi - Route::middleware('lottery.player')->group(function (): void { - Route::get('me', MeController::class)->name('me'); - }); }); - // 后台 API 前缀;中间件 lottery.admin 内预留 Sanctum / RBAC + /* + | 已登录玩家:PRD 把路径拆成 `/v1/player/*` 与 `/v1/wallet/*`,这里用同一中间件块避免重复写。 + | 勿把 wallet 挂到 player 前缀下,否则会变成 /api/v1/player/wallet/...,与 PRD §10.1.1 不一致。 + */ + Route::middleware('lottery.player')->group(function (): void { + Route::prefix('player') + ->name('api.v1.player.') + ->group(function (): void { + Route::get('me', MeController::class)->name('me'); + }); + + Route::prefix('wallet') + ->name('api.v1.wallet.') + ->group(function (): void { + Route::get('balance', WalletBalanceController::class)->name('balance'); + }); + }); + + // 后台 API;lottery.admin 内预留 Sanctum / RBAC Route::middleware('lottery.admin') ->prefix('admin') ->name('api.v1.admin.') diff --git a/tests/Feature/WalletBalanceTest.php b/tests/Feature/WalletBalanceTest.php new file mode 100644 index 0000000..8e7769e --- /dev/null +++ b/tests/Feature/WalletBalanceTest.php @@ -0,0 +1,49 @@ +create([ + 'site_code' => 'test', + 'site_player_id' => 'p1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer dev:'.$player->id, + 'X-Locale' => 'zh', + ])->getJson('/api/v1/wallet/balance'); + + $response->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.balance', 0) + ->assertJsonPath('data.frozen_balance', 0) + ->assertJsonPath('data.currency_code', 'NPR') + ->assertJsonPath('data.wallet_type', 'lottery') + ->assertJsonPath('data.main_balance', null); + + expect(PlayerWallet::query()->where('player_id', $player->id)->count())->toBe(1); +}); + +test('wallet balance rejects illegal currency query', function () { + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'p2', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/balance?currency=!!') + ->assertStatus(400) + ->assertJsonPath('code', 1005); +});