From 36e50383ba26fa38f177ad425f699f09a54b95ab Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 26 May 2026 15:24:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=A5=A8=E6=8D=AE?= =?UTF-8?q?=E4=B8=8E=E9=92=B1=E5=8C=85=E6=9C=8D=E5=8A=A1=E7=9A=84=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E6=80=A7=E5=8F=8A=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 TicketItemShowController 与 TicketItemsIndexController 的响应中新增订单状态与失败原因字段。 更新 WalletLogsController:待对账列表支持按币种筛选。 在 TicketPlacementService 中引入幂等性校验,支持处理已退款订单的重复请求。 优化钱包相关操作的错误码与错误提示信息,提升问题定位与用户理解。 增强测试用例,验证票据下单流程中的新幂等性行为。 --- .../IdempotentTicketReplayException.php | 16 +++ .../V1/Ticket/TicketItemShowController.php | 3 + .../V1/Ticket/TicketItemsIndexController.php | 3 +- .../Api/V1/Wallet/WalletLogsController.php | 11 ++- app/Lottery/ErrorCode.php | 3 + .../Ticket/TicketPlacementService.php | 97 ++++++++++++++++--- app/Services/Ticket/TicketWalletService.php | 2 +- ...d_unique_client_trace_to_ticket_orders.php | 25 +++++ lang/en/wallet.php | 1 + lang/ne/wallet.php | 1 + lang/zh/wallet.php | 1 + tests/Feature/TicketBettingApiTest.php | 14 ++- 12 files changed, 154 insertions(+), 23 deletions(-) create mode 100644 app/Exceptions/IdempotentTicketReplayException.php create mode 100644 database/migrations/2026_05_26_120000_add_unique_client_trace_to_ticket_orders.php diff --git a/app/Exceptions/IdempotentTicketReplayException.php b/app/Exceptions/IdempotentTicketReplayException.php new file mode 100644 index 0000000..60fe152 --- /dev/null +++ b/app/Exceptions/IdempotentTicketReplayException.php @@ -0,0 +1,16 @@ + $item->ticket_no, 'order_no' => $item->order?->order_no, + 'order_status' => $item->order?->status, 'draw_no' => $draw?->draw_no, 'currency_code' => $item->order?->currency_code, 'play_code' => $item->play_code, @@ -154,6 +155,8 @@ final class TicketItemShowController extends Controller 'rebate_rate_snapshot' => (string) $item->rebate_rate_snapshot, 'actual_deduct_amount' => (int) $item->actual_deduct_amount, 'status' => $item->status, + 'fail_reason_code' => $item->fail_reason_code, + 'fail_reason_text' => $item->fail_reason_text, 'win_amount' => (int) $item->win_amount, 'jackpot_win_amount' => (int) $item->jackpot_win_amount, 'settled_at' => $item->settled_at?->toIso8601String(), diff --git a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php index 3dee03a..a26a643 100644 --- a/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php +++ b/app/Http/Controllers/Api/V1/Ticket/TicketItemsIndexController.php @@ -43,7 +43,7 @@ final class TicketItemsIndexController extends Controller ->where('ticket_items.player_id', $player->id) ->with([ 'draw:id,draw_no,business_date', - 'order:id,order_no,currency_code,created_at', + 'order:id,order_no,currency_code,status,created_at', ]) ->orderByDesc('ticket_items.id'); @@ -85,6 +85,7 @@ final class TicketItemsIndexController extends Controller return [ 'ticket_no' => $row->ticket_no, 'order_no' => $row->order?->order_no, + 'order_status' => $row->order?->status, 'draw_no' => $row->draw?->draw_no, 'currency_code' => $row->order?->currency_code, 'play_code' => $row->play_code, diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php index 565474a..9b225a7 100644 --- a/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php +++ b/app/Http/Controllers/Api/V1/Wallet/WalletLogsController.php @@ -39,7 +39,9 @@ final class WalletLogsController extends Controller $perPage = $this->perPage($request, 'size', 20, 100); $page = $this->page($request); - $pendingPayload = $this->pendingReconcilePayload((int) $player->id); + $currencyCode = strtoupper(trim((string) $request->query('currency', ''))); + + $pendingPayload = $this->pendingReconcilePayload((int) $player->id, $currencyCode); $bizFilter = $this->resolveBizTypeFilter((string) $request->query('type', '')); @@ -58,6 +60,10 @@ final class WalletLogsController extends Controller ->with('wallet') ->orderByDesc('id'); + if ($currencyCode !== '') { + $query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode)); + } + if ($bizFilter !== null) { $query->whereIn('biz_type', $bizFilter); } @@ -78,10 +84,11 @@ final class WalletLogsController extends Controller /** * @return list> */ - private function pendingReconcilePayload(int $playerId): array + private function pendingReconcilePayload(int $playerId, string $currencyCode = ''): array { return TransferOrder::query() ->where('player_id', $playerId) + ->when($currencyCode !== '', fn ($q) => $q->where('currency_code', $currencyCode)) ->where('status', 'pending_reconcile') ->orderByDesc('id') ->limit(50) diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index 8563f8c..0091191 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -77,6 +77,9 @@ enum ErrorCode: int */ case BetConfigStale = 2008; + /** 同 client_trace_id 重试,但原订单已退款/不可回放 */ + case BetIdempotentReplayRejected = 2009; + /** 风险池额度不足,号码已售罄 */ case RiskPoolSoldOut = 4001; diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index fc23311..b86a21b 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -10,7 +10,9 @@ use App\Models\WalletTxn; use App\Models\TicketOrder; use App\Models\PlayerWallet; use App\Models\TicketCombination; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; +use App\Exceptions\IdempotentTicketReplayException; use App\Exceptions\TicketOperationException; use App\Services\Jackpot\JackpotContributionService; use App\Services\Draw\DrawHallSnapshotBuilder; @@ -48,7 +50,7 @@ final class TicketPlacementService ->first(); if ($existing !== null) { - return $this->responseForOrder($existing, null); + return $this->resolveIdempotentReplay($existing); } } @@ -63,12 +65,14 @@ final class TicketPlacementService $expectedVersions = null; } - $placement = DB::transaction(function () use ( - $player, - $currencyCode, - $payload, - $expectedVersions - ): array { + try { + $placement = DB::transaction(function () use ( + $player, + $currencyCode, + $payload, + $expectedVersions, + $clientTraceId, + ): array { $draw = Draw::query() ->where('draw_no', (string) $payload['draw_id']) ->lockForUpdate() @@ -80,6 +84,18 @@ final class TicketPlacementService throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } + if ($clientTraceId !== null && $clientTraceId !== '') { + $existingInTx = TicketOrder::query() + ->where('player_id', $player->id) + ->where('draw_id', $draw->id) + ->where('client_trace_id', $clientTraceId) + ->lockForUpdate() + ->first(); + if ($existingInTx !== null) { + throw new IdempotentTicketReplayException($existingInTx); + } + } + $configVersions = $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); $evaluatedLines = []; @@ -142,13 +158,18 @@ final class TicketPlacementService ->where('currency_code', $currencyCode) ->lockForUpdate() ->first(); + if ($wallet !== null && (int) $wallet->status !== 0) { + throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); + } + $walletBalance = $wallet !== null ? (int) $wallet->balance : 0; $walletAvailable = $walletBalance - ($wallet !== null ? (int) $wallet->frozen_balance : 0); if ($walletAvailable < $totalActualDeduct) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } - $order = TicketOrder::query()->create([ + try { + $order = TicketOrder::query()->create([ 'order_no' => $this->newOrderNo(), 'player_id' => $player->id, 'draw_id' => $draw->id, @@ -163,7 +184,21 @@ final class TicketPlacementService 'play_config_version_no' => $configVersions['play_config_version_no'], 'odds_version_no' => $configVersions['odds_version_no'], 'risk_cap_version_no' => $configVersions['risk_cap_version_no'], - ]); + ]); + } catch (QueryException $e) { + if ($clientTraceId !== null && $this->isUniqueClientTraceViolation($e)) { + $existing = TicketOrder::query() + ->where('player_id', $player->id) + ->where('draw_id', $draw->id) + ->where('client_trace_id', $clientTraceId) + ->first(); + if ($existing !== null) { + throw new IdempotentTicketReplayException($existing); + } + } + + throw $e; + } $successfulItems = []; $failedItems = []; @@ -274,6 +309,9 @@ final class TicketPlacementService 'success_total_actual_deduct' => $successTotalActualDeduct, ]; }); + } catch (IdempotentTicketReplayException $e) { + return $this->resolveIdempotentReplay($e->order); + } $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail(); $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); @@ -341,7 +379,7 @@ final class TicketPlacementService $successCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'pending_draw')->count(); $pendingConfirmCount = TicketItem::query() ->where('order_id', $order->id) - ->whereIn('status', ['pending_confirm', 'partial_pending_confirm']) + ->where('status', 'pending_confirm') ->count(); $failureCount = TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(); if ($balanceAfter === null) { @@ -353,11 +391,14 @@ final class TicketPlacementService $balanceAfter = $walletTxn === null ? null : (int) $walletTxn->balance_after; } + $nowUtc = now()->utc(); + return [ 'order_no' => $order->order_no, 'draw' => [ 'draw_id' => $draw->draw_no, - 'status' => $draw->status, + 'status' => $this->drawHallSnapshot->effectiveHallDisplayStatus($draw, $nowUtc), + 'db_status' => $draw->status, ], 'summary' => [ 'order_status' => $order->status, @@ -389,6 +430,40 @@ final class TicketPlacementService ]; } + /** + * @return array + */ + private function resolveIdempotentReplay(TicketOrder $order): array + { + if (! $this->canIdempotentReplay($order)) { + throw new TicketOperationException( + 'idempotent_replay_rejected', + ErrorCode::BetIdempotentReplayRejected->value, + 409, + ); + } + + return $this->responseForOrder($order, null); + } + + private function canIdempotentReplay(TicketOrder $order): bool + { + return in_array($order->status, [ + 'placed', + 'partial_failed', + 'pending_confirm', + 'partial_pending_confirm', + ], true); + } + + private function isUniqueClientTraceViolation(QueryException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? ''; + + return in_array($sqlState, ['23000', '23505'], true) + || str_contains(strtolower($exception->getMessage()), 'uniq_ticket_orders_player_draw_trace'); + } + private function newOrderNo(): string { return 'TO'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 78c91ce..d5862e5 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -40,7 +40,7 @@ final class TicketWalletService } if ((int) $wallet->status !== 0) { - throw new TicketOperationException('wallet_frozen', ErrorCode::WalletExternalRejected->value); + throw new TicketOperationException('wallet_frozen', ErrorCode::WalletLotteryFrozen->value); } $before = (int) $wallet->balance; diff --git a/database/migrations/2026_05_26_120000_add_unique_client_trace_to_ticket_orders.php b/database/migrations/2026_05_26_120000_add_unique_client_trace_to_ticket_orders.php new file mode 100644 index 0000000..0885f2f --- /dev/null +++ b/database/migrations/2026_05_26_120000_add_unique_client_trace_to_ticket_orders.php @@ -0,0 +1,25 @@ +unique( + ['player_id', 'draw_id', 'client_trace_id'], + 'uniq_ticket_orders_player_draw_trace', + ); + }); + } + + public function down(): void + { + Schema::table('ticket_orders', function (Blueprint $table): void { + $table->dropUnique('uniq_ticket_orders_player_draw_trace'); + }); + } +}; diff --git a/lang/en/wallet.php b/lang/en/wallet.php index 2b90539..f6d55a8 100644 --- a/lang/en/wallet.php +++ b/lang/en/wallet.php @@ -22,5 +22,6 @@ return [ '2006' => 'The draw is not open for betting', '2007' => 'This play is not supported yet', '2008' => 'Odds or play settings changed; please preview again before placing', + '2009' => 'This order was refunded or cannot be resubmitted; close preview and place a new bet', '4001' => 'This number is sold out for the current draw', ]; diff --git a/lang/ne/wallet.php b/lang/ne/wallet.php index b6fe800..a576f6c 100644 --- a/lang/ne/wallet.php +++ b/lang/ne/wallet.php @@ -21,5 +21,6 @@ return [ '2006' => 'यो ड्र अहिले बेटिङका लागि खुला छैन', '2007' => 'यो खेल अझै समर्थित छैन', '2008' => 'अड्स वा सेटिङ परिवर्तन भयो; पुन: पूर्वावलोकन गर्नुहोस्', + '2009' => 'यो अर्डर फिर्ता भइसकेको छ वा पुन: पेश गर्न मिल्दैन; पूर्वावलोकन बन्द गरी नयाँ बेट गर्नुहोस्', '4001' => 'यो नम्बर हालको ड्रका लागि sold out भइसकेको छ', ]; diff --git a/lang/zh/wallet.php b/lang/zh/wallet.php index e1a2c0c..a0706c6 100644 --- a/lang/zh/wallet.php +++ b/lang/zh/wallet.php @@ -21,5 +21,6 @@ return [ '2006' => '当前期号不可下注', '2007' => '该玩法暂不支持下注', '2008' => '赔率或玩法配置已变更,请重新预览后再提交', + '2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注', '4001' => '该号码本期已售罄', ]; diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 742b055..aef4cd1 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -1131,12 +1131,12 @@ test('ticket place reverses wallet and releases risk when post deduction confirm ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); }); -test('ticket place idempotency replays refunded order for same trace', function (): void { +test('ticket place idempotency rejects replay for refunded order with same trace', function (): void { $player = ticketPlayerWithWallet(); $draw = ticketOpenDraw(); $trace = 'trace-refunded-replay'; - $order = TicketOrder::query()->create([ + TicketOrder::query()->create([ 'order_no' => 'TO-REFUNDED-IDEM', 'player_id' => $player->id, 'draw_id' => $draw->id, @@ -1152,14 +1152,12 @@ test('ticket place idempotency replays refunded order for same trace', function $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => $trace]); - $replay = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) - ->assertOk() - ->assertJsonPath('code', ErrorCode::Success->value) - ->json('data'); + ->assertStatus(409) + ->assertJsonPath('code', ErrorCode::BetIdempotentReplayRejected->value); - expect($replay['order_no'])->toBe($order->order_no) - ->and(TicketOrder::query()->count())->toBe(1) + expect(TicketOrder::query()->count())->toBe(1) ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); });