feat(core): harden sessions settlement and credit activity
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

This commit is contained in:
wchino
2026-07-22 20:53:43 +08:00
parent 35f6e46958
commit 15bd997c4e
48 changed files with 1920 additions and 93 deletions

View File

@@ -3,19 +3,20 @@
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\LimitedQuery;
use App\Support\AdminDataScope;
use App\Support\CreditAmountScale;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use Illuminate\Support\Facades\DB;
use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
/**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}
@@ -48,6 +49,7 @@ final class PlayerLedgerLogsService
public function __construct(
private readonly PlayerCreditService $playerCreditService,
private readonly PlayerCreditActivityService $creditActivityService,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
private readonly SettlementPartyEnrichment $partyEnrichment,
) {}
@@ -430,6 +432,127 @@ final class PlayerLedgerLogsService
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$activities = $this->creditActivityService->build($player);
if ($activities !== []) {
return $this->paginateCreditActivities($player, $activities, $page, $perPage, $typeFilterRaw);
}
return $this->paginateLegacyCreditLedger($player, $page, $perPage, $typeFilterRaw);
}
/**
* @param list<array<string, mixed>> $activities
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditActivities(
Player $player,
array $activities,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$currency = (string) $player->default_currency;
$creditLimitMinor = $this->creditLimitMinor($player, $currency);
$runningMinor = $this->clampCreditAvailableMinor(
$this->playerCreditService->availableCreditMinor($player, $currency),
$creditLimitMinor,
);
// 先按完整活动时间线回推每笔的变更后额度,再做筛选和分页。
// 否则筛选掉的较新活动会让历史记录的 balance_after 偏离真实值。
foreach ($activities as &$activity) {
$activity['balance_after'] = $runningMinor;
$activity['balance_after_formatted'] = CurrencyFormatter::fromMinor($runningMinor);
$activity['funding_mode'] = (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT);
$activity['auth_source'] = $player->auth_source;
$runningMinor = $this->clampCreditAvailableMinor(
$runningMinor - (int) ($activity['_available_delta'] ?? 0),
$creditLimitMinor,
);
unset($activity['_available_delta']);
}
unset($activity);
$activities = array_values(array_filter(
$activities,
fn (array $activity): bool => $this->creditActivityMatchesFilter($activity, $typeFilterRaw),
));
$offset = max(0, ($page - 1) * $perPage);
$items = array_slice($activities, $offset, $perPage);
return [
'items' => $items,
'total' => count($activities),
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @param array<string, mixed> $activity
*/
private function creditActivityMatchesFilter(array $activity, string $typeFilterRaw): bool
{
$raw = trim($typeFilterRaw);
if ($raw === '') {
return true;
}
$parts = array_values(array_filter(array_map(
static fn (string $part): string => Str::lower(trim($part)),
explode(',', $raw),
)));
if ($parts === []) {
return true;
}
$kind = (string) ($activity['activity_kind'] ?? '');
$status = (string) ($activity['activity_status'] ?? '');
$netAmount = (int) ($activity['net_amount'] ?? 0);
$rebateAmount = (int) ($activity['rebate_amount'] ?? 0);
foreach ($parts as $part) {
if ($part === 'bet' && $kind === 'bet') {
return true;
}
if ($part === 'game_settlement' && $kind === 'draw_result') {
return true;
}
if ($part === 'bill_settlement' && $kind === 'period_settlement') {
return true;
}
if ($part === 'rebate' && $kind === 'draw_result' && $rebateAmount > 0) {
return true;
}
if ($part === 'reversal' && ($kind === 'refund' || $status === 'reversed')) {
return true;
}
if ($part === 'refund' && $kind === 'refund') {
return true;
}
if ($part === 'win_credit' && $kind === 'draw_result' && $netAmount > 0) {
return true;
}
if ($part === 'credit_release' && in_array($kind, ['draw_result', 'refund', 'period_settlement'], true)) {
return true;
}
}
return false;
}
/**
* 兼容没有订单领域记录的旧数据与孤立测试数据;真实玩家活动优先走聚合视图。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateLegacyCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
$includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw);
@@ -732,7 +855,7 @@ final class PlayerLedgerLogsService
return 0;
}
return \App\Support\CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
return CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
}
private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int