feat: 增强票据与钱包服务的幂等性及错误处理能力

在 TicketItemShowController 与 TicketItemsIndexController 的响应中新增订单状态与失败原因字段。
更新 WalletLogsController:待对账列表支持按币种筛选。
在 TicketPlacementService 中引入幂等性校验,支持处理已退款订单的重复请求。
优化钱包相关操作的错误码与错误提示信息,提升问题定位与用户理解。
增强测试用例,验证票据下单流程中的新幂等性行为。
This commit is contained in:
2026-05-26 15:24:54 +08:00
parent c8c90e3e94
commit 36e50383ba
12 changed files with 154 additions and 23 deletions

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions;
use App\Models\TicketOrder;
use RuntimeException;
/** 事务内发现同 trace 订单已存在,交由 {@see TicketPlacementService} 做幂等回放。 */
final class IdempotentTicketReplayException extends RuntimeException
{
public function __construct(
public readonly TicketOrder $order,
) {
parent::__construct('idempotent_ticket_replay');
}
}

View File

@@ -142,6 +142,7 @@ final class TicketItemShowController extends Controller
return ApiResponse::success([
'ticket_no' => $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(),

View File

@@ -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,

View File

@@ -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<array<string, mixed>>
*/
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)

View File

@@ -77,6 +77,9 @@ enum ErrorCode: int
*/
case BetConfigStale = 2008;
/** 同 client_trace_id 重试,但原订单已退款/不可回放 */
case BetIdempotentReplayRejected = 2009;
/** 风险池额度不足,号码已售罄 */
case RiskPoolSoldOut = 4001;

View File

@@ -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<string, mixed>
*/
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);

View File

@@ -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;

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Schema::table('ticket_orders', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -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',
];

View File

@@ -21,5 +21,6 @@ return [
'2006' => 'यो ड्र अहिले बेटिङका लागि खुला छैन',
'2007' => 'यो खेल अझै समर्थित छैन',
'2008' => 'अड्स वा सेटिङ परिवर्तन भयो; पुन: पूर्वावलोकन गर्नुहोस्',
'2009' => 'यो अर्डर फिर्ता भइसकेको छ वा पुन: पेश गर्न मिल्दैन; पूर्वावलोकन बन्द गरी नयाँ बेट गर्नुहोस्',
'4001' => 'यो नम्बर हालको ड्रका लागि sold out भइसकेको छ',
];

View File

@@ -21,5 +21,6 @@ return [
'2006' => '当前期号不可下注',
'2007' => '该玩法暂不支持下注',
'2008' => '赔率或玩法配置已变更,请重新预览后再提交',
'2009' => '该订单已退款或不可重复提交,请关闭预览后重新下注',
'4001' => '该号码本期已售罄',
];

View File

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