feat: enhance player authentication and agent management features
Some checks failed
lotterLaravel CI / test (push) Has been cancelled

- Updated AGENTS.md to clarify player interface bindings and agent account restrictions.
- Improved PlayerAuthLoginController to include captcha verification for player login.
- Enhanced AdminPlayerIndexController with permission checks for admin users.
- Refactored AdminPlayerStoreController to enforce agent node restrictions for non-super admins.
- Introduced new error codes for player authentication failures and updated related services.
- Enhanced validation rules for agent profiles to include settlement cycle options.
- Improved AdminCaptchaService to support separate scopes for admin and player captcha handling.
- Updated various services to ensure proper credit management and settlement processes.
This commit is contained in:
2026-06-17 15:27:00 +08:00
parent b496a9457c
commit 2e0b257160
49 changed files with 714 additions and 334 deletions

View File

@@ -787,10 +787,6 @@ final class AdminReportQueryService
];
}
if ($limited['truncated']) {
array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']);
}
return $rows;
}

View File

@@ -6,11 +6,19 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
/**
* 后台登录图形验证码SVG 产出 + Cache 短时保存答案摘要(单行文本,便于前台用 img[src=data:...] 展示)。
* 图形验证码SVG 产出 + Cache 短时保存答案摘要(admin / player 登录共用渲染逻辑)。
*/
final class AdminCaptchaService
{
private const PREFIX = 'admin_captcha:';
public const SCOPE_ADMIN = 'admin';
public const SCOPE_PLAYER = 'player';
/** @var array<string, string> */
private const PREFIXES = [
self::SCOPE_ADMIN => 'admin_captcha:',
self::SCOPE_PLAYER => 'player_captcha:',
];
private const TTL_SECONDS = 120;
@@ -20,13 +28,13 @@ final class AdminCaptchaService
/**
* @return array{captcha_key: string, image_svg: string, image_base64: string}
*/
public function create(): array
public function create(string $scope = self::SCOPE_ADMIN): array
{
$code = $this->randomCode();
$key = (string) Str::uuid();
Cache::put(
self::PREFIX.$key,
$this->prefix($scope).$key,
$this->digest($code),
now()->addSeconds(self::TTL_SECONDS),
);
@@ -40,14 +48,14 @@ final class AdminCaptchaService
];
}
public function verify(?string $captchaKey, ?string $captchaInput): bool
public function verify(?string $captchaKey, ?string $captchaInput, string $scope = self::SCOPE_ADMIN): bool
{
if ($captchaKey === null || $captchaKey === ''
|| $captchaInput === null || trim($captchaInput) === '') {
return false;
}
$digest = Cache::pull(self::PREFIX.$captchaKey);
$digest = Cache::pull($this->prefix($scope).$captchaKey);
if ($digest === null) {
return false;
}
@@ -57,6 +65,16 @@ final class AdminCaptchaService
return hash_equals($digest, $this->digest($guess));
}
private function prefix(string $scope): string
{
$prefix = self::PREFIXES[$scope] ?? null;
if ($prefix === null) {
throw new \InvalidArgumentException('invalid_captcha_scope');
}
return $prefix;
}
private function digest(string $normalizedCode): string
{
return hash_hmac(

View File

@@ -4,6 +4,7 @@ namespace App\Services\Agent;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
/**
@@ -43,6 +44,7 @@ final class AgentCreditAllocatedSyncService
$playerTotal = (int) DB::table('player_credit_accounts as pca')
->join('players as p', 'p.id', '=', 'pca.player_id')
->where('p.agent_node_id', $agent->id)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->sum('pca.credit_limit');
$childIds = AgentNode::query()->where('parent_id', $agent->id)->pluck('id');

View File

@@ -40,6 +40,12 @@ final class AgentProfileService
$defaultRebate = array_key_exists('default_player_rebate', $payload)
? (float) $payload['default_player_rebate'] / 100
: (float) ($existingProfile->default_player_rebate ?? 0);
$settlementCycle = array_key_exists('settlement_cycle', $payload)
? (string) $payload['settlement_cycle']
: (string) ($existingProfile->settlement_cycle ?? config('agent_line_defaults.settlement_cycle', 'weekly'));
if ($settlementCycle === '') {
$settlementCycle = 'weekly';
}
$useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload);
@@ -59,7 +65,7 @@ final class AgentProfileService
);
}
return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate): AgentProfile {
return DB::transaction(function () use ($node, $payload, $parent, $totalShare, $creditLimit, $rebateLimit, $defaultRebate, $settlementCycle): AgentProfile {
$profile = AgentProfile::query()->firstOrNew(['agent_node_id' => $node->id]);
$previousCredit = (int) $profile->credit_limit;
$isNew = ! $profile->exists;
@@ -104,6 +110,7 @@ final class AgentProfileService
'can_grant_extra_rebate' => (bool) ($payload['can_grant_extra_rebate'] ?? $profile->can_grant_extra_rebate ?? false),
'can_create_child_agent' => (bool) ($payload['can_create_child_agent'] ?? ($isNew ? false : $profile->can_create_child_agent)),
'can_create_player' => (bool) ($payload['can_create_player'] ?? ($isNew ? true : $profile->can_create_player ?? true)),
'settlement_cycle' => $settlementCycle,
]);
if (! $profile->exists) {
$profile->allocated_credit = 0;
@@ -141,6 +148,7 @@ final class AgentProfileService
'can_grant_extra_rebate' => (bool) $profile->can_grant_extra_rebate,
'can_create_child_agent' => (bool) $profile->can_create_child_agent,
'can_create_player' => (bool) $profile->can_create_player,
'settlement_cycle' => (string) ($profile->settlement_cycle ?? 'weekly'),
];
}

View File

@@ -3,13 +3,17 @@
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Support\CreditAmountScale;
use App\Models\TicketItem;
use App\Services\Player\PlayerCreditService;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
final class GameSettlementReversalService
{
public function __construct(
private readonly PlayerCreditService $playerCreditService,
) {}
public function reverseTicketItem(TicketItem $item): void
{
$ledger = DB::table('share_ledger')->where('ticket_item_id', $item->id)->whereNull('reversal_of_id')->first();
@@ -60,21 +64,10 @@ final class GameSettlementReversalService
}
$player = Player::query()->find((int) $ledger->player_id);
if ($player !== null && PlayerFundingMode::usesCredit($player) && (int) $ledger->game_win_loss > 0) {
$playerId = (int) $ledger->player_id;
$row = DB::table('player_credit_accounts')->where('player_id', $playerId)->first();
if ($row !== null) {
$deltaMinor = (int) $ledger->game_win_loss;
$deltaMajor = CreditAmountScale::minorToMajor(
$deltaMinor,
(string) $player->default_currency,
);
DB::table('player_credit_accounts')
->where('player_id', $playerId)
->update([
'used_credit' => max(0, (int) $row->used_credit - $deltaMajor),
'updated_at' => $settledAt,
]);
if ($player !== null && PlayerFundingMode::usesCredit($player)) {
$gameWinLoss = (int) $ledger->game_win_loss;
if ($gameWinLoss !== 0) {
$this->playerCreditService->reverseGameSettlement($player, $gameWinLoss, $item->id);
}
}
});

View File

@@ -19,6 +19,7 @@ final class SettlementCenterLedgerService
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'game_settlement_reversal',
'settlement_confirm',
'settlement_payout',
];
@@ -572,10 +573,9 @@ final class SettlementCenterLedgerService
$id = (int) $stub->entry_id;
if ($kind === 'credit' && isset($creditById[$id])) {
$row = $creditById[$id];
$pid = (int) $row->player_id;
$bill = (string) ($row->ref_type ?? '') === 'settlement_bill'
? ($creditBillRefs[(int) ($row->ref_id ?? 0)] ?? null)
: ($playerBills[$pid] ?? null);
: null;
$items[] = $this->formatCreditEntry($row, $bill, $ticketRefs);
} elseif ($kind === 'payment' && isset($paymentById[$id])) {
$items[] = $this->formatPaymentEntry($paymentById[$id]);

View File

@@ -18,11 +18,18 @@ final class PlayerCreditService
{
$limit = max(0, (int) ($payload['credit_limit'] ?? 0));
$now = now();
$exists = DB::table('player_credit_accounts')
$row = DB::table('player_credit_accounts')
->where('player_id', $player->id)
->exists();
->first();
if ($row !== null) {
$usedTotal = (int) $row->used_credit + (int) $row->frozen_credit;
if ($limit < $usedTotal) {
throw ValidationException::withMessages([
'credit_limit' => ['below_player_used'],
]);
}
if ($exists) {
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
@@ -72,7 +79,8 @@ final class PlayerCreditService
$this->assertCreditGuards($player);
$currency = (string) $player->default_currency;
if ($amountMinor > $this->availableCreditMinor($player, $currency)) {
$majorNeeded = CreditAmountScale::minorToMajor($amountMinor, $currency);
if ($majorNeeded > $this->availableCredit($player)) {
throw ValidationException::withMessages([
'credit' => ['insufficient'],
]);
@@ -274,9 +282,58 @@ final class PlayerCreditService
]);
}
public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void
{
if ($gameWinLossSigned === 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}
$now = now();
if ($gameWinLossSigned > 0) {
$amountMinor = $gameWinLossSigned;
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => $amountMinor,
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
return;
}
$amountMinor = abs($gameWinLossSigned);
$currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency);
DB::table('player_credit_accounts')
->where('player_id', $player->id)
->update([
'used_credit' => DB::raw('used_credit + '.$majorDelta),
'updated_at' => $now,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -$amountMinor,
'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId,
'created_at' => $now,
'updated_at' => $now,
]);
}
public function releaseFromSettlement(Player $player, int $amountMinor, int $billId): void
{
if ($amountMinor <= 0) {
if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return;
}

View File

@@ -35,7 +35,7 @@ final class PlayerLedgerLogsService
/** PRD 对外类型 → credit_ledger.reason信用盘不用钱包「派彩」口径 */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold', 'game_settlement_loss'],
'reversal' => ['bet_hold_release'],
'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'],
@@ -106,6 +106,7 @@ final class PlayerLedgerLogsService
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'game_settlement_reversal',
'settlement_confirm',
'settlement_payout',
]), 5000);
@@ -180,9 +181,26 @@ final class PlayerLedgerLogsService
$pageRows = array_slice($simplified, $offset, $perPage);
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
foreach (array_slice($simplified, 0, $offset) as $priorRow) {
if (! $this->adminCreditRowAffectsAvailableBalance($priorRow)) {
continue;
}
$runningMinor -= $this->adminCreditRowSignedDelta($priorRow);
}
$items = [];
foreach ($pageRows as $formatted) {
$signed = (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
if (! $this->adminCreditRowAffectsAvailableBalance($formatted)) {
$items[] = array_merge($formatted, [
'balance_after' => null,
'balance_after_formatted' => null,
'balance_before' => null,
'balance_before_formatted' => null,
]);
continue;
}
$signed = $this->adminCreditRowSignedDelta($formatted);
$items[] = array_merge($formatted, [
'balance_after' => $runningMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
@@ -414,7 +432,13 @@ final class PlayerLedgerLogsService
->paginate($perPage, ['*'], 'page', $page);
$currency = (string) $player->default_currency;
$runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency);
$skipRows = max(0, ($paginator->currentPage() - 1) * $paginator->perPage());
$runningMinor = $this->advanceCreditLedgerRunningMinor(
(int) $player->id,
$reasonFilter,
$this->playerCreditService->availableCreditMinor($player, $currency),
$skipRows,
);
$items = $paginator->getCollection()
->map(function (object $row) use (&$runningMinor, $player, $currency): array {
$amount = (int) $row->amount;
@@ -587,6 +611,49 @@ final class PlayerLedgerLogsService
return $reason !== 'settlement_payout';
}
/**
* @param list<string>|null $reasonFilter
*/
private function advanceCreditLedgerRunningMinor(
int $playerId,
?array $reasonFilter,
int $runningMinor,
int $skipRows,
): int {
if ($skipRows <= 0) {
return $runningMinor;
}
$priorRows = $this->creditLedgerQuery($playerId, $reasonFilter)
->limit($skipRows)
->get();
foreach ($priorRows as $row) {
if (! $this->creditReasonAffectsAvailableBalance((string) $row->reason)) {
continue;
}
$runningMinor -= (int) $row->amount;
}
return $runningMinor;
}
/**
* @param array<string, mixed> $formatted
*/
private function adminCreditRowAffectsAvailableBalance(array $formatted): bool
{
return ($formatted['biz_type'] ?? '') !== 'settlement_payout';
}
/**
* @param array<string, mixed> $formatted
*/
private function adminCreditRowSignedDelta(array $formatted): int
{
return (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
}
/**
* @return array<string, mixed>
*/
@@ -636,7 +703,7 @@ final class PlayerLedgerLogsService
{
return match ($reason) {
'bet_hold', 'game_settlement_loss' => 'bet',
'bet_hold_release' => 'reversal',
'bet_hold_release', 'game_settlement_reversal' => 'reversal',
'settlement_confirm' => 'refund',
'game_settlement_win' => 'win_credit',
'settlement_payout' => 'bill_settlement',