feat: enhance agent management and validation logic

- Updated AGENTS.md to clarify agent account restrictions and permissions.
- Implemented checks in AgentNodeAdminUserStoreController and AgentNodeRoleStoreController to restrict admin user and role creation to the agent's own node.
- Enhanced validation in AdminPlayerStoreController and AdminPlayerUpdateController to enforce credit limit and rebate rate rules based on player funding mode.
- Refactored various request classes to utilize shared admin account field rules for consistency.
- Improved error handling in services related to credit allocation and rebate limits to ensure proper validation and messaging.
This commit is contained in:
2026-06-14 21:13:27 +08:00
parent 395e1c7400
commit 5b6d4cb74d
56 changed files with 1558 additions and 222 deletions

View File

@@ -26,10 +26,20 @@ final class AgentProfileService
{
$parent = $parent ?? ($node->parent_id !== null ? AgentNode::query()->find($node->parent_id) : null);
$totalShare = (float) ($payload['total_share_rate'] ?? 0);
$creditLimit = (int) ($payload['credit_limit'] ?? 0);
$rebateLimit = (float) ($payload['rebate_limit'] ?? 0) / 100;
$defaultRebate = (float) ($payload['default_player_rebate'] ?? 0) / 100;
$existingProfile = AgentProfile::query()->where('agent_node_id', $node->id)->first();
$totalShare = array_key_exists('total_share_rate', $payload)
? (float) $payload['total_share_rate']
: (float) ($existingProfile->total_share_rate ?? 0);
$creditLimit = array_key_exists('credit_limit', $payload)
? (int) $payload['credit_limit']
: (int) ($existingProfile->credit_limit ?? 0);
$rebateLimit = array_key_exists('rebate_limit', $payload)
? (float) $payload['rebate_limit'] / 100
: (float) ($existingProfile->rebate_limit ?? 0);
$defaultRebate = array_key_exists('default_player_rebate', $payload)
? (float) $payload['default_player_rebate'] / 100
: (float) ($existingProfile->default_player_rebate ?? 0);
$useRelative = $parent !== null && array_key_exists('relative_share_rate', $payload);
@@ -68,18 +78,24 @@ final class AgentProfileService
}
if ($parent !== null) {
$delta = $isNew ? $creditLimit : max(0, $creditLimit - $previousCredit);
if ($delta > 0) {
$this->creditAllocationValidator->assertAllocationWithinParent($parent, $delta);
}
$this->creditAllocationValidator->assertChildCreditLimitWithinParent(
$parent,
$isNew ? 0 : $previousCredit,
$creditLimit,
$isNew,
);
}
if ($defaultRebate > $rebateLimit && $rebateLimit > 0) {
if ($defaultRebate > $rebateLimit) {
throw ValidationException::withMessages([
'default_player_rebate' => ['exceeds_limit'],
]);
}
if ($parent !== null) {
$this->rebateLimitValidator->assertChildRebateLimitWithinParent($parent, $rebateLimit);
}
$profile->fill([
'total_share_rate' => $totalShare,
'credit_limit' => $creditLimit,
@@ -245,7 +261,7 @@ final class AgentProfileService
*/
public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void
{
if ($actor->isSuperAdmin()) {
if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) {
return;
}

View File

@@ -13,13 +13,47 @@ final class CreditAllocationValidator
private readonly AgentCreditAllocatedSyncService $allocatedSync,
) {}
public function assertChildCreditLimitWithinParent(
AgentNode $parent,
int $previousChildLimit,
int $newChildLimit,
bool $isNewChild,
): void {
if ($newChildLimit < 0) {
throw ValidationException::withMessages([
'credit_limit' => ['invalid'],
]);
}
$this->allocatedSync->syncForAgent($parent);
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'credit_limit' => ['parent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);
$floor = $isNewChild ? 0 : max(0, $previousChildLimit);
$maxAllowed = $floor + $available;
if ($newChildLimit > $maxAllowed) {
throw ValidationException::withMessages([
'credit_limit' => ['exceeds_available'],
]);
}
}
public function assertAllocationWithinParent(AgentNode $parent, int $additionalCredit): void
{
$this->allocatedSync->syncForAgent($parent);
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
return;
throw ValidationException::withMessages([
'credit_limit' => ['parent_profile_required'],
]);
}
$available = max(0, (int) $profile->credit_limit - (int) $profile->allocated_credit);

View File

@@ -12,7 +12,9 @@ final class RebateLimitValidator
{
$profile = AgentProfile::query()->where('agent_node_id', $agent->id)->first();
if ($profile === null) {
return;
throw ValidationException::withMessages([
'rebate_rate' => ['agent_profile_required'],
]);
}
// Both $rebateRate and $profile->rebate_limit are ratios (0-1)
@@ -23,10 +25,38 @@ final class RebateLimitValidator
]);
}
if ($extraRebateRate > $limit) {
throw ValidationException::withMessages([
'extra_rebate_rate' => ['exceeds_limit'],
]);
}
if ($extraRebateRate > 0 && ! $profile->can_grant_extra_rebate) {
throw ValidationException::withMessages([
'extra_rebate_rate' => ['not_allowed'],
]);
}
if ($limit > 0 && ($rebateRate + $extraRebateRate) > $limit + 1e-9) {
throw ValidationException::withMessages([
'rebate_rate' => ['exceeds_limit'],
]);
}
}
public function assertChildRebateLimitWithinParent(AgentNode $parent, float $childRebateLimitRatio): void
{
$profile = AgentProfile::query()->where('agent_node_id', $parent->id)->first();
if ($profile === null) {
throw ValidationException::withMessages([
'rebate_limit' => ['parent_profile_required'],
]);
}
if ($childRebateLimitRatio > (float) $profile->rebate_limit) {
throw ValidationException::withMessages([
'rebate_limit' => ['exceeds_parent'],
]);
}
}
}

View File

@@ -39,7 +39,14 @@ final class SettlementPaymentService
]);
}
$payAmount = min($amount, abs((int) $bill->unpaid_amount));
$unpaid = abs((int) $bill->unpaid_amount);
if ($amount > $unpaid) {
throw ValidationException::withMessages([
'amount' => ['exceeds_unpaid'],
]);
}
$payAmount = $amount;
if ($payAmount <= 0) {
return;
}

View File

@@ -25,15 +25,7 @@ final class PlayerNativeAuthService
);
}
if ($siteCode === '') {
$siteCode = trim((string) config('lottery.integration.default_site_code', ''));
}
$player = Player::query()
->where('site_code', $siteCode)
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->first();
$player = $this->resolveNativePlayer($siteCode, $username);
if ($player === null || ! is_string($player->password_hash) || $player->password_hash === '') {
throw new PlayerAuthenticationException(
@@ -132,4 +124,29 @@ final class PlayerNativeAuthService
$player->forceFill($updates)->save();
}
/**
* 彩票端登录:玩家只填账号密码,不传站点编号。
* 若请求带了 site_code部署绑站则优先在该站查找否则按账号全局匹配唯一彩票原生玩家。
*/
private function resolveNativePlayer(string $siteCode, string $username): ?Player
{
$query = Player::query()
->where('username', $username)
->where('auth_source', PlayerAuthSource::LOTTERY_NATIVE);
if ($siteCode !== '') {
$scoped = (clone $query)->where('site_code', $siteCode)->first();
if ($scoped !== null) {
return $scoped;
}
}
$candidates = $query->get();
if ($candidates->count() === 1) {
return $candidates->first();
}
return null;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Ticket;
use App\Models\Player;
use App\Support\PlayerFundingMode;
/**
* 下注行回水:钱包盘立减实扣;信用盘仅展示账期回水预估,实扣仍按全额占用授信。
*/
final class TicketLineInstantRebateApplicator
{
public function __construct(
private readonly InstantRebateResolver $instantRebateResolver,
) {}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
public function apply(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
$evaluated['rule_snapshot_json']['instant_rebate_applied'] = ! PlayerFundingMode::usesCredit($player);
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
/**
* 确认弹窗展示用回水:信用盘为账期回水预估,钱包盘为下注立减额。
*
* @param array<string, mixed> $evaluated
*/
public function displayRebateAmount(Player $player, array $evaluated): int
{
$bet = (int) $evaluated['total_bet_amount'];
if (PlayerFundingMode::usesCredit($player)) {
$rate = (float) ($evaluated['rebate_rate_snapshot'] ?? 0);
return (int) floor($bet * max(0.0, min(1.0, $rate)));
}
return max(0, $bet - (int) $evaluated['actual_deduct_amount']);
}
/** 落库订单/注单上的即时回水合计(信用盘为 0。 */
public function persistedInstantRebateAmount(Player $player, array $evaluated): int
{
if (PlayerFundingMode::usesCredit($player)) {
return 0;
}
return $this->displayRebateAmount($player, $evaluated);
}
}

View File

@@ -24,7 +24,7 @@ final class TicketPlacementService
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
private readonly RiskPoolService $riskPoolService,
private readonly TicketWalletService $ticketWalletService,
private readonly JackpotContributionService $jackpotContribution,
@@ -132,7 +132,7 @@ final class TicketPlacementService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyCreditLineInstantRebatePolicy($player, $evaluated);
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -141,7 +141,7 @@ final class TicketPlacementService
// place 阶段以 acquire 的原子扣减结果为准,允许单行售罄后形成混合成功/失败结果。
$evaluatedLines[] = $evaluated;
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
$totalBet += (int) $evaluated['total_bet_amount'];
$totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
@@ -283,7 +283,7 @@ final class TicketPlacementService
continue;
}
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->persistedInstantRebateAmount($player, $evaluated);
$item->forceFill([
'actual_deduct_amount' => (int) $evaluated['actual_deduct_amount'],
'risk_locked_amount' => $lockedAmount,
@@ -370,7 +370,7 @@ final class TicketPlacementService
])->save();
});
} catch (\Throwable $e) {
DB::transaction(function () use ($order): void {
DB::transaction(function () use ($order, $player): void {
$items = TicketItem::query()
->where('order_id', $order->id)
->where('status', 'pending_confirm')
@@ -396,10 +396,12 @@ final class TicketPlacementService
}
$order->forceFill(['status' => 'refunded'])->save();
// Reverse the bet deduct first to restore balance
if (! PlayerFundingMode::usesCredit($player)) {
$this->ticketWalletService->reverseBetDeduct($order);
$this->ticketWalletService->releaseReservedBetDeduct($order, 'wallet_deduct_failed_release');
}
$this->ticketWalletService->reverseBetDeduct($order);
});
throw $e;
@@ -526,37 +528,4 @@ final class TicketPlacementService
return str_pad($number, 4, '0', STR_PAD_LEFT);
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyCreditLineInstantRebatePolicy(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
}

View File

@@ -14,7 +14,7 @@ final class TicketPreviewService
public function __construct(
private readonly PlayCatalogResolver $catalogResolver,
private readonly PlayRuleEngine $ruleEngine,
private readonly InstantRebateResolver $instantRebateResolver,
private readonly TicketLineInstantRebateApplicator $rebateApplicator,
private readonly RiskPoolService $riskPoolService,
private readonly DrawHallSnapshotBuilder $drawHallSnapshot,
) {}
@@ -65,7 +65,7 @@ final class TicketPreviewService
$resolved['play_config'],
$resolved['odds_items'],
);
$evaluated = $this->applyPlayerInstantRebate($player, $evaluated);
$evaluated = $this->rebateApplicator->apply($player, $evaluated);
$locks = array_map(fn (array $combo): array => [
'number_4d' => $combo['number_4d'],
@@ -81,7 +81,7 @@ final class TicketPreviewService
}
}
$rebateAmount = (int) $evaluated['total_bet_amount'] - (int) $evaluated['actual_deduct_amount'];
$rebateAmount = $this->rebateApplicator->displayRebateAmount($player, $evaluated);
$totalBet += (int) $evaluated['total_bet_amount'];
$totalRebate += $rebateAmount;
$totalActualDeduct += (int) $evaluated['actual_deduct_amount'];
@@ -130,42 +130,10 @@ final class TicketPreviewService
'total_rebate_amount' => $totalRebate,
'total_actual_deduct' => $totalActualDeduct,
'total_estimated_payout' => $totalEstimatedPayout,
'instant_rebate_applied' => ! PlayerFundingMode::usesCredit($player),
],
'lines' => $lines,
'warnings' => $warningRows,
];
}
/**
* @param array<string, mixed> $evaluated
* @return array<string, mixed>
*/
private function applyPlayerInstantRebate(Player $player, array $evaluated): array
{
$resolved = $this->instantRebateResolver->resolveForPlayer(
$player,
(string) $evaluated['play_code'],
(float) $evaluated['rebate_rate_snapshot'],
);
$evaluated['rule_snapshot_json']['base_rebate_rate'] = number_format($resolved['base_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['player_addon_rebate_rate'] = number_format($resolved['player_addon_rebate_rate'], 4, '.', '');
$evaluated['rule_snapshot_json']['rebate_inherited_from_agent'] = $resolved['inherited_from_agent'];
if (PlayerFundingMode::usesCredit($player)) {
$evaluated['rebate_rate_snapshot'] = '0.0000';
$evaluated['actual_deduct_amount'] = (int) $evaluated['total_bet_amount'];
return $evaluated;
}
$finalRate = $resolved['final_rebate_rate'];
$evaluated['rebate_rate_snapshot'] = number_format($finalRate, 4, '.', '');
$evaluated['actual_deduct_amount'] = max(
0,
(int) floor((int) $evaluated['total_bet_amount'] * (1 - $finalRate)),
);
return $evaluated;
}
}

View File

@@ -133,14 +133,13 @@ final class TicketWalletService
return;
}
if (WalletTxn::query()->where('biz_type', 'bet_deduct')->where('biz_no', $order->order_no)->where('status', self::TXN_POSTED)->exists()) {
return;
}
if (WalletTxn::query()->where('biz_type', self::BIZ_BET_RESERVE_RELEASE)->where('idempotent_key', $releaseIdempotentKey)->exists()) {
return;
}
// Even if bet_deduct exists, we still need to release frozen balance
// because finalizeReservedBetDeduct may have failed to release it completely
$wallet = PlayerWallet::query()
->where('player_id', $order->player_id)
->where('wallet_type', 'lottery')