feat: 更新开发环境配置,支持在 local 和 testing 环境下的玩家身份绕过,新增钱包余额 API 路由

This commit is contained in:
2026-05-08 16:02:46 +08:00
parent fc0999664a
commit 8954325194
8 changed files with 178 additions and 11 deletions

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api\V1\Wallet;
use App\Http\Controllers\Controller;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 【玩家】查询彩票侧钱包余额。
*
* 路由:`GET /api/v1/wallet/balance`,需 middleware `lottery.player`
* PRD`01-产品文档.md` §10.1.1;金额单位为最小货币 bigint参见 `docs/04` §8
*
* 【行为要点】
* - 币种:优先 Query `currency`,否则使用玩家 `default_currency`,再回退 `config('lottery.default_currency')`
* - 若尚无 `player_wallets` 记录:按 `wallet_type=lottery` + 币种 **首次开立**一行(余额 0),便于新玩家直接进入查询
* - `main_balance`:主站钱包余额占位,接入主站 API 后再返回实数;当前固定 `null`
*/
class WalletBalanceController extends Controller
{
private const WALLET_TYPE_LOTTERY = 'lottery';
public function __invoke(Request $request): JsonResponse
{
$player = $request->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));
}
// 币种码:字母数字,长度 116与 migrations 字段一致
if (! preg_match('/^[A-Z0-9]{1,16}$/', $code)) {
// 业务码占用 10001999 钱包段1003 已在 PRD 保留为「金额超出限制」,币种非法单用 1005
return ApiResponse::error(
__('wallet.invalid_currency'),
1005,
null,
400,
);
}
return $code;
}
}

View File

@@ -12,8 +12,8 @@ use Illuminate\Http\Request;
* 从请求头解析玩家身份,返回已落库的 {@see Player} * 从请求头解析玩家身份,返回已落库的 {@see Player}
* *
* 两种模式(互斥优先级:先判断 dev再走 JWT * 两种模式(互斥优先级:先判断 dev再走 JWT
* 1) 开发绕过:仅当 `APP_ENV=local` `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` * 1) 开发绕过: `LOTTERY_PLAYER_AUTH_DEV_BYPASS=true` 且运行环境为 `local` **`testing`**PHPUnit
* 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(勿上生产)。 * 接受 `Authorization: Bearer dev:{players.id}`,直连主键查库(**禁止在生产开启**)。
* 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT默认 HS256 * 2) 生产:使用 `MAIN_SITE_SSO_JWT_SECRET` 验签 JWT默认 HS256
* payload 读取 `site_code``site_player_id`(字段名可 env 覆盖)再查 players 表。 * payload 读取 `site_code``site_player_id`(字段名可 env 覆盖)再查 players 表。
* *
@@ -55,7 +55,7 @@ final class PlayerTokenResolver
private function devBypassAllowed(): bool private function devBypassAllowed(): bool
{ {
return (bool) config('lottery.player_auth.dev_bypass') return (bool) config('lottery.player_auth.dev_bypass')
&& app()->environment('local'); && app()->environment(['local', 'testing']);
} }
private function resolveDevToken(string $token): Player private function resolveDevToken(string $token): Player

6
lang/en/wallet.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
/** PRD 钱包段多语言NegotiateLotteryLocale 后由 __() 选用 */
return [
'invalid_currency' => 'Invalid currency code',
];

5
lang/ne/wallet.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'invalid_currency' => 'मुद्रा कोड अमान्य',
];

5
lang/zh/wallet.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'invalid_currency' => '币种参数不合法',
];

View File

@@ -32,5 +32,6 @@
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/> <env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="LOTTERY_PLAYER_AUTH_DEV_BYPASS" value="true"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -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\HealthController;
use App\Http\Controllers\Api\V1\Player\MeController; use App\Http\Controllers\Api\V1\Player\MeController;
use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController; use App\Http\Controllers\Api\V1\Player\PingController as PlayerPingController;
use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/* /*
| Laravel 默认 bootstrap 中为 api 文件加前缀 `api`,故实际 URL | Laravel bootstrap 中为本文件自动加前缀 `api` + 中间件组 `api`,故实际 URL
| /api/v1/health | /api/v1/health
| /api/v1/player/... | /api/v1/player/...
| /api/v1/wallet/...
| /api/v1/admin/... | /api/v1/admin/...
*/ */
@@ -17,19 +19,32 @@ Route::prefix('v1')->group(function (): void {
// 探活:无鉴权 // 探活:无鉴权
Route::get('health', HealthController::class)->name('api.v1.health'); Route::get('health', HealthController::class)->name('api.v1.health');
// 玩家前缀下:仅 ping 公开me 与 wallet/* 共用 lottery.player见下方大组
Route::prefix('player') Route::prefix('player')
->name('api.v1.player.') ->name('api.v1.player.')
->group(function (): void { ->group(function (): void {
// 不需 Bearer
Route::get('ping', PlayerPingController::class)->name('ping'); Route::get('ping', PlayerPingController::class)->name('ping');
});
// 需 BearerPlayerTokenResolver + EnsurePlayerApi /*
| 已登录玩家PRD 把路径拆成 `/v1/player/*` `/v1/wallet/*`,这里用同一中间件块避免重复写。
| 勿把 wallet 挂到 player 前缀下,否则会变成 /api/v1/player/wallet/...,与 PRD §10.1.1 不一致。
*/
Route::middleware('lottery.player')->group(function (): void { 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::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 // 后台 APIlottery.admin 内预留 Sanctum / RBAC
Route::middleware('lottery.admin') Route::middleware('lottery.admin')
->prefix('admin') ->prefix('admin')
->name('api.v1.admin.') ->name('api.v1.admin.')

View File

@@ -0,0 +1,49 @@
<?php
use App\Models\Player;
use App\Models\PlayerWallet;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('wallet balance creates lottery wallet row and returns zeros', function () {
$player = Player::query()->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);
});