feat: 新增赔率版本和玩法配置版本的删除接口,支持删除草稿版本

This commit is contained in:
2026-05-15 15:30:40 +08:00
parent 5398af0a55
commit c0cd8be0fb
18 changed files with 574 additions and 125 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Config;
use App\Models\AdminUser;
use App\Models\OddsVersion;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Lottery\ConfigVersionStatus;
use App\Services\Config\OddsStreamService;
/** DELETE /api/v1/admin/config/odds-versions/{id} */
final class OddsVersionDestroyController extends Controller
{
public function __invoke(Request $request, int $id, OddsStreamService $service): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
/** @var OddsVersion $version */
$version = OddsVersion::query()->whereKey($id)->firstOrFail();
if ($version->status === ConfigVersionStatus::Active->value) {
return ApiResponse::error(
'cannot delete active config version',
ErrorCode::ConfigVersionCannotDeleteActive->value,
null,
400,
);
}
$service->deleteVersion($version, $admin, $request);
return ApiResponse::success(['deleted' => true]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Config;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Models\PlayConfigVersion;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Lottery\ConfigVersionStatus;
use App\Services\Config\PlayConfigStreamService;
/** DELETE /api/v1/admin/config/play-versions/{id} */
final class PlayConfigVersionDestroyController extends Controller
{
public function __invoke(Request $request, int $id, PlayConfigStreamService $service): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
/** @var PlayConfigVersion $version */
$version = PlayConfigVersion::query()->whereKey($id)->firstOrFail();
if ($version->status === ConfigVersionStatus::Active->value) {
return ApiResponse::error(
'cannot delete active config version',
ErrorCode::ConfigVersionCannotDeleteActive->value,
null,
400,
);
}
$service->deleteVersion($version, $admin, $request);
return ApiResponse::success(['deleted' => true]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Config;
use App\Models\AdminUser;
use App\Models\RiskCapVersion;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Lottery\ConfigVersionStatus;
use App\Services\Config\RiskCapStreamService;
/** DELETE /api/v1/admin/config/risk-cap-versions/{id} */
final class RiskCapVersionDestroyController extends Controller
{
public function __invoke(Request $request, int $id, RiskCapStreamService $service): JsonResponse
{
/** @var AdminUser $admin */
$admin = $request->lotteryAdmin();
/** @var RiskCapVersion $version */
$version = RiskCapVersion::query()->whereKey($id)->firstOrFail();
if ($version->status === ConfigVersionStatus::Active->value) {
return ApiResponse::error(
'cannot delete active config version',
ErrorCode::ConfigVersionCannotDeleteActive->value,
null,
400,
);
}
$service->deleteVersion($version, $admin, $request);
return ApiResponse::success(['deleted' => true]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Ticket;
use Illuminate\Foundation\Http\FormRequest;
abstract class TicketBetRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'draw_id' => ['required', 'string', 'max:32'],
'currency_code' => ['required', 'string', 'max:16'],
'client_trace_id' => ['nullable', 'string', 'max:64'],
'lines' => ['required', 'array', 'min:1', 'max:100'],
'lines.*.number' => ['required', 'string', 'max:32'],
'lines.*.play_code' => ['required', 'string', 'max:32'],
'lines.*.amount' => ['required', 'integer', 'min:1'],
'lines.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'],
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
];
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Http\Requests\Ticket;
final class TicketPlaceRequest extends TicketPreviewRequest
final class TicketPlaceRequest extends TicketBetRequest
{
/**
* @return array<string, mixed>

View File

@@ -2,30 +2,6 @@
namespace App\Http\Requests\Ticket;
use Illuminate\Foundation\Http\FormRequest;
final class TicketPreviewRequest extends FormRequest
final class TicketPreviewRequest extends TicketBetRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'draw_id' => ['required', 'string', 'max:32'],
'currency_code' => ['required', 'string', 'max:16'],
'client_trace_id' => ['nullable', 'string', 'max:64'],
'lines' => ['required', 'array', 'min:1', 'max:100'],
'lines.*.number' => ['required', 'string', 'max:32'],
'lines.*.play_code' => ['required', 'string', 'max:32'],
'lines.*.amount' => ['required', 'integer', 'min:1'],
'lines.*.digit_slot' => ['nullable', 'integer', 'min:0', 'max:3'],
'lines.*.dimension' => ['nullable', 'string', 'in:D2,D3,D4'],
];
}
}

View File

@@ -89,6 +89,9 @@ enum ErrorCode: int
/** 赔率 / 目录币种未启用或不可下注 */
case ConfigCurrencyInvalid = 2103;
/** 不能删除当前生效active的配置版本 */
case ConfigVersionCannotDeleteActive = 2104;
/* ========== 80008999 玩家 SSO / Bearer 鉴权 ========== */
/** 无 Bearer / 格式错误 / token 为空 */

View File

@@ -149,6 +149,26 @@ final class OddsStreamService
);
}
public function deleteVersion(OddsVersion $version, AdminUser $admin, ?Request $request = null): void
{
$before = $this->snapshotVersion($version);
DB::transaction(function () use ($version): void {
$version->delete();
});
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'odds',
actionCode: 'delete',
targetType: 'odds_version',
targetId: (string) $version->id,
beforeJson: $before,
afterJson: null,
);
}
/** @return array<string, mixed> */
private function snapshotVersion(OddsVersion $v): array
{

View File

@@ -147,6 +147,26 @@ final class PlayConfigStreamService
);
}
public function deleteVersion(PlayConfigVersion $version, AdminUser $admin, ?Request $request = null): void
{
$before = $this->snapshotVersion($version);
DB::transaction(function () use ($version): void {
$version->delete();
});
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'play_config',
actionCode: 'delete',
targetType: 'play_config_version',
targetId: (string) $version->id,
beforeJson: $before,
afterJson: null,
);
}
/** @return array<string, mixed> */
private function snapshotVersion(PlayConfigVersion $v): array
{

View File

@@ -131,6 +131,26 @@ final class RiskCapStreamService
);
}
public function deleteVersion(RiskCapVersion $version, AdminUser $admin, ?Request $request = null): void
{
$before = $this->snapshotVersion($version);
DB::transaction(function () use ($version): void {
$version->delete();
});
AuditLogger::recordForAdmin(
$admin,
$request,
moduleCode: 'risk_cap',
actionCode: 'delete',
targetType: 'risk_cap_version',
targetId: (string) $version->id,
beforeJson: $before,
afterJson: null,
);
}
/** @return array<string, mixed> */
private function snapshotVersion(RiskCapVersion $v): array
{

View File

@@ -4,6 +4,7 @@ namespace App\Services\Draw;
use Carbon\Carbon;
use App\Models\Draw;
use App\Models\RiskPool;
use App\Lottery\DrawStatus;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
@@ -144,6 +145,33 @@ final class DrawHallSnapshotBuilder
'seconds_remaining_in_cooldown' => $coolingRemain,
];
$riskAlerts = RiskPool::query()
->where('draw_id', $target->id)
->where(function ($q): void {
$q->where('sold_out_status', 1)
->orWhereRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) >= 0.8');
})
->orderByRaw('(locked_amount * 1.0 / NULLIF(total_cap_amount, 0)) DESC')
->orderByDesc('locked_amount')
->orderBy('normalized_number')
->limit(500)
->get(['normalized_number', 'total_cap_amount', 'locked_amount', 'remaining_amount', 'sold_out_status'])
->map(fn ($row) => [
'normalized_number' => (string) $row->normalized_number,
'total_cap_amount' => (int) $row->total_cap_amount,
'locked_amount' => (int) $row->locked_amount,
'remaining_amount' => (int) $row->remaining_amount,
'sold_out_status' => (int) $row->sold_out_status,
'is_sold_out' => (int) $row->sold_out_status === 1,
'usage_ratio' => (int) $row->total_cap_amount > 0
? round(((int) $row->locked_amount) / (int) $row->total_cap_amount, 6)
: null,
])
->values()
->all();
$payload['risk_pool_alerts'] = $riskAlerts;
if ($this->showsPublishedResults((string) $target->status)) {
$batchId = DrawResultBatch::query()
->where('draw_id', $target->id)

View File

@@ -41,12 +41,12 @@ final class TicketPlacementService
$expectedVersions = null;
}
$order = DB::transaction(function () use (
$placement = DB::transaction(function () use (
$player,
$currencyCode,
$payload,
$expectedVersions
): TicketOrder {
): array {
$draw = Draw::query()
->where('draw_no', (string) $payload['draw_id'])
->lockForUpdate()
@@ -103,7 +103,7 @@ final class TicketPlacementService
'client_trace_id' => $payload['client_trace_id'] ?? null,
]);
$this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order);
$balanceAfter = $this->ticketWalletService->deduct($player, $currencyCode, $totalActualDeduct, $order);
foreach ($evaluatedLines as $evaluated) {
$item = TicketItem::query()->create([
@@ -157,9 +157,15 @@ final class TicketPlacementService
$this->jackpotContribution->recordFromPlacedTicketItem($item, $draw, $currencyCode);
}
return $order;
return [
'order' => $order,
'balance_after' => $balanceAfter,
];
});
$order = $placement['order'];
$balanceAfter = $placement['balance_after'];
$draw = Draw::query()->whereKey($order->draw_id)->firstOrFail();
return [
@@ -174,6 +180,7 @@ final class TicketPlacementService
'total_actual_deduct' => (int) $order->total_actual_deduct,
'total_estimated_payout' => (int) $order->total_estimated_payout,
],
'balance_after' => $balanceAfter,
'items' => TicketItem::query()
->where('order_id', $order->id)
->orderBy('id')

View File

@@ -17,7 +17,7 @@ final class TicketWalletService
private const TXN_DIR_IN = 1;
public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): void
public function deduct(Player $player, string $currencyCode, int $amountMinor, TicketOrder $order): int
{
$wallet = PlayerWallet::query()
->where('player_id', $player->id)
@@ -65,6 +65,8 @@ final class TicketWalletService
'idempotent_key' => $order->client_trace_id,
'remark' => null,
]);
return $after;
}
/**

View File

@@ -137,28 +137,17 @@ final class LotteryTransferService
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $order, $main, $transferNo, $idempotentKey): void {
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$before = (int) $wallet->balance;
$after = $before + $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => self::BIZ_TRANSFER_IN,
'biz_no' => $transferNo,
'direction' => self::TXN_DIR_IN,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => $main->externalRefNo,
'idempotent_key' => $idempotentKey,
'remark' => null,
]);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_IN,
direction: self::TXN_DIR_IN,
amountMinor: $amountMinor,
bizNo: $transferNo,
externalRefNo: $main->externalRefNo,
idempotentKey: $idempotentKey,
remark: null,
deltaSign: 1,
);
$order->forceFill([
'status' => self::ST_SUCCESS,
@@ -222,34 +211,18 @@ final class LotteryTransferService
try {
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey): void {
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$before = (int) $wallet->balance;
if ($before < $amountMinor) {
throw new WalletOperationException(
'insufficient_balance',
ErrorCode::WalletInsufficientBalance->value,
);
}
$after = $before - $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => self::BIZ_TRANSFER_OUT,
'biz_no' => $transferNo,
'direction' => self::TXN_DIR_OUT,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => null,
]);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_OUT,
direction: self::TXN_DIR_OUT,
amountMinor: $amountMinor,
bizNo: $transferNo,
externalRefNo: null,
idempotentKey: $idempotentKey,
remark: null,
deltaSign: -1,
requireBalance: true,
);
});
} catch (WalletOperationException $e) {
if ($e->lotteryCode === ErrorCode::WalletInsufficientBalance->value) {
@@ -292,28 +265,17 @@ final class LotteryTransferService
DB::transaction(function () use ($player, $currencyCode, $amountMinor, $transferNo, $idempotentKey, $order, $main): void {
$wallet = $this->lockLotteryWallet($player, $currencyCode);
$before = (int) $wallet->balance;
$after = $before + $amountMinor;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $player->id,
'wallet_id' => $wallet->id,
'biz_type' => self::BIZ_TRANSFER_OUT_REFUND,
'biz_no' => $transferNo,
'direction' => self::TXN_DIR_IN,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => $idempotentKey,
'remark' => 'withdraw_failed_refund',
]);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_TRANSFER_OUT_REFUND,
direction: self::TXN_DIR_IN,
amountMinor: $amountMinor,
bizNo: $transferNo,
externalRefNo: null,
idempotentKey: $idempotentKey,
remark: 'withdraw_failed_refund',
deltaSign: 1,
);
$order->forceFill([
'status' => self::ST_FAILED,
@@ -386,28 +348,17 @@ final class LotteryTransferService
if ($order->direction === self::DIR_OUT) {
DB::transaction(function () use ($order, $remark): void {
$wallet = $this->lockLotteryWalletById($order->player_id, $order->currency_code);
$before = (int) $wallet->balance;
$after = $before + (int) $order->amount;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => (int) $order->player_id,
'wallet_id' => $wallet->id,
'biz_type' => self::BIZ_REVERSAL,
'biz_no' => $order->transfer_no,
'direction' => self::TXN_DIR_IN,
'amount' => (int) $order->amount,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => null,
'idempotent_key' => null,
'remark' => $remark ?: 'reversal_pending_reconcile',
]);
$this->postLotteryWalletMovement(
wallet: $wallet,
bizType: self::BIZ_REVERSAL,
direction: self::TXN_DIR_IN,
amountMinor: (int) $order->amount,
bizNo: $order->transfer_no,
externalRefNo: null,
idempotentKey: null,
remark: $remark ?: 'reversal_pending_reconcile',
deltaSign: 1,
);
$order->forceFill([
'status' => self::ST_REVERSED,
@@ -701,6 +652,61 @@ final class LotteryTransferService
return 'WX_'.Str::lower(str_replace('-', '', Str::uuid()->toString()));
}
/**
* 统一执行彩票钱包余额变更并记录流水。
*
* @return array{before: int, after: int}
*/
private function postLotteryWalletMovement(
PlayerWallet $wallet,
string $bizType,
int $direction,
int $amountMinor,
string $bizNo,
?string $externalRefNo,
?string $idempotentKey,
?string $remark,
int $deltaSign,
bool $requireBalance = false,
): array {
$before = (int) $wallet->balance;
if ($requireBalance && $deltaSign < 0 && $before < $amountMinor) {
throw new WalletOperationException(
'insufficient_balance',
ErrorCode::WalletInsufficientBalance->value,
);
}
$delta = $amountMinor * $deltaSign;
$after = $before + $delta;
$wallet->forceFill([
'balance' => $after,
'version' => (int) $wallet->version + 1,
])->save();
WalletTxn::query()->create([
'txn_no' => $this->newTxnNo(),
'player_id' => $wallet->player_id,
'wallet_id' => $wallet->id,
'biz_type' => $bizType,
'biz_no' => $bizNo,
'direction' => $direction,
'amount' => $amountMinor,
'balance_before' => $before,
'balance_after' => $after,
'status' => self::TXN_POSTED,
'external_ref_no' => $externalRefNo,
'idempotent_key' => $idempotentKey,
'remark' => $remark,
]);
return [
'before' => $before,
'after' => $after,
];
}
private function failedOrderToException(TransferOrder $order): WalletOperationException
{
if (($order->fail_reason ?? '') === 'insufficient_balance') {