feat: 切换 schema dump 基线并增强返点结算与管理校验

This commit is contained in:
2026-06-08 17:41:41 +08:00
parent 2d32f006c5
commit 8d5d7f5b17
130 changed files with 5746 additions and 6723 deletions

View File

@@ -38,8 +38,8 @@ final class RiskCapItemsReplaceController extends Controller
'items' => ['required', 'array', 'min:1'],
'items.*.draw_id' => ['sometimes', 'nullable', 'integer', 'exists:draws,id'],
'items.*.normalized_number' => ['required', 'string', 'size:4', 'regex:/^[0-9]{4}$/'],
'items.*.cap_amount' => ['required', 'integer', 'min:0'],
'items.*.cap_type' => ['required', 'string', 'max:16'],
'items.*.cap_amount' => ['required', 'integer', 'min:1'],
'items.*.cap_type' => ['required', 'string', 'in:default,per_number'],
]);
$service->replaceItems($version, $data['items'], $admin);

View File

@@ -50,6 +50,7 @@ final class AdminCurrencyDestroyController extends Controller
$checks = [
'玩家默认币种' => DB::table('players')->where('default_currency', $code),
'玩家钱包' => DB::table('player_wallets')->where('currency_code', $code),
'站点默认币种' => DB::table('admin_sites')->where('currency_code', $code),
'转账单' => DB::table('transfer_orders')->where('currency_code', $code),
'注单' => DB::table('ticket_orders')->where('currency_code', $code),
'赔率配置' => DB::table('odds_items')->where('currency_code', $code),

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Jackpot;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\JackpotPool;
use App\Support\ApiResponse;
use App\Support\ApiMessage;
@@ -40,11 +41,21 @@ final class AdminJackpotPoolManualBurstController extends Controller
}
$data = $request->validate([
'draw_id' => 'required|integer|exists:draws,id',
'draw_id' => ['required'],
]);
$drawId = $this->resolveDrawId(trim((string) $data['draw_id']));
if ($drawId === null) {
return ApiResponse::error(
trans('validation.exists', ['attribute' => 'draw_id'], $request->lotteryLocale()),
ErrorCode::ClientHttpError->value,
['draw_id' => [trans('validation.exists', ['attribute' => 'draw_id'], $request->lotteryLocale())]],
422,
);
}
try {
$payload = $this->service->execute($pool, (int) $data['draw_id']);
$payload = $this->service->execute($pool, $drawId);
} catch (\RuntimeException $e) {
return ApiResponse::error(
ApiMessage::get($request, 'jackpot_manual_burst_failed', [
@@ -58,4 +69,22 @@ final class AdminJackpotPoolManualBurstController extends Controller
return ApiResponse::success($payload);
}
private function resolveDrawId(string $drawRef): ?int
{
if ($drawRef === '') {
return null;
}
if (ctype_digit($drawRef)) {
$draw = Draw::query()->whereKey((int) $drawRef)->first();
if ($draw !== null) {
return (int) $draw->id;
}
}
$draw = Draw::query()->where('draw_no', $drawRef)->first();
return $draw !== null ? (int) $draw->id : null;
}
}

View File

@@ -11,7 +11,9 @@ use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Support\AdminSiteScope;
use App\Support\PlayerFundingMode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
/** DELETE /api/v1/admin/players/{player} */
final class AdminPlayerDestroyController extends Controller
@@ -25,13 +27,36 @@ final class AdminPlayerDestroyController extends Controller
return $denied;
}
$hasWallets = Player::query()
->whereKey($player->getKey())
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
if (PlayerFundingMode::usesCredit($player)) {
$creditRow = DB::table('player_credit_accounts')
->where('player_id', $player->getKey())
->first();
$usedCredit = (int) ($creditRow->used_credit ?? 0);
$frozenCredit = (int) ($creditRow->frozen_credit ?? 0);
if ($usedCredit !== 0 || $frozenCredit !== 0) {
return ApiMessage::errorResponse($request, 'admin.player_credit_in_use_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
}
} else {
$hasWallets = Player::query()
->whereKey($player->getKey())
->whereHas('wallets', static fn (Builder $q) => $q->where('balance', '!=', 0))
->exists();
if ($hasWallets) {
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
}
}
$hasUnpaidSettlementBills = DB::table('settlement_bills')
->where('owner_type', 'player')
->where('owner_id', $player->getKey())
->whereIn('status', ['confirmed', 'partial_paid', 'overdue'])
->where('unpaid_amount', '>', 0)
->exists();
if ($hasWallets) {
return ApiMessage::errorResponse($request, 'admin.player_wallet_balance_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
if ($hasUnpaidSettlementBills) {
return ApiMessage::errorResponse($request, 'admin.player_unpaid_settlement_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
}
$hasTickets = TicketOrder::query()
@@ -42,6 +67,7 @@ final class AdminPlayerDestroyController extends Controller
return ApiMessage::errorResponse($request, 'admin.player_has_tickets_blocks_delete', ErrorCode::ValidationFailed->value, null, 422);
}
DB::table('player_credit_accounts')->where('player_id', $player->getKey())->delete();
$player->wallets()->delete();
$player->delete();

View File

@@ -35,7 +35,7 @@ final class AdminPlayerIndexController extends Controller
if ($keyword !== '') {
$term = '%'.addcslashes($keyword, '%_\\').'%';
$q->where(static function ($sub) use ($term): void {
$q->where(static function ($sub) use ($term, $keyword): void {
$sub->where('site_player_id', 'like', $term)
->orWhere('username', 'like', $term)
->orWhere('nickname', 'like', $term);

View File

@@ -55,7 +55,7 @@ final class AdminPlayerStoreController extends Controller
if ($isNative) {
$sitePlayerId = $sitePlayerId !== ''
? $sitePlayerId
: 'native:'.Str::lower(Str::ulid());
: $this->generateNativeSitePlayerId($siteCode);
}
if ($sitePlayerId === '') {
@@ -192,4 +192,20 @@ final class AdminPlayerStoreController extends Controller
return $rootId !== null ? (int) $rootId : null;
}
private function generateNativeSitePlayerId(string $siteCode): string
{
$prefix = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $siteCode) ?: 'LP', 0, 2));
$prefix = str_pad($prefix, 2, 'P');
do {
$candidate = sprintf('%s%06d', $prefix, random_int(0, 999999));
$exists = Player::query()
->where('site_code', $siteCode)
->where('site_player_id', $candidate)
->exists();
} while ($exists);
return $candidate;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\V1\Admin\Reconcile;
use App\Models\TransferOrder;
use App\Models\ReconcileJob;
use Illuminate\Http\Request;
use App\Models\ReconcileItem;
@@ -20,6 +21,19 @@ final class ReconcileItemIndexController extends Controller
->orderBy('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']);
$transferNos = collect($paginator->items())
->map(fn (ReconcileItem $item) => $item->side_a_ref)
->filter(fn ($value) => is_string($value) && $value !== '')
->values()
->all();
$transferStatuses = $transferNos === []
? []
: TransferOrder::query()
->whereIn('transfer_no', $transferNos)
->pluck('status', 'transfer_no')
->all();
return AdminApiList::jsonWith($paginator, fn (ReconcileItem $r) => [
'id' => (int) $r->id,
'side_a_ref' => $r->side_a_ref,
@@ -27,6 +41,8 @@ final class ReconcileItemIndexController extends Controller
'difference_amount' => (int) $r->difference_amount,
'status' => $r->status,
'resolved_at' => $r->resolved_at?->toIso8601String(),
'is_resolved' => $r->resolved_at !== null || in_array($transferStatuses[$r->side_a_ref ?? ''] ?? null, ['success', 'reversed', 'manually_processed'], true),
'current_transfer_status' => $transferStatuses[$r->side_a_ref ?? ''] ?? null,
'created_at' => $r->created_at?->toIso8601String(),
], [
'job_id' => (int) $reconcile_job->id,

View File

@@ -14,10 +14,16 @@ final class ReportJobIndexController extends Controller
public function __invoke(Request $request): JsonResponse
{
$p = AdminApiList::readPaging($request);
$reportType = trim((string) $request->query('report_type', ''));
$paginator = ReportJob::query()
->orderByDesc('id')
->paginate($p['perPage'], ['*'], 'page', $p['page']);
$query = ReportJob::query()
->orderByDesc('id');
if ($reportType !== '') {
$query->where('report_type', $reportType);
}
$paginator = $query->paginate($p['perPage'], ['*'], 'page', $p['page']);
return AdminApiList::json($paginator, fn (ReportJob $j) => $this->row($j));
}

View File

@@ -46,7 +46,7 @@ final class AdminTicketItemIndexController extends Controller
->with([
'draw:id,draw_no,business_date',
'order:id,order_no,currency_code,created_at',
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode',
'player.agentNode:id,code,name',
])
->orderByDesc('ticket_items.id');
@@ -108,6 +108,8 @@ final class AdminTicketItemIndexController extends Controller
'site_player_id' => $row->player?->site_player_id,
'username' => $row->player?->username,
'nickname' => $row->player?->nickname,
'funding_mode' => $row->player?->funding_mode,
'uses_credit' => $row->player?->funding_mode === 'credit',
'order_no' => $row->order?->order_no,
'draw_no' => $row->draw?->draw_no,
'currency_code' => $row->order?->currency_code,

View File

@@ -67,7 +67,7 @@ final class WalletTransactionListController extends Controller
$query = WalletTxn::query()
->with([
'player:id,site_code,site_player_id,username,nickname,agent_node_id',
'player:id,site_code,site_player_id,username,nickname,agent_node_id,funding_mode,auth_source',
'player.agentNode:id,code,name',
])
->orderByDesc('id');

View File

@@ -18,7 +18,6 @@ final class SettingIndexController extends Controller
/** @var list<string> */
private const PUBLIC_GROUPS = [
'frontend',
'currency',
];
public function __invoke(Request $request): JsonResponse

View File

@@ -18,8 +18,11 @@ final class TicketPreviewController extends Controller
public function __invoke(TicketPreviewRequest $request): JsonResponse
{
$player = $request->lotteryPlayer();
abort_if($player === null, 500, 'lottery_player missing');
try {
$data = $this->previewService->preview($request->validated());
$data = $this->previewService->preview($player, $request->validated());
} catch (TicketOperationException $e) {
return ApiResponse::error(
LotteryMessage::wallet($request, $e->lotteryCode),

View File

@@ -43,10 +43,17 @@ final class AgentGameSettlementRecorder
$gameType = trim((string) ($item->play_code ?? '')) ?: '*';
$snapshot = $this->snapshotBuilder->buildForPlayer($player, $gameType);
$baseRebateRate = $this->baseRebateRateForTicketItem($item);
$basicRebateRate = $this->normalizeRate($baseRebateRate + (float) $snapshot['rebate_rate']);
$shareSnapshot = [
...$snapshot,
'base_rebate_rate' => $baseRebateRate,
'basic_rebate_rate' => $basicRebateRate,
];
$gameWinLoss = $this->platformWinLoss($item, $netWin, $terminalStatus);
$validBet = (int) $item->total_bet_amount;
$basicRebate = (int) round($validBet * $snapshot['rebate_rate']);
$basicRebate = (int) round($validBet * $basicRebateRate);
$extraRebate = (int) round($validBet * $snapshot['extra_rebate_rate']);
$extraByCode = [];
@@ -66,15 +73,11 @@ final class AgentGameSettlementRecorder
$settledAt = now();
DB::transaction(function () use ($item, $player, $snapshot, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void {
DB::transaction(function () use ($item, $player, $snapshot, $shareSnapshot, $basicRebateRate, $gameWinLoss, $basicRebate, $result, $settledAt, $validBet, $extraRebate, $gameType): void {
$item->forceFill([
'agent_node_id' => $snapshot['agent_node_id'],
'share_snapshot' => [
'total_shares' => $snapshot['total_shares'],
'actual_shares' => $snapshot['actual_shares'],
'chain_codes' => $snapshot['chain_codes'],
],
'agent_rebate_rate_snapshot' => $snapshot['rebate_rate'],
'share_snapshot' => $shareSnapshot,
'agent_rebate_rate_snapshot' => $basicRebateRate,
'agent_settled_at' => $settledAt,
])->save();
@@ -83,7 +86,7 @@ final class AgentGameSettlementRecorder
'player_id' => $player->id,
'agent_node_id' => $snapshot['agent_node_id'],
'agent_path' => json_encode($snapshot['agent_path']),
'share_snapshot' => json_encode($snapshot),
'share_snapshot' => json_encode($shareSnapshot),
'game_win_loss' => (int) round($gameWinLoss),
'basic_rebate' => $basicRebate,
'shared_net_win_loss' => (int) round($result->sharedNetWinLoss),
@@ -99,7 +102,7 @@ final class AgentGameSettlementRecorder
'ticket_item_id' => $item->id,
'game_type' => $gameType,
'valid_bet_amount' => $validBet,
'rebate_rate' => $snapshot['rebate_rate'],
'rebate_rate' => $basicRebateRate,
'rebate_amount' => $basicRebate,
'rebate_type' => 'basic',
'owner_agent_id' => $snapshot['agent_node_id'],
@@ -138,6 +141,38 @@ final class AgentGameSettlementRecorder
});
}
private function baseRebateRateForTicketItem(TicketItem $item): float
{
$ruleSnapshot = is_array($item->rule_snapshot_json) ? $item->rule_snapshot_json : [];
$baseFromRule = isset($ruleSnapshot['base_rebate_rate'])
? (float) $ruleSnapshot['base_rebate_rate']
: null;
if ($baseFromRule !== null && $baseFromRule > 0) {
return $this->normalizeRate($baseFromRule);
}
$oddsSnapshot = is_array($item->odds_snapshot_json) ? $item->odds_snapshot_json : [];
foreach ($oddsSnapshot as $row) {
if (! is_array($row)) {
continue;
}
if (! array_key_exists('rebate_rate', $row)) {
continue;
}
return $this->normalizeRate((float) $row['rebate_rate']);
}
return 0.0;
}
private function normalizeRate(float $rate): float
{
return max(0.0, min(1.0, $rate));
}
private function platformWinLoss(TicketItem $item, int $netWin, string $terminalStatus): float
{
if ($terminalStatus === 'settled_lose') {

View File

@@ -2,6 +2,8 @@
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -10,6 +12,7 @@ final class AgentSettlementBadDebtService
{
public function __construct(
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
private readonly PlayerCreditService $playerCreditService,
) {}
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
@@ -99,6 +102,13 @@ final class AgentSettlementBadDebtService
'updated_at' => $now,
]);
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
$player = Player::query()->find((int) $original->owner_id);
if ($player !== null) {
$this->playerCreditService->releaseFromSettlement($player, $unpaid, $originalBillId);
}
}
$this->periodCompletion->syncIfReady($periodId);
return $archiveBillId;

View File

@@ -56,6 +56,20 @@ final class AgentSettlementBillGuard
public function markConfirmed(int $billId): void
{
$this->assertPeriodMutable($billId);
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
if ((string) $bill->status === 'confirmed') {
return;
}
if ((string) $bill->status !== 'pending_confirm') {
throw ValidationException::withMessages([
'bill' => ['not_confirmable'],
]);
}
DB::table('settlement_bills')->where('id', $billId)->update([
'status' => 'confirmed',

View File

@@ -18,7 +18,9 @@ final class SettlementCenterLedgerService
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'settlement_confirm',
'settlement_payout',
];
private const ADJUSTMENT_BIZ_TYPES = [

View File

@@ -5,6 +5,7 @@ namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class SettlementPaymentService
{
@@ -25,66 +26,72 @@ final class SettlementPaymentService
*/
public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void
{
$bill = DB::table('settlement_bills')->where('id', $billId)->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
DB::transaction(function () use ($billId, $amount, $adminUserId, $meta): void {
$bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$this->billGuard->assertPeriodMutable($billId);
$this->billGuard->assertPayable($billId);
$this->billGuard->assertPeriodMutable($billId);
if (! in_array((string) $bill->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_payable'],
]);
}
$amount = min($amount, abs((int) $bill->unpaid_amount));
if ($amount <= 0) {
return;
}
$payAmount = min($amount, abs((int) $bill->unpaid_amount));
if ($payAmount <= 0) {
return;
}
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => $payerType,
'payer_id' => $payerId,
'payee_type' => $payeeType,
'payee_id' => $payeeId,
'amount' => $amount,
'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null,
'remark' => $meta['remark'] ?? null,
'status' => 'confirmed',
'created_by' => $adminUserId,
'confirmed_by' => $adminUserId,
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => $payerType,
'payer_id' => $payerId,
'payee_type' => $payeeType,
'payee_id' => $payeeId,
'amount' => $payAmount,
'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null,
'remark' => $meta['remark'] ?? null,
'status' => 'confirmed',
'created_by' => $adminUserId,
'confirmed_by' => $adminUserId,
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$newPaid = (int) $bill->paid_amount + $amount;
$newUnpaid = max(0, (int) $bill->unpaid_amount - $amount);
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
$newPaid = (int) $bill->paid_amount + $payAmount;
$newUnpaid = max(0, (int) $bill->unpaid_amount - $payAmount);
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
DB::table('settlement_bills')->where('id', $billId)->update([
'paid_amount' => $newPaid,
'unpaid_amount' => $newUnpaid,
'status' => $status,
'updated_at' => now(),
]);
DB::table('settlement_bills')->where('id', $billId)->update([
'paid_amount' => $newPaid,
'unpaid_amount' => $newUnpaid,
'status' => $status,
'updated_at' => now(),
]);
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
if ((int) $bill->net_amount > 0) {
$this->playerCreditService->releaseFromSettlement($player, $amount, $billId);
} elseif ((int) $bill->net_amount < 0) {
$this->playerCreditService->applySettlementPayout($player, $amount, $billId);
}
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
if ((int) $bill->net_amount > 0) {
$this->playerCreditService->releaseFromSettlement($player, $payAmount, $billId);
} elseif ((int) $bill->net_amount < 0) {
$this->playerCreditService->applySettlementPayout($player, $payAmount, $billId);
}
if ($status === 'settled') {
$this->periodCloseRebate->markRebatesSettledForBill($billId);
if ($status === 'settled') {
$this->periodCloseRebate->markRebatesSettledForBill($billId);
}
}
}
}
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
});
}
/**

View File

@@ -8,7 +8,7 @@ use App\Models\JackpotPool;
use App\Models\JackpotContribution;
/**
* 产品文档 §5.11.1:每笔有效注单按比例蓄水(在下注成功路径调用,非结算)。
* 产品文档 §5.11.1:每笔满足门槛的有效注单按下注额比例蓄水(在下注成功路径调用,非结算)。
*/
final class JackpotContributionService
{
@@ -25,12 +25,14 @@ final class JackpotContributionService
return;
}
if ((int) $item->actual_deduct_amount < (int) $pool->min_bet_amount) {
$betAmount = (int) $item->total_bet_amount;
if ($betAmount < (int) $pool->min_bet_amount) {
return;
}
$rate = (float) $pool->contribution_rate;
$contrib = (int) floor((int) $item->actual_deduct_amount * $rate);
$contrib = (int) floor($betAmount * $rate);
if ($contrib <= 0) {
return;
}

View File

@@ -13,6 +13,10 @@ use Illuminate\Support\Facades\Cache;
*/
final class LotterySettings
{
private const CURRENCY_DISPLAY_DECIMALS = 2;
private const CURRENCY_DECIMAL_SEPARATOR = '.';
private const CURRENCY_THOUSANDS_SEPARATOR = ',';
public static function defaultCurrency(): string
{
$fallback = (string) config('lottery.default_currency', 'NPR');
@@ -70,25 +74,17 @@ final class LotterySettings
public static function currencyDisplayDecimals(): int
{
$fallback = (int) config('lottery.ui.format.currency.decimals', 2);
return max(0, min(12, (int) self::get('currency.display_decimals', $fallback)));
return self::CURRENCY_DISPLAY_DECIMALS;
}
public static function currencyDecimalSeparator(): string
{
return (string) self::get(
'currency.decimal_separator',
(string) config('lottery.ui.format.currency.decimal_separator', '.')
);
return self::CURRENCY_DECIMAL_SEPARATOR;
}
public static function currencyThousandsSeparator(): string
{
return (string) self::get(
'currency.thousands_separator',
(string) config('lottery.ui.format.currency.thousands_separator', ',')
);
return self::CURRENCY_THOUSANDS_SEPARATOR;
}
public static function cacheTtlSeconds(): int

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\Ticket;
use App\Models\AgentProfile;
use App\Models\Player;
use Illuminate\Support\Facades\DB;
final class InstantRebateResolver
{
/**
* @return array{
* base_rebate_rate: float,
* player_addon_rebate_rate: float,
* final_rebate_rate: float,
* inherited_from_agent: bool
* }
*/
public function resolveForPlayer(Player $player, string $playCode, float $baseRebateRate): array
{
$row = DB::table('player_rebate_profiles')
->where('player_id', $player->id)
->where('game_type', $playCode)
->first();
if ($row === null) {
$row = DB::table('player_rebate_profiles')
->where('player_id', $player->id)
->where('game_type', '*')
->first();
}
$addonRate = 0.0;
$inheritedFromAgent = false;
if ($row !== null && ! (bool) $row->inherit_from_agent) {
$addonRate = (float) $row->rebate_rate + (float) $row->extra_rebate_rate;
} else {
$inheritedFromAgent = true;
$profile = $player->agent_node_id
? AgentProfile::query()->where('agent_node_id', $player->agent_node_id)->first()
: null;
$addonRate = (float) ($profile?->default_player_rebate ?? 0);
}
return [
'base_rebate_rate' => $this->normalizeRate($baseRebateRate),
'player_addon_rebate_rate' => $this->normalizeRate($addonRate),
'final_rebate_rate' => $this->normalizeRate($baseRebateRate + $addonRate),
'inherited_from_agent' => $inheritedFromAgent,
];
}
private function normalizeRate(float $rate): float
{
return max(0.0, min(1.0, $rate));
}
}

View File

@@ -24,6 +24,7 @@ final class TicketPlacementService
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly RiskPoolService $riskPoolService,
private readonly TicketWalletService $ticketWalletService,
private readonly JackpotContributionService $jackpotContribution,
@@ -532,12 +533,29 @@ final class TicketPlacementService
*/
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
{
if (! PlayerFundingMode::usesCredit($player)) {
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}

View File

@@ -3,15 +3,18 @@
namespace App\Services\Ticket;
use App\Models\Draw;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Exceptions\TicketOperationException;
use App\Services\Draw\DrawHallSnapshotBuilder;
use App\Support\PlayerFundingMode;
final class TicketPreviewService
{
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly RiskPoolService $riskPoolService,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
) {}
@@ -20,7 +23,7 @@ final class TicketPreviewService
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function preview(array $payload): array
public function preview(Player $player, array $payload): array
{
$drawNo = trim((string) ($payload['draw_id'] ?? ''));
$draw = $drawNo === ''
@@ -62,6 +65,7 @@ final class TicketPreviewService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyPlayerInstantRebate($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -131,4 +135,37 @@ final class TicketPreviewService
'warnings' => $warningRows,
];
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyPlayerInstantRebate(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
}

View File

@@ -103,6 +103,7 @@ final class PlayerLedgerLogsService
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'settlement_confirm',
'settlement_payout',
])->limit(5000)->get()->all();

View File

@@ -3,6 +3,8 @@
namespace App\Support;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@@ -106,23 +108,29 @@ final class AuditLogApiPresenter
{
$items = collect($paginator->items());
$resourceNames = self::loadResourceNames($items);
$operatorLabels = self::loadOperatorLabels($items);
return AdminApiList::payload(
$paginator,
fn (AuditLog $r) => self::row($r, $resourceNames),
fn (AuditLog $r) => self::row($r, $resourceNames, $operatorLabels),
);
}
/**
* @param array<string, string> $resourceNames
* @param array<string, string> $operatorLabels
* @return array<string, mixed>
*/
public static function row(AuditLog $r, array $resourceNames = []): array
public static function row(AuditLog $r, array $resourceNames = [], array $operatorLabels = []): array
{
$operatorDisplay = self::operatorDisplay($r, $operatorLabels);
return [
'id' => (int) $r->id,
'operator_type' => $r->operator_type,
'operator_id' => (int) $r->operator_id,
'operator_label' => $operatorDisplay['label'],
'operator_subtitle' => $operatorDisplay['subtitle'],
'module_code' => $r->module_code,
'action_code' => $r->action_code,
'target_type' => $r->target_type,
@@ -138,6 +146,57 @@ final class AuditLogApiPresenter
];
}
/**
* @param Collection<int, AuditLog> $items
* @return array<string, string>
*/
private static function loadOperatorLabels(Collection $items): array
{
$labels = [];
$adminIds = $items
->filter(fn (AuditLog $row) => $row->operator_type === 'admin' && (int) $row->operator_id > 0)
->pluck('operator_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
if ($adminIds !== []) {
$admins = AdminUser::query()
->whereIn('id', $adminIds)
->get(['id', 'username', 'name']);
foreach ($admins as $admin) {
$labels['admin:'.$admin->id] = self::formatIdentityLabel(
(string) ($admin->name ?? ''),
(string) ($admin->username ?? ''),
);
}
}
$playerIds = $items
->filter(fn (AuditLog $row) => $row->operator_type === 'player' && (int) $row->operator_id > 0)
->pluck('operator_id')
->map(fn (mixed $id): int => (int) $id)
->unique()
->values()
->all();
if ($playerIds !== []) {
$players = Player::query()
->whereIn('id', $playerIds)
->get(['id', 'username', 'nickname']);
foreach ($players as $player) {
$labels['player:'.$player->id] = self::formatIdentityLabel(
(string) ($player->nickname ?? ''),
(string) ($player->username ?? ''),
);
}
}
return $labels;
}
/**
* @param Collection<int, AuditLog> $items
* @return array<string, string>
@@ -171,6 +230,48 @@ final class AuditLogApiPresenter
return self::MODULE_LABELS[$code] ?? $code;
}
/**
* @param array<string, string> $operatorLabels
*/
private static function operatorDisplay(AuditLog $r, array $operatorLabels): array
{
$type = (string) ($r->operator_type ?? '');
$id = (int) ($r->operator_id ?? 0);
if ($type === 'system') {
return ['label' => '系统', 'subtitle' => null];
}
$key = $type.':'.$id;
if (isset($operatorLabels[$key]) && $operatorLabels[$key] !== '') {
return [
'label' => $operatorLabels[$key],
'subtitle' => $id > 0 ? sprintf('%s #%d', $type === 'admin' ? '管理员' : ($type === 'player' ? '玩家' : $type), $id) : null,
];
}
$fallbackLabel = match ($type) {
'admin' => $id > 0 ? '管理员 #'.$id : '管理员',
'player' => $id > 0 ? '玩家 #'.$id : '玩家',
'system' => '系统',
default => $id > 0 ? sprintf('%s #%d', $type !== '' ? $type : '未知', $id) : ($type !== '' ? $type : '未知'),
};
return ['label' => $fallbackLabel, 'subtitle' => null];
}
private static function formatIdentityLabel(string $primary, string $secondary): string
{
$primary = trim($primary);
$secondary = trim($secondary);
if ($primary !== '') {
return $primary;
}
return $secondary;
}
/**
* @param array<string, string> $resourceNames
*/

View File

@@ -9,7 +9,7 @@ use App\Services\LotterySettings;
/**
* 将「最小货币单位」整数格式化为展示用字符串(不改变业务数字,仅格式化)。
*
* 拆分位数由 {@see config('lottery.ui.format.currency.decimals')} 决定,默认 2(即 ÷100
* 展示格式固定为 2 位小数、`.` 小数点、`,` 千分位;不从后台设置或环境变量读取
*/
final class CurrencyFormatter
{