feat: 新增赔率版本和玩法配置版本的删除接口,支持删除草稿版本
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Ticket/TicketBetRequest.php
Normal file
31
app/Http/Requests/Ticket/TicketBetRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Ticket;
|
||||
|
||||
final class TicketPlaceRequest extends TicketPreviewRequest
|
||||
final class TicketPlaceRequest extends TicketBetRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ enum ErrorCode: int
|
||||
/** 赔率 / 目录币种未启用或不可下注 */
|
||||
case ConfigCurrencyInvalid = 2103;
|
||||
|
||||
/** 不能删除当前生效(active)的配置版本 */
|
||||
case ConfigVersionCannotDeleteActive = 2104;
|
||||
|
||||
/* ========== 8000–8999 玩家 SSO / Bearer 鉴权 ========== */
|
||||
|
||||
/** 无 Bearer / 格式错误 / token 为空 */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -17,6 +17,8 @@ final class DatabaseSeeder extends Seeder
|
||||
PlayTypeSeeder::class,
|
||||
OperationalConfigV1Seeder::class,
|
||||
OddsPrizeScopesBackfillSeeder::class,
|
||||
/** 对齐玩法目录与 active/draft 配置行(修复历史子集种子) */
|
||||
PlayOperationalAlignmentSeeder::class,
|
||||
LotterySettingsSeeder::class,
|
||||
]);
|
||||
|
||||
|
||||
140
database/seeders/PlayOperationalAlignmentSeeder.php
Normal file
140
database/seeders/PlayOperationalAlignmentSeeder.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Currency;
|
||||
use App\Models\OddsItem;
|
||||
use App\Models\PlayType;
|
||||
use App\Models\OddsVersion;
|
||||
use App\Models\PlayConfigItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\PlayConfigVersion;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\OddsStandardScopes;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
|
||||
/**
|
||||
* 将「玩法目录」与已存在的玩法配置 / 赔率版本对齐(幂等,可反复执行)。
|
||||
*
|
||||
* 典型场景:`play_types` 已扩展,但某条 active/draft 的 `play_config_items` 或 `odds_items`
|
||||
* 仍是早期子集,后台出现大量「无配置行」或赔率缺档。
|
||||
*
|
||||
* 执行:php artisan db:seed --class=PlayOperationalAlignmentSeeder
|
||||
*/
|
||||
final class PlayOperationalAlignmentSeeder extends Seeder
|
||||
{
|
||||
private const MIN_BET = 100;
|
||||
|
||||
private const MAX_BET = 500_000_000;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(PlayTypeSeeder::class);
|
||||
|
||||
DB::transaction(function (): void {
|
||||
$open = [ConfigVersionStatus::Active->value, ConfigVersionStatus::Draft->value];
|
||||
|
||||
foreach (PlayConfigVersion::query()->whereIn('status', $open)->orderBy('id')->cursor() as $version) {
|
||||
$this->syncPlayConfigItems($version);
|
||||
}
|
||||
|
||||
foreach (OddsVersion::query()->whereIn('status', $open)->orderBy('id')->cursor() as $version) {
|
||||
$this->syncOddsItemsForAllPlayTypes($version);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function syncPlayConfigItems(PlayConfigVersion $version): void
|
||||
{
|
||||
$vid = (int) $version->id;
|
||||
$existing = PlayConfigItem::query()
|
||||
->where('version_id', $vid)
|
||||
->pluck('play_code')
|
||||
->all();
|
||||
$have = array_fill_keys($existing, true);
|
||||
|
||||
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->cursor() as $pt) {
|
||||
if (isset($have[$pt->play_code])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PlayConfigItem::query()->create([
|
||||
'version_id' => $vid,
|
||||
'play_code' => $pt->play_code,
|
||||
'is_enabled' => (bool) $pt->is_enabled,
|
||||
'min_bet_amount' => self::MIN_BET,
|
||||
'max_bet_amount' => self::MAX_BET,
|
||||
'display_order' => (int) $pt->sort_order,
|
||||
'rule_text_zh' => null,
|
||||
'rule_text_en' => null,
|
||||
'rule_text_ne' => null,
|
||||
'extra_config_json' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncOddsItemsForAllPlayTypes(OddsVersion $version): void
|
||||
{
|
||||
$vid = (int) $version->id;
|
||||
|
||||
$currencies = OddsItem::query()
|
||||
->where('version_id', $vid)
|
||||
->distinct()
|
||||
->pluck('currency_code')
|
||||
->filter(static fn ($c) => is_string($c) && $c !== '')
|
||||
->values();
|
||||
|
||||
if ($currencies->isEmpty()) {
|
||||
$fallback = Currency::query()
|
||||
->where('is_bettable', true)
|
||||
->where('is_enabled', true)
|
||||
->orderBy('code')
|
||||
->value('code');
|
||||
if ($fallback === null || $fallback === '') {
|
||||
return;
|
||||
}
|
||||
$currencies = collect([strtoupper((string) $fallback)]);
|
||||
}
|
||||
|
||||
foreach ($currencies as $currencyCode) {
|
||||
$currencyCode = strtoupper((string) $currencyCode);
|
||||
|
||||
foreach (PlayType::query()->orderBy('sort_order')->orderBy('play_code')->cursor() as $pt) {
|
||||
$playCode = $pt->play_code;
|
||||
|
||||
$anchor = OddsItem::query()
|
||||
->where('version_id', $vid)
|
||||
->where('play_code', $playCode)
|
||||
->where('currency_code', $currencyCode)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$rebate = (float) ($anchor?->rebate_rate ?? 0);
|
||||
$commission = (float) ($anchor?->commission_rate ?? 0);
|
||||
|
||||
foreach (OddsStandardScopes::PRESET_ODDS_BY_SCOPE as $scope => $oddsValue) {
|
||||
$exists = OddsItem::query()
|
||||
->where('version_id', $vid)
|
||||
->where('play_code', $playCode)
|
||||
->where('currency_code', $currencyCode)
|
||||
->where('prize_scope', $scope)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
OddsItem::query()->create([
|
||||
'version_id' => $vid,
|
||||
'play_code' => $playCode,
|
||||
'prize_scope' => $scope,
|
||||
'odds_value' => $oddsValue,
|
||||
'rebate_rate' => $rebate,
|
||||
'commission_rate' => $commission,
|
||||
'currency_code' => $currencyCode,
|
||||
'extra_config_json' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\Config\OddsItemsReplaceController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionPublishController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\OddsVersionDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionShowController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapItemsReplaceController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionIndexController;
|
||||
@@ -18,6 +19,8 @@ use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigItemsReplaceController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionIndexController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionStoreController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionPublishController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\PlayConfigVersionDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\Config\RiskCapVersionDestroyController;
|
||||
use App\Http\Controllers\Api\V1\Admin\AdminSettingController;
|
||||
|
||||
/**
|
||||
@@ -84,6 +87,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r
|
||||
Route::post('play-versions/{id}/publish', PlayConfigVersionPublishController::class)
|
||||
->whereNumber('id')
|
||||
->name('play-versions.publish');
|
||||
Route::delete('play-versions/{id}', PlayConfigVersionDestroyController::class)
|
||||
->whereNumber('id')
|
||||
->name('play-versions.destroy');
|
||||
|
||||
// 赔率版本写入
|
||||
Route::post('odds-versions', OddsVersionStoreController::class)
|
||||
@@ -94,6 +100,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r
|
||||
Route::post('odds-versions/{id}/publish', OddsVersionPublishController::class)
|
||||
->whereNumber('id')
|
||||
->name('odds-versions.publish');
|
||||
Route::delete('odds-versions/{id}', OddsVersionDestroyController::class)
|
||||
->whereNumber('id')
|
||||
->name('odds-versions.destroy');
|
||||
|
||||
// 封顶版本写入
|
||||
Route::post('risk-cap-versions', RiskCapVersionStoreController::class)
|
||||
@@ -104,6 +113,9 @@ Route::middleware('admin.permission:prd.play_switch.manage|prd.odds.manage|prd.r
|
||||
Route::post('risk-cap-versions/{id}/publish', RiskCapVersionPublishController::class)
|
||||
->whereNumber('id')
|
||||
->name('risk-cap-versions.publish');
|
||||
Route::delete('risk-cap-versions/{id}', RiskCapVersionDestroyController::class)
|
||||
->whereNumber('id')
|
||||
->name('risk-cap-versions.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
use App\Models\PlayType;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\OddsVersion;
|
||||
use App\Models\RiskCapVersion;
|
||||
use App\Models\PlayConfigVersion;
|
||||
use App\Lottery\ConfigVersionStatus;
|
||||
use App\Lottery\ErrorCode;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -86,3 +89,65 @@ test('admin play config draft publish flow', function (): void {
|
||||
test('admin play-types requires authentication', function (): void {
|
||||
$this->getJson('/api/v1/admin/play-types')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('admin cannot delete active play config version', function (): void {
|
||||
$token = mintConfigAdminToken();
|
||||
$active = PlayConfigVersion::query()->where('status', ConfigVersionStatus::Active->value)->firstOrFail();
|
||||
|
||||
$this->deleteJson('/api/v1/admin/config/play-versions/'.$active->id, [], [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertJsonPath('code', ErrorCode::ConfigVersionCannotDeleteActive->value);
|
||||
});
|
||||
|
||||
test('admin can delete draft play config version', function (): void {
|
||||
$token = mintConfigAdminToken();
|
||||
$create = $this->postJson('/api/v1/admin/config/play-versions', [
|
||||
'reason' => 'to delete',
|
||||
], ['Authorization' => 'Bearer '.$token]);
|
||||
$create->assertOk();
|
||||
$draftId = (int) $create->json('data.id');
|
||||
|
||||
$this->deleteJson('/api/v1/admin/config/play-versions/'.$draftId, [], [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted', true);
|
||||
|
||||
expect(PlayConfigVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can delete draft odds version', function (): void {
|
||||
$token = mintConfigAdminToken();
|
||||
$create = $this->postJson('/api/v1/admin/config/odds-versions', [
|
||||
'reason' => 'to delete',
|
||||
], ['Authorization' => 'Bearer '.$token]);
|
||||
$create->assertOk();
|
||||
$draftId = (int) $create->json('data.id');
|
||||
|
||||
$this->deleteJson('/api/v1/admin/config/odds-versions/'.$draftId, [], [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted', true);
|
||||
|
||||
expect(OddsVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can delete draft risk cap version', function (): void {
|
||||
$token = mintConfigAdminToken();
|
||||
$create = $this->postJson('/api/v1/admin/config/risk-cap-versions', [
|
||||
'reason' => 'to delete',
|
||||
], ['Authorization' => 'Bearer '.$token]);
|
||||
$create->assertOk();
|
||||
$draftId = (int) $create->json('data.id');
|
||||
|
||||
$this->deleteJson('/api/v1/admin/config/risk-cap-versions/'.$draftId, [], [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.deleted', true);
|
||||
|
||||
expect(RiskCapVersion::query()->whereKey($draftId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user