feat: 增强票据与钱包服务的幂等性及错误处理能力
在 TicketItemShowController 与 TicketItemsIndexController 的响应中新增订单状态与失败原因字段。 更新 WalletLogsController:待对账列表支持按币种筛选。 在 TicketPlacementService 中引入幂等性校验,支持处理已退款订单的重复请求。 优化钱包相关操作的错误码与错误提示信息,提升问题定位与用户理解。 增强测试用例,验证票据下单流程中的新幂等性行为。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user