Files
lotteryLaravel/app/Services/Wallet/PlayerLedgerLogsService.php
kang b496a9457c
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
feat: enhance configuration and logging for admin services
- Updated .env.example to enable Redis Lua for lottery risk pool and added configuration for agent settlement.
- Improved AGENTS.md to clarify site operations and admin roles.
- Enhanced PHPUnit configuration with memory limit and cache directory settings.
- Refactored AdminReportQueryService and related services to utilize LimitedQuery for better data handling and truncation warnings.
- Added logging for draw settlement failures in DrawTickService.
- Introduced credit preflight checks and reverse bet hold functionality in PlayerCreditService.
- Improved risk pool management with new Redis lock handling in RiskPoolService and TicketPlacementService.
2026-06-16 17:08:10 +08:00

684 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\LimitedQuery;
use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。
*/
final class PlayerLedgerLogsService
{
/** PRD 对外类型 → wallet_txns.biz_type */
private const WALLET_TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
/** PRD 对外类型 → credit_ledger.reason信用盘不用钱包「派彩」口径 */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold', 'game_settlement_loss'],
'reversal' => ['bet_hold_release'],
'refund' => ['settlement_confirm'],
'win_credit' => ['game_settlement_win'],
'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'],
'bill_settlement' => ['settlement_payout'],
'transfer_in' => [],
'transfer_out' => [],
];
public function __construct(
private readonly PlayerCreditService $playerCreditService,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
private readonly SettlementPartyEnrichment $partyEnrichment,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* funding_mode: string,
* auth_source: string|null,
* }
*/
public function listForPlayerApi(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$meta = [
'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn',
'funding_mode' => (string) ($player->funding_mode ?? ''),
'auth_source' => $player->auth_source,
];
if (PlayerFundingMode::usesCredit($player)) {
$result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw);
return array_merge($result, $meta);
}
$result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw);
return array_merge($result, $meta);
}
/**
* 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger字段形状对齐 wallet_txns 列表。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function listForAdminPlayer(
Player $player,
int $page,
int $perPage,
?string $bizType = null,
): array {
if (! PlayerFundingMode::usesCredit($player)) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$currency = (string) $player->default_currency;
$limited = LimitedQuery::get($this->creditLedgerQuery($player->id, [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'settlement_confirm',
'settlement_payout',
]), 5000);
$rawRows = $limited['rows']->all();
$enriched = array_map(function (object $row) use ($player): object {
return (object) [
'id' => (int) $row->id,
'amount' => (int) $row->amount,
'reason' => (string) $row->reason,
'ref_type' => $row->ref_type ?? null,
'ref_id' => $row->ref_id ?? null,
'created_at' => $row->created_at ?? null,
'updated_at' => $row->updated_at ?? null,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'agent_node_id' => $player->agent_node_id,
'funding_mode' => $player->funding_mode,
'auth_source' => $player->auth_source,
'default_currency' => $player->default_currency,
'direct_agent_id' => null,
'direct_agent_code' => null,
'direct_agent_name' => null,
'parent_agent_id' => null,
'parent_agent_code' => null,
'parent_agent_name' => null,
];
}, $rawRows);
$ticketIds = [];
foreach ($enriched as $row) {
if ((string) ($row->ref_type ?? '') === 'ticket_item') {
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$ticketIds[] = $ticketId;
}
}
}
$ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds)));
$simplified = $this->betFlowPresenter->simplifyCreditRows(
$enriched,
$ticketRefs,
fn (object $row): array => $this->formatAdminCreditRow($row, $player, $currency, 0),
function (object $row) use ($player, $currency): array {
$formatted = $this->formatAdminCreditRow($row, $player, $currency, 0);
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$formatted['txn_no'] = 'CLS-T'.$ticketId;
}
return $formatted;
},
);
if ($bizType !== null && $bizType !== '') {
$filterType = trim($bizType);
$simplified = array_values(array_filter(
$simplified,
static fn (array $item): bool => ($item['biz_type'] ?? '') === $filterType
|| ($filterType === 'bet_hold' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD)
|| ($filterType === 'game_settlement' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT),
));
}
$total = count($simplified);
$offset = max(0, ($page - 1) * $perPage);
$pageRows = array_slice($simplified, $offset, $perPage);
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = [];
foreach ($pageRows as $formatted) {
$signed = (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
$items[] = array_merge($formatted, [
'balance_after' => $runningMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
'balance_before' => $signed >= 0
? max(0, $runningMinor - $signed)
: $runningMinor + abs($signed),
]);
$runningMinor -= $signed;
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'truncated' => $limited['truncated'],
];
}
/**
* 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listForAdminCreditIndex(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
?int $settlementPeriodId = null,
?int $playerId = null,
?string $reason = null,
?string $createdFrom = null,
?string $createdTo = null,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'cl.updated_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
])
->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
if ($reason !== null && $reason !== '') {
$query->where('cl.reason', $reason);
}
$range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'ledger_source' => 'credit_ledger',
];
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolveCreatedRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
if ($period === null) {
return null;
}
return [
Carbon::parse($period->period_start)->startOfDay(),
Carbon::parse($period->period_end)->endOfDay(),
];
}
$from = $createdFrom !== null && $createdFrom !== ''
? Carbon::parse($createdFrom)->startOfDay()
: null;
$to = $createdTo !== null && $createdTo !== ''
? Carbon::parse($createdTo)->endOfDay()
: null;
if ($from === null && $to === null) {
return null;
}
return [
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
$to ?? Carbon::now()->endOfDay(),
];
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditIndexRow(object $row): array
{
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$currency = (string) ($row->default_currency ?? '');
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $row->player_id,
'site_code' => (string) $row->site_code,
'site_player_id' => $row->site_player_id,
'username' => $row->username,
'nickname' => $row->nickname,
'biz_type' => (string) $row->reason,
'type' => $this->creditReasonToPublicType((string) $row->reason),
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $amount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source,
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateWalletTxns(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$bizFilter = $this->resolveWalletBizFilter($typeFilterRaw);
if (is_array($bizFilter) && $bizFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
if (is_array($reasonFilter) && $reasonFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$paginator = $this->creditLedgerQuery((int) $player->id, $reasonFilter)
->paginate($perPage, ['*'], 'page', $page);
$currency = (string) $player->default_currency;
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$amount = (int) $row->amount;
$reason = (string) $row->reason;
$affectsBalance = $this->creditReasonAffectsAvailableBalance($reason);
$formatted = $this->formatPlayerCreditRow(
$row,
$player,
$currency,
$affectsBalance ? $runningMinor : null,
$affectsBalance,
);
if ($affectsBalance) {
$runningMinor -= $amount;
}
return $formatted;
})
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @param list<string>|null $reasonFilter
*/
private function creditLedgerQuery(int $playerId, ?array $reasonFilter)
{
$query = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->orderByDesc('id');
if ($reasonFilter !== null) {
$query->whereIn('reason', $reasonFilter);
}
return $query;
}
/**
* @return list<string>|null
*/
private function resolveWalletBizFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ);
}
/**
* @return list<string>|null
*/
private function resolveCreditReasonFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
}
/**
* @param array<string, list<string>> $map
* @return list<string>|null
*/
private function resolveTypeFilterMap(string $raw, array $map): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$resolved = [];
foreach ($parts as $part) {
$key = Str::lower($part);
if (! isset($map[$key])) {
continue;
}
foreach ($map[$key] as $value) {
$resolved[] = $value;
}
}
return array_values(array_unique($resolved));
}
/**
* @return array<string, mixed>
*/
private function formatWalletTxnRow(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->walletBizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedWalletAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
];
}
/**
* @return array<string, mixed>
*/
private function formatPlayerCreditRow(
object $row,
Player $player,
string $currency,
?int $balanceAfterMinor,
?bool $affectsAvailableBalance = null,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$reason = (string) $row->reason;
$publicType = $this->creditReasonToPublicType($reason);
$affectsBalance = $affectsAvailableBalance ?? $this->creditReasonAffectsAvailableBalance($reason);
return [
'log_id' => 'CL-'.$row->id,
'type' => $publicType,
'biz_type' => $reason,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'amount_abs' => $amountAbs,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $affectsBalance ? $balanceAfterMinor : null,
'balance_after_formatted' => $affectsBalance && $balanceAfterMinor !== null
? CurrencyFormatter::fromMinor($balanceAfterMinor)
: null,
'affects_available_credit' => $affectsBalance,
'ref_id' => $this->creditRefLabel($row),
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
/** 账期收付记账不改变 player_credit_accounts不参与可用信用倒推。 */
private function creditReasonAffectsAvailableBalance(string $reason): bool
{
return $reason !== 'settlement_payout';
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$balanceBefore = $amount >= 0
? max(0, $balanceAfterMinor - $amount)
: $balanceAfterMinor + $amountAbs;
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'wallet_id' => null,
'biz_type' => (string) $row->reason,
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'balance_before' => $balanceBefore,
'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore),
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => null,
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
private function creditReasonToPublicType(string $reason): string
{
return match ($reason) {
'bet_hold', 'game_settlement_loss' => 'bet',
'bet_hold_release' => 'reversal',
'settlement_confirm' => 'refund',
'game_settlement_win' => 'win_credit',
'settlement_payout' => 'bill_settlement',
default => $reason,
};
}
private function walletBizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedWalletAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
private function creditRefLabel(object $row): ?string
{
if ($row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse($value)->toIso8601String();
}
}