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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Services/Ticket/TicketLineInstantRebateApplicator.php
Normal file
78
app/Services/Ticket/TicketLineInstantRebateApplicator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user