From a9d0f39a9c8a24c7839b7808635456e846d9d1c5 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 27 May 2026 09:57:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=BC=80=E5=A5=96?= =?UTF-8?q?=E4=B8=8E=E8=AE=BE=E7=BD=AE=E6=8E=A7=E5=88=B6=E5=99=A8=E7=9A=84?= =?UTF-8?q?=E5=B8=81=E7=A7=8D=E6=94=AF=E6=8C=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 CurrencyResolver,用于在 DrawCurrentController、DrawResultShowController 与 DrawResultsIndexController 中统一处理币种代码解析。 更新 DrawHallSnapshotBuilder 与 DrawResultViewService 的构建方法,新增币种代码参数支持,确保开奖相关功能中的币种处理一致性。 增强 SettingIndexController:新增允许访问的 KV 配置分组校验。 在 OddsStreamService、PlayConfigStreamService 与 RiskCapStreamService 中新增广播功能,用于在玩法目录变更时推送更新通知。 新增测试用例,验证风险限额发布的广播行为。 --- app/Events/PlayCatalogUpdatedBroadcast.php | 58 +++++++++++++++++++ .../Api/V1/Draw/DrawCurrentController.php | 5 +- .../Api/V1/Draw/DrawResultShowController.php | 4 +- .../V1/Draw/DrawResultsIndexController.php | 4 +- .../Api/V1/Setting/SettingIndexController.php | 35 +++++++---- app/Services/Config/OddsStreamService.php | 6 ++ .../Config/PlayConfigStreamService.php | 6 ++ app/Services/Config/RiskCapStreamService.php | 11 ++++ app/Services/Draw/DrawHallSnapshotBuilder.php | 17 +++++- app/Services/Draw/DrawResultViewService.php | 23 ++++++-- .../Draw/LotteryHallRealtimeBroadcaster.php | 25 ++++++++ tests/Feature/PlayerRealtimeBroadcastTest.php | 27 +++++++++ tests/Feature/PublicSettingsApiTest.php | 37 ++++++++++++ 13 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 app/Events/PlayCatalogUpdatedBroadcast.php create mode 100644 tests/Feature/PublicSettingsApiTest.php diff --git a/app/Events/PlayCatalogUpdatedBroadcast.php b/app/Events/PlayCatalogUpdatedBroadcast.php new file mode 100644 index 0000000..7fd4c6a --- /dev/null +++ b/app/Events/PlayCatalogUpdatedBroadcast.php @@ -0,0 +1,58 @@ +|null $meta + */ + public function __construct( + public readonly string $module, + public readonly int $versionId, + public readonly string $versionLabel, + public readonly ?array $meta, + public readonly int $emittedAtMs, + ) {} + + /** @return array */ + public function broadcastOn(): array + { + return [new Channel('lottery-hall')]; + } + + public function broadcastAs(): string + { + return 'play.catalog_updated'; + } + + /** + * @return array{module: string, version_id: int, version_label: string, meta: array|null, message: string, emitted_at_ms: int} + */ + public function broadcastWith(): array + { + return [ + 'module' => $this->module, + 'version_id' => $this->versionId, + 'version_label' => $this->versionLabel, + 'meta' => $this->meta, + 'message' => '玩法配置已更新,请刷新后下注', + 'emitted_at_ms' => $this->emittedAtMs, + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php b/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php index 6d01d4d..938f0f0 100644 --- a/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php +++ b/app/Http/Controllers/Api/V1/Draw/DrawCurrentController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1\Draw; use App\Support\ApiResponse; use Illuminate\Http\Request; +use App\Support\CurrencyResolver; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Services\Draw\DrawHallSnapshotBuilder; @@ -19,9 +20,11 @@ final class DrawCurrentController extends Controller public function __invoke(Request $request): JsonResponse { + $currencyCode = CurrencyResolver::resolve($request, $request->lotteryPlayer()); + return ApiResponse::success([ 'server_now_ms' => (int) floor(microtime(true) * 1000), - 'data' => $this->snapshot->build(), + 'data' => $this->snapshot->build(null, $currencyCode), ]); } } diff --git a/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php b/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php index 7712e96..1090d1e 100644 --- a/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php +++ b/app/Http/Controllers/Api/V1/Draw/DrawResultShowController.php @@ -6,6 +6,7 @@ use App\Models\Draw; use App\Lottery\ErrorCode; use App\Support\ApiResponse; use Illuminate\Http\Request; +use App\Support\CurrencyResolver; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; use App\Services\Draw\DrawResultViewService; @@ -42,7 +43,8 @@ final class DrawResultShowController extends Controller ); } - $payload = $this->viewer->summarizeDraw($draw); + $currencyCode = CurrencyResolver::resolve($request, $request->lotteryPlayer()); + $payload = $this->viewer->summarizeDraw($draw, $currencyCode); if ($payload === null) { return ApiResponse::error( trans('api.not_found', [], $request->lotteryLocale()), diff --git a/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php b/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php index dcf7c07..29885ce 100644 --- a/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php +++ b/app/Http/Controllers/Api/V1/Draw/DrawResultsIndexController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Draw; use App\Models\Draw; use App\Support\ApiResponse; use Illuminate\Http\Request; +use App\Support\CurrencyResolver; use App\Models\DrawResultBatch; use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; @@ -52,7 +53,8 @@ final class DrawResultsIndexController extends Controller ->orderByDesc('draw_time') ->paginate(perPage: $perPage, columns: ['*'], pageName: 'page', page: $page); - $decorated = $this->viewer->decoratePaginator($paginator); + $currencyCode = CurrencyResolver::resolve($request, $request->lotteryPlayer()); + $decorated = $this->viewer->decoratePaginator($paginator, $currencyCode); return ApiResponse::success([ 'items' => $decorated->items(), diff --git a/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php b/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php index 8ebcc40..46ce746 100644 --- a/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php +++ b/app/Http/Controllers/Api/V1/Setting/SettingIndexController.php @@ -4,31 +4,44 @@ namespace App\Http\Controllers\Api\V1\Setting; use App\Http\Controllers\Controller; use App\Models\LotterySetting; +use App\Lottery\ErrorCode; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; /** - * 玩家端/公开:读取 KV 配置(公开访问) + * 玩家端/公开:读取 KV 配置(公开访问;仅允许白名单分组)。 */ final class SettingIndexController extends Controller { + /** @var list */ + private const PUBLIC_GROUPS = [ + 'frontend', + ]; + public function __invoke(Request $request): JsonResponse { $group = $request->query('group'); - - $query = LotterySetting::query()->orderBy('setting_key'); - if (! empty($group)) { - $query->where('group_name', $group); + if (! is_string($group) || $group === '' || ! in_array($group, self::PUBLIC_GROUPS, true)) { + return ApiResponse::error( + 'invalid_settings_group', + ErrorCode::ClientHttpError->value, + ['allowed_groups' => self::PUBLIC_GROUPS], + 400, + ); } - $items = $query->get()->map(fn (LotterySetting $s): array => [ - 'key' => $s->setting_key, - 'value' => $s->value_json, - 'group' => $s->group_name, - 'description' => $s->description_zh, - ]); + $items = LotterySetting::query() + ->where('group_name', $group) + ->orderBy('setting_key') + ->get() + ->map(fn (LotterySetting $s): array => [ + 'key' => $s->setting_key, + 'value' => $s->value_json, + 'group' => $s->group_name, + 'description' => $s->description_zh, + ]); return ApiResponse::success(['items' => $items]); } diff --git a/app/Services/Config/OddsStreamService.php b/app/Services/Config/OddsStreamService.php index 277ddf5..3a7a21e 100644 --- a/app/Services/Config/OddsStreamService.php +++ b/app/Services/Config/OddsStreamService.php @@ -152,6 +152,12 @@ final class OddsStreamService 'v'.(string) $draft->version_no, ['version_no' => (int) $draft->version_no], ); + $this->hallRealtime->notifyPlayCatalogUpdated( + 'odds', + (int) $draft->id, + 'v'.(string) $draft->version_no, + ['version_no' => (int) $draft->version_no], + ); AuditLogger::recordForAdmin( $admin, diff --git a/app/Services/Config/PlayConfigStreamService.php b/app/Services/Config/PlayConfigStreamService.php index 09e0668..858d892 100644 --- a/app/Services/Config/PlayConfigStreamService.php +++ b/app/Services/Config/PlayConfigStreamService.php @@ -214,6 +214,12 @@ final class PlayConfigStreamService $after = $this->snapshotVersion($draft->fresh(['items'])); $this->broadcastToggleDiffs($currentItems, $draft->items()->get()); + $this->hallRealtime->notifyPlayCatalogUpdated( + 'play_config', + (int) $draft->id, + 'v'.(string) $draft->version_no, + ['version_no' => (int) $draft->version_no], + ); AuditLogger::recordForAdmin( $admin, diff --git a/app/Services/Config/RiskCapStreamService.php b/app/Services/Config/RiskCapStreamService.php index e306f51..59ee18a 100644 --- a/app/Services/Config/RiskCapStreamService.php +++ b/app/Services/Config/RiskCapStreamService.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use App\Services\AuditLogger; use App\Models\RiskCapVersion; use Illuminate\Support\Facades\DB; +use App\Services\Draw\LotteryHallRealtimeBroadcaster; use App\Http\Middleware\RecordAdminApiAudit; use App\Lottery\ConfigVersionStatus; use Illuminate\Validation\ValidationException; @@ -16,6 +17,10 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; /** 后台:风控封顶版本({@see risk_cap_versions} / {@see risk_cap_items}) */ final class RiskCapStreamService { + public function __construct( + private readonly LotteryHallRealtimeBroadcaster $hallRealtime, + ) {} + /** @return LengthAwarePaginator */ public function paginate(?string $status, int $perPage, int $page = 1): LengthAwarePaginator { @@ -119,6 +124,12 @@ final class RiskCapStreamService }); $after = $this->snapshotVersion($draft->fresh(['items'])); + $this->hallRealtime->notifyPlayCatalogUpdated( + 'risk_cap', + (int) $draft->id, + 'v'.(string) $draft->version_no, + ['version_no' => (int) $draft->version_no], + ); AuditLogger::recordForAdmin( $admin, diff --git a/app/Services/Draw/DrawHallSnapshotBuilder.php b/app/Services/Draw/DrawHallSnapshotBuilder.php index 9a4f40e..f3008d9 100644 --- a/app/Services/Draw/DrawHallSnapshotBuilder.php +++ b/app/Services/Draw/DrawHallSnapshotBuilder.php @@ -199,9 +199,10 @@ final class DrawHallSnapshotBuilder /** * @return array|null */ - public function build(?Carbon $nowUtc = null): ?array + public function build(?Carbon $nowUtc = null, ?string $currencyCode = null): ?array { $nowUtc = ($nowUtc ?? Carbon::now())->utc(); + $currencyCode = $this->normalizeCurrencyCode($currencyCode); $target = $this->resolveHallTarget($nowUtc); @@ -255,7 +256,8 @@ final class DrawHallSnapshotBuilder 'seconds_to_draw' => $secsToDraw, 'cooling_end_time' => $target->cooling_end_time?->toIso8601String(), 'seconds_remaining_in_cooldown' => $coolingRemain, - 'jackpot' => $this->jackpotSummary->summary('NPR'), + 'jackpot_currency_code' => $currencyCode, + 'jackpot' => $this->jackpotSummary->summary($currencyCode), ]; $riskAlerts = RiskPool::query() @@ -320,4 +322,15 @@ final class DrawHallSnapshotBuilder return $payload; } + + private function normalizeCurrencyCode(?string $currencyCode): string + { + $code = strtoupper(substr(trim((string) ($currencyCode ?? '')), 0, 16)); + + if ($code !== '') { + return $code; + } + + return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16)); + } } diff --git a/app/Services/Draw/DrawResultViewService.php b/app/Services/Draw/DrawResultViewService.php index 42d313b..ab3d4e0 100644 --- a/app/Services/Draw/DrawResultViewService.php +++ b/app/Services/Draw/DrawResultViewService.php @@ -75,8 +75,9 @@ final class DrawResultViewService * * @return array|null */ - public function summarizeDraw(Draw $draw): ?array + public function summarizeDraw(Draw $draw, ?string $currencyCode = null): ?array { + $currencyCode = $this->normalizeCurrencyCode($currencyCode); $version = (int) $draw->current_result_version; if ($version < 1) { return null; @@ -115,7 +116,8 @@ final class DrawResultViewService 'draw_time_iso' => $draw->draw_time?->toIso8601String(), 'result_version' => $version, 'result_source' => $draw->result_source, - 'jackpot' => $this->jackpotSummary->summary('NPR'), + 'jackpot_currency_code' => $currencyCode, + 'jackpot' => $this->jackpotSummary->summary($currencyCode), 'results' => $numbers, 'result_items' => $items->map(fn (DrawResultItem $r) => [ 'prize_type' => $r->prize_type, @@ -132,10 +134,10 @@ final class DrawResultViewService /** * @param LengthAwarePaginator $paginator */ - public function decoratePaginator(LengthAwarePaginator $paginator): LengthAwarePaginator + public function decoratePaginator(LengthAwarePaginator $paginator, ?string $currencyCode = null): LengthAwarePaginator { - $collection = $paginator->getCollection()->map(function (Draw $draw): ?array { - return $this->summarizeDraw($draw); + $collection = $paginator->getCollection()->map(function (Draw $draw) use ($currencyCode): ?array { + return $this->summarizeDraw($draw, $currencyCode); })->filter(); $paginator->setCollection($collection->values()); @@ -183,4 +185,15 @@ final class DrawResultViewService 'next_draw_no' => $nextNo, ]; } + + private function normalizeCurrencyCode(?string $currencyCode): string + { + $code = strtoupper(substr(trim((string) ($currencyCode ?? '')), 0, 16)); + + if ($code !== '') { + return $code; + } + + return strtoupper(substr(trim((string) config('lottery.default_currency', 'NPR')), 0, 16)); + } } diff --git a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php index e5d8944..12766a3 100644 --- a/app/Services/Draw/LotteryHallRealtimeBroadcaster.php +++ b/app/Services/Draw/LotteryHallRealtimeBroadcaster.php @@ -3,6 +3,7 @@ namespace App\Services\Draw; use App\Events\OddsUpdateBroadcast; +use App\Events\PlayCatalogUpdatedBroadcast; use App\Events\PlayToggleBroadcast; use App\Events\RiskSoldOutBroadcast; use App\Events\RiskWarningBroadcast; @@ -118,6 +119,30 @@ final class LotteryHallRealtimeBroadcaster )); } + /** + * `play.catalog_updated` —— 玩法/赔率/封顶版本发布(全量目录变更)。 + * + * @param string $module play_config|odds|risk_cap + */ + public function notifyPlayCatalogUpdated( + string $module, + int $versionId, + string $versionLabel, + ?array $meta = null, + ): void { + if (! $this->driverSupportsRealtime()) { + return; + } + + broadcast(new PlayCatalogUpdatedBroadcast( + $module, + $versionId, + $versionLabel, + $meta, + (int) floor(microtime(true) * 1000), + )); + } + /** `odds.update` —— 赔率变更 */ public function notifyOddsUpdate(int $versionId, string $versionName, ?array $diff = null): void { diff --git a/tests/Feature/PlayerRealtimeBroadcastTest.php b/tests/Feature/PlayerRealtimeBroadcastTest.php index 3d9da68..eb6504a 100644 --- a/tests/Feature/PlayerRealtimeBroadcastTest.php +++ b/tests/Feature/PlayerRealtimeBroadcastTest.php @@ -1,8 +1,10 @@ create([ + 'username' => 'risk_cap_admin', + 'name' => 'Risk Cap QA', + 'email' => null, + 'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + $draft = app(RiskCapStreamService::class)->createDraft($admin, 'test', null); + app(RiskCapStreamService::class)->replaceItems($draft, [ + [ + 'normalized_number' => '1234', + 'cap_amount' => 1_000_000, + 'cap_type' => 'default', + ], + ], $admin); + app(RiskCapStreamService::class)->publish($draft, $admin); + + Event::assertDispatched(PlayCatalogUpdatedBroadcast::class); +}); + test('transfer in dispatches balance update after success', function (): void { Event::fake([BalanceUpdateBroadcast::class]); diff --git a/tests/Feature/PublicSettingsApiTest.php b/tests/Feature/PublicSettingsApiTest.php new file mode 100644 index 0000000..278c3d4 --- /dev/null +++ b/tests/Feature/PublicSettingsApiTest.php @@ -0,0 +1,37 @@ +seed(CurrencySeeder::class); +}); + +test('public settings requires allowed group', function (): void { + $this->getJson('/api/v1/settings') + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::ClientHttpError->value); + + $this->getJson('/api/v1/settings?group=wallet') + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::ClientHttpError->value); +}); + +test('public settings returns frontend group only', function (): void { + \App\Models\LotterySetting::query()->updateOrCreate( + ['setting_key' => 'frontend.play_rules_html_zh'], + [ + 'group_name' => 'frontend', + 'value_json' => '

rules

', + 'description_zh' => '规则', + ], + ); + + $this->getJson('/api/v1/settings?group=frontend') + ->assertOk() + ->assertJsonPath('code', ErrorCode::Success->value) + ->assertJsonFragment(['key' => 'frontend.play_rules_html_zh']); +});