feat: enhance player authentication and agent management features
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
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:
@@ -787,10 +787,6 @@ final class AdminReportQueryService
|
||||
];
|
||||
}
|
||||
|
||||
if ($limited['truncated']) {
|
||||
array_unshift($rows, ['警告', '审计日志已截断至 5000 条,请缩小日期范围后重试']);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user