feat(wallet): 增强资金流水和信用账本的公共类型过滤,支持游戏结算和账单结算
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
2026-06-30 17:55:16 +08:00
parent f1f68d09d3
commit ed5a983003
8 changed files with 384 additions and 24 deletions

View File

@@ -36,6 +36,16 @@ final class WalletTransactionListController extends Controller
private const ALLOWED_STATUS = ['posted', 'pending_reconcile', 'reversed'];
/** 后台筛选兼容玩家端资金流水公共类型,也接受底层 biz_type。 */
private const PUBLIC_BIZ_TYPE_MAP = [
'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'],
];
public function __construct(
private readonly PlayerLedgerLogsService $ledgerLogs,
) {}
@@ -101,7 +111,16 @@ final class WalletTransactionListController extends Controller
}
if (! empty($validated['biz_type'])) {
$query->where('biz_type', trim((string) $validated['biz_type']));
$bizTypes = $this->resolveBizTypeFilter((string) $validated['biz_type']);
if ($bizTypes === []) {
return ApiResponse::success([
'items' => [],
'total' => 0,
'page' => $page,
'per_page' => $perPage,
]);
}
$query->whereIn('biz_type', $bizTypes);
}
if ($request->boolean('abnormal')) {
@@ -167,4 +186,25 @@ final class WalletTransactionListController extends Controller
'auth_source' => $p?->auth_source,
];
}
/**
* @return list<string>
*/
private function resolveBizTypeFilter(string $raw): array
{
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return [];
}
$resolved = [];
foreach ($parts as $part) {
$key = strtolower($part);
foreach (self::PUBLIC_BIZ_TYPE_MAP[$key] ?? [$key] as $bizType) {
$resolved[] = $bizType;
}
}
return array_values(array_unique($resolved));
}
}

View File

@@ -14,7 +14,7 @@ use App\Services\Wallet\PlayerLedgerLogsService;
/**
* PRD §10.1.1`GET /api/v1/wallet/logs` 钱包/信用流水(按玩家资金模式分表)。
*
* Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal
* Query`page``size`(每页条数,默认 20)、`type`逗号分隔transfer_in,transfer_out,bet,prize,refund,reversal,game_settlement,bill_settlement,rebate
*/
final class WalletLogsController extends Controller
{

View File

@@ -137,10 +137,15 @@ final class AgentProfileService
$profile->refresh();
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
$totalShareRate = (float) $profile->total_share_rate;
$parentTotalShareRate = $this->parentTotalShareRateForProfile($profile);
return [
'agent_node_id' => (int) $profile->agent_node_id,
'total_share_rate' => (float) $profile->total_share_rate,
'total_share_rate' => $totalShareRate,
'relative_share_rate' => $parentTotalShareRate > 0
? round($totalShareRate / $parentTotalShareRate * 100, 2)
: null,
'credit_limit' => (int) $profile->credit_limit,
'allocated_credit' => (int) $profile->allocated_credit,
'used_credit' => (int) $profile->used_credit,
@@ -154,6 +159,20 @@ final class AgentProfileService
];
}
private function parentTotalShareRateForProfile(AgentProfile $profile): float
{
$node = AgentNode::query()->find((int) $profile->agent_node_id);
if ($node === null || $node->parent_id === null) {
return 0.0;
}
$parentProfile = AgentProfile::query()
->where('agent_node_id', (int) $node->parent_id)
->first();
return $parentProfile !== null ? (float) $parentProfile->total_share_rate : 100.0;
}
public function profileForNode(int $agentNodeId): ?AgentProfile
{
return AgentProfile::query()->where('agent_node_id', $agentNodeId)->first();

View File

@@ -17,12 +17,15 @@ use App\Support\AgentSettlementPeriodWindow;
final class SettlementCenterLedgerService
{
private const CREDIT_BIZ_TYPES = [
'bet',
'bet_hold',
'game_settlement',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'game_settlement_reversal',
'settlement_confirm',
'bill_settlement',
'settlement_payout',
];
@@ -34,6 +37,12 @@ final class SettlementCenterLedgerService
private const SHARE_BIZ_TYPE = 'share_ledger';
private const CREDIT_PUBLIC_BIZ_TYPE_MAP = [
'bet' => ['bet_hold'],
'game_settlement' => ['bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'],
'bill_settlement' => ['settlement_confirm', 'settlement_payout'],
];
public function __construct(
private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
@@ -304,7 +313,7 @@ final class SettlementCenterLedgerService
}
if ($filters->bizType !== null && $filters->bizType !== '') {
$query->where('cl.reason', $filters->bizType);
$query->whereIn('cl.reason', $this->resolveCreditReasonFilter($filters->bizType));
} elseif ($filters->betFlowOnly) {
$query->whereIn('cl.reason', [
'bet_hold',
@@ -319,6 +328,14 @@ final class SettlementCenterLedgerService
return $query;
}
/**
* @return list<string>
*/
private function resolveCreditReasonFilter(string $bizType): array
{
return self::CREDIT_PUBLIC_BIZ_TYPE_MAP[$bizType] ?? [$bizType];
}
private function paymentStubQuery(
AdminUser $admin,
string $siteCode,

View File

@@ -34,12 +34,14 @@ final class PlayerLedgerLogsService
/** PRD 对外类型 → credit_ledger.reason信用盘不用钱包「派彩」口径 */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold', 'game_settlement_loss'],
'bet' => ['bet_hold'],
'game_settlement' => ['bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'],
'reversal' => ['bet_hold_release', 'game_settlement_reversal'],
'refund' => ['settlement_confirm'],
'win_credit' => ['game_settlement_win'],
'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'],
'bill_settlement' => ['settlement_payout'],
'bill_settlement' => ['settlement_confirm', 'settlement_payout'],
'rebate' => [],
'transfer_in' => [],
'transfer_out' => [],
];
@@ -423,24 +425,43 @@ final class PlayerLedgerLogsService
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
$includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw);
if (is_array($reasonFilter) && $reasonFilter === []) {
if (is_array($reasonFilter) && $reasonFilter === [] && ! $includeRebates) {
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;
$skipRows = max(0, ($paginator->currentPage() - 1) * $paginator->perPage());
$creditRows = is_array($reasonFilter) && $reasonFilter === []
? collect()
: $this->creditLedgerQuery((int) $player->id, $reasonFilter)->get();
$rebateRows = $includeRebates ? $this->rebateRecordRows((int) $player->id) : collect();
$rows = $creditRows
->map(fn (object $row): object => (object) ['source' => 'credit', 'row' => $row])
->concat($rebateRows->map(fn (object $row): object => (object) ['source' => 'rebate', 'row' => $row]))
->sortByDesc(fn (object $entry): string => (string) ($entry->row->created_at ?? ''))
->values();
$total = $rows->count();
$offset = max(0, ($page - 1) * $perPage);
$pageRows = $rows->slice($offset, $perPage)->values();
$priorCreditRows = $rows
->slice(0, $offset)
->filter(fn (object $entry): bool => $entry->source === 'credit')
->count();
$runningMinor = $this->advanceCreditLedgerRunningMinor(
(int) $player->id,
$reasonFilter,
$this->playerCreditService->availableCreditMinor($player, $currency),
$skipRows,
$priorCreditRows,
);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$items = $pageRows
->map(function (object $entry) use (&$runningMinor, $player, $currency): array {
if ($entry->source === 'rebate') {
return $this->formatPlayerRebateRow($entry->row, $player, $currency);
}
$row = $entry->row;
$amount = (int) $row->amount;
$reason = (string) $row->reason;
$affectsBalance = $this->creditReasonAffectsAvailableBalance($reason);
@@ -462,9 +483,9 @@ final class PlayerLedgerLogsService
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
@@ -501,6 +522,18 @@ final class PlayerLedgerLogsService
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
}
private function creditFilterIncludesRebates(string $raw): bool
{
$raw = trim($raw);
if ($raw === '') {
return true;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
return in_array('rebate', array_map(Str::lower(...), $parts), true);
}
/**
* @param array<string, list<string>> $map
* @return list<string>|null
@@ -605,6 +638,48 @@ final class PlayerLedgerLogsService
];
}
private function rebateRecordRows(int $playerId)
{
return DB::table('rebate_records')
->where('player_id', $playerId)
->whereNull('reversal_of_id')
->orderByDesc('created_at')
->orderByDesc('id')
->get();
}
/**
* @return array<string, mixed>
*/
private function formatPlayerRebateRow(object $row, Player $player, string $currency): array
{
$amount = (int) $row->rebate_amount;
return [
'log_id' => 'RB-'.$row->id,
'type' => 'rebate',
'biz_type' => 'rebate_'.$row->status,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor(abs($amount)),
'amount_abs' => abs($amount),
'amount_abs_formatted' => CurrencyFormatter::fromMinor(abs($amount)),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => null,
'balance_after_formatted' => null,
'affects_available_credit' => false,
'ref_id' => 'ticket_item#'.$row->ticket_item_id,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => (string) $row->status,
'remark' => 'period_rebate',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'rebate_record',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
/** 账期收付记账不改变 player_credit_accounts不参与可用信用倒推。 */
private function creditReasonAffectsAvailableBalance(string $reason): bool
{
@@ -702,10 +777,9 @@ final class PlayerLedgerLogsService
private function creditReasonToPublicType(string $reason): string
{
return match ($reason) {
'bet_hold', 'game_settlement_loss' => 'bet',
'bet_hold_release', 'game_settlement_reversal' => 'reversal',
'settlement_confirm' => 'refund',
'game_settlement_win' => 'win_credit',
'bet_hold' => 'bet',
'bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal' => 'game_settlement',
'settlement_confirm' => 'bill_settlement',
'settlement_payout' => 'bill_settlement',
default => $reason,
};