feat: 增强票据与钱包服务的幂等性及错误处理能力
在 TicketItemShowController 与 TicketItemsIndexController 的响应中新增订单状态与失败原因字段。 更新 WalletLogsController:待对账列表支持按币种筛选。 在 TicketPlacementService 中引入幂等性校验,支持处理已退款订单的重复请求。 优化钱包相关操作的错误码与错误提示信息,提升问题定位与用户理解。 增强测试用例,验证票据下单流程中的新幂等性行为。
This commit is contained in:
16
app/Exceptions/IdempotentTicketReplayException.php
Normal file
16
app/Exceptions/IdempotentTicketReplayException.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -77,6 +77,9 @@ enum ErrorCode: int
|
||||
*/
|
||||
case BetConfigStale = 2008;
|
||||
|
||||
/** 同 client_trace_id 重试,但原订单已退款/不可回放 */
|
||||
case BetIdempotentReplayRejected = 2009;
|
||||
|
||||
/** 风险池额度不足,号码已售罄 */
|
||||
case RiskPoolSoldOut = 4001;
|
||||
|
||||
|
||||
@@ -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