$payload * @return array */ public function place(Player $player, array $payload): array { $currencyCode = strtoupper((string) $payload['currency_code']); $expectedVersions = $payload['expected_config_versions'] ?? null; if (is_array($expectedVersions)) { $expectedVersions = [ 'play_config_version_no' => (int) $expectedVersions['play_config_version_no'], 'odds_version_no' => (int) $expectedVersions['odds_version_no'], 'risk_cap_version_no' => (int) $expectedVersions['risk_cap_version_no'], ]; } else { $expectedVersions = null; } $placement = DB::transaction(function () use ( $player, $currencyCode, $payload, $expectedVersions ): array { $draw = Draw::query() ->where('draw_no', (string) $payload['draw_id']) ->lockForUpdate() ->first(); if ($draw === null) { throw new TicketOperationException('draw_not_found', ErrorCode::BetInvalidDraw->value); } if ($draw->status !== DrawStatus::Open->value || ($draw->close_time !== null && now()->greaterThanOrEqualTo($draw->close_time))) { throw new TicketOperationException('draw_closed', ErrorCode::DrawClosed->value); } $this->catalogResolver->lockActiveConfigVersionsForPlacement($expectedVersions); $evaluatedLines = []; $totalBet = 0; $totalRebate = 0; $totalActualDeduct = 0; $totalEstimatedPayout = 0; $closedPlayCleanupRows = []; foreach ((array) $payload['lines'] as $index => $line) { try { $resolved = $this->catalogResolver->resolve((string) $line['play_code'], $currencyCode); } catch (TicketOperationException $e) { if ($e->lotteryCode === ErrorCode::PlayModeClosed->value) { $closedPlayCleanupRows[] = [ 'client_line_no' => $index + 1, 'play_code' => (string) ($line['play_code'] ?? ''), ]; continue; } throw $e; } $evaluated = $this->ruleEngine->evaluateLine( (array) $line, $resolved['play_config'], $resolved['odds_items'], ); $locks = array_map(fn (array $combo): array => [ 'number_4d' => $combo['number_4d'], 'amount' => $combo['estimated_payout'], ], $evaluated['combinations']); // place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。 $evaluatedLines[] = $evaluated; $rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount']; $totalBet += (int) $evaluated['total_bet_amount']; $totalRebate += $rebateAmount; $totalActualDeduct += (int) $evaluated['actual_deduct_amount']; $totalEstimatedPayout += (int) $evaluated['estimated_max_payout']; } if ($closedPlayCleanupRows !== []) { throw new TicketOperationException( 'play_closed_need_cleanup', ErrorCode::PlayModeClosed->value, 400, [ 'cleanup_hint' => '玩法已关闭,相关注项已清理', 'cleanup_lines' => $closedPlayCleanupRows, ], ); } $walletBalance = (int) (PlayerWallet::query() ->where('player_id', $player->id) ->where('wallet_type', 'lottery') ->where('currency_code', $currencyCode) ->lockForUpdate() ->value('balance') ?? 0); if ($walletBalance < $totalActualDeduct) { throw new TicketOperationException('bet_insufficient_balance', ErrorCode::BetInsufficientBalance->value); } $order = TicketOrder::query()->create([ 'order_no' => $this->newOrderNo(), 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => $currencyCode, 'total_bet_amount' => $totalBet, 'total_rebate_amount' => $totalRebate, 'total_actual_deduct' => $totalActualDeduct, 'total_estimated_payout' => $totalEstimatedPayout, 'status' => 'pending', 'submit_source' => 'h5', 'client_trace_id' => $payload['client_trace_id'] ?? null, ]); $successfulItems = []; $failedItems = []; $successTotalBet = 0; $successTotalRebate = 0; $successTotalActualDeduct = 0; $successTotalEstimatedPayout = 0; $firstFailure = null; foreach ($evaluatedLines as $evaluated) { $item = TicketItem::query()->create([ 'ticket_no' => $this->newTicketNo(), 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => $evaluated['original_number'], 'normalized_number' => $this->normalizedNumberForStorage($evaluated), 'play_code' => $evaluated['play_code'], 'dimension' => $evaluated['dimension'], 'digit_slot' => $evaluated['digit_slot'], 'bet_mode' => $evaluated['bet_mode'], 'unit_bet_amount' => $evaluated['unit_bet_amount'], 'total_bet_amount' => $evaluated['total_bet_amount'], 'rebate_rate_snapshot' => $evaluated['rebate_rate_snapshot'], 'commission_rate_snapshot' => $evaluated['commission_rate_snapshot'], 'actual_deduct_amount' => 0, 'odds_snapshot_json' => $evaluated['odds_snapshot_json'], 'rule_snapshot_json' => $evaluated['rule_snapshot_json'], 'combination_count' => $evaluated['combination_count'], 'estimated_max_payout' => $evaluated['estimated_max_payout'], 'risk_locked_amount' => 0, 'status' => 'pending', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, ]); $locks = []; foreach ($evaluated['combinations'] as $combo) { TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => $combo['combination_no'], 'number_4d' => $combo['number_4d'], 'bet_amount' => $combo['bet_amount'], 'estimated_payout' => $combo['estimated_payout'], 'created_at' => now(), ]); $locks[] = [ 'number_4d' => $combo['number_4d'], 'amount' => $combo['estimated_payout'], ]; } try { $lockedAmount = $this->riskPoolService->acquire((int) $draw->id, $item, $locks); } catch (TicketOperationException $e) { if ($e->lotteryCode !== ErrorCode::RiskPoolSoldOut->value) { throw $e; } $firstFailure ??= $e; $item->forceFill([ 'status' => 'failed', 'fail_reason_code' => (string) $e->lotteryCode, 'fail_reason_text' => 'risk_sold_out', ])->save(); $failedItems[] = $item; continue; } $rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount']; $item->forceFill([ 'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'], 'risk_locked_amount' => $lockedAmount, 'status' => 'pending_confirm', ])->save(); $successfulItems[] = $item; $successTotalBet += (int) $evaluated['total_bet_amount']; $successTotalRebate += $rebateAmount; $successTotalActualDeduct += (int) $evaluated['actual_deduct_amount']; $successTotalEstimatedPayout += (int) $evaluated['estimated_max_payout']; } if ($successfulItems === []) { throw $firstFailure ?? new TicketOperationException('risk_sold_out', ErrorCode::RiskPoolSoldOut->value); } $order->forceFill([ 'total_bet_amount' => $successTotalBet, 'total_rebate_amount' => $successTotalRebate, 'total_actual_deduct' => $successTotalActualDeduct, 'total_estimated_payout' => $successTotalEstimatedPayout, 'status' => $failedItems === [] ? 'pending_confirm' : 'partial_pending_confirm', ])->save(); return [ 'order' => $order, 'draw_id' => (int) $draw->id, 'currency_code' => $currencyCode, 'successful_item_ids' => array_map( fn (TicketItem $item): int => (int) $item->id, $successfulItems, ), 'has_failed_items' => $failedItems !== [], 'success_total_actual_deduct' => $successTotalActualDeduct, ]; }); $order = TicketOrder::query()->whereKey($placement['order']->id)->firstOrFail(); $draw = Draw::query()->whereKey((int) $placement['draw_id'])->firstOrFail(); try { $balanceAfter = $this->ticketWalletService->deduct($player, (string) $placement['currency_code'], (int) $placement['success_total_actual_deduct'], $order); DB::transaction(function () use ($order, $draw, $placement): void { $successfulItems = TicketItem::query() ->whereIn('id', $placement['successful_item_ids']) ->lockForUpdate() ->get(); foreach ($successfulItems as $item) { $item->forceFill(['status' => 'success'])->save(); $this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, (string) $placement['currency_code']); } $order->forceFill([ 'status' => $placement['has_failed_items'] ? 'partial_failed' : 'placed', ])->save(); }); } catch (\Throwable $e) { DB::transaction(function () use ($order): void { $items = TicketItem::query() ->where('order_id', $order->id) ->where('status', 'pending_confirm') ->with('combinations') ->lockForUpdate() ->get(); foreach ($items as $item) { $locks = []; foreach ($item->combinations as $combo) { $locks[] = [ 'number_4d' => (string) $combo->number_4d, 'amount' => (int) $combo->estimated_payout, ]; } $this->riskPoolService->release((int) $order->draw_id, $item, $locks); $item->forceFill([ 'status' => 'refunded', 'fail_reason_code' => (string) ErrorCode::BetInsufficientBalance->value, 'fail_reason_text' => 'wallet_deduct_failed_refund', 'risk_locked_amount' => 0, ])->save(); } $order->forceFill(['status' => 'refunded'])->save(); }); throw $e; } $order = TicketOrder::query()->whereKey($order->id)->firstOrFail(); return [ 'order_no' => $order->order_no, 'draw' => [ 'draw_id' => $draw->draw_no, 'status' => $draw->status, ], 'summary' => [ 'total_bet_amount' => (int) $order->total_bet_amount, 'total_rebate_amount' => (int) $order->total_rebate_amount, 'total_actual_deduct' => (int) $order->total_actual_deduct, 'total_estimated_payout' => (int) $order->total_estimated_payout, 'success_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'success')->count(), 'failure_count' => TicketItem::query()->where('order_id', $order->id)->where('status', 'failed')->count(), ], 'balance_after' => $balanceAfter, 'items' => TicketItem::query() ->where('order_id', $order->id) ->orderBy('id') ->get() ->map(fn (TicketItem $item) => [ 'ticket_no' => $item->ticket_no, 'play_code' => $item->play_code, 'number' => $item->original_number, 'total_bet_amount' => (int) $item->total_bet_amount, 'actual_deduct_amount' => (int) $item->actual_deduct_amount, 'estimated_max_payout' => (int) $item->estimated_max_payout, 'combination_count' => (int) $item->combination_count, 'status' => $item->status, 'fail_reason_code' => $item->fail_reason_code, 'fail_reason_text' => $item->fail_reason_text, ])->values()->all(), ]; } private function newOrderNo(): string { return 'TO'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } private function newTicketNo(): string { return 'TK'.now()->format('YmdHis').str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); } /** * ticket_items.normalized_number 现为 char(4),短维度玩法需要统一填充。 * * @param array $evaluated */ private function normalizedNumberForStorage(array $evaluated): string { $number = (string) $evaluated['normalized_number']; if (strlen($number) === 4) { return $number; } return str_pad($number, 4, '0', STR_PAD_LEFT); } }