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);
+});