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;