Files
lotteryLaravel/app/Support/AdminAgentScope.php
kang 6b2ea39ea1
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
fix: 修复并发竞态与幂等性缺陷并优化子树查询
- 扩大玩家ID随机数位宽降低碰撞概率
- 授信额度变更加 lockForUpdate 防并发下调
- 结算冲正与入账利用唯一索引实现幂等闸门
- 失败登录计数加锁防丢失
- 代理子树查询改用子查询替代全表 pluck
- 修复返点默认值百分比单位转换
- 测试中全量 fake 广播事件防 CI 500
2026-06-18 09:38:24 +08:00

248 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Support;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use Illuminate\Database\Eloquent\Builder;
/**
* 代理子树数据范围P1节点访问P2 起叠加玩家 agent_node_id
*/
final class AdminAgentScope
{
public static function primaryAgentNode(AdminUser $admin): ?AgentNode
{
if ($admin->isSuperAdmin()) {
return null;
}
$agentId = $admin->primaryAgentNodeId();
if ($agentId === null) {
return null;
}
return AgentNode::query()->find($agentId);
}
/** 仅站点运营admin_user_site_roles未绑定代理节点。 */
public static function isSiteOnlyOperator(AdminUser $admin): bool
{
if ($admin->isSuperAdmin() || self::primaryAgentNode($admin) !== null) {
return false;
}
$siteIds = $admin->accessibleAdminSiteIds();
return $siteIds !== null && $siteIds !== [];
}
public static function nodeVisibleTo(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
// Agent account (bound via agent node) - check first
// Even if they also have site roles, agent binding takes precedence for visibility
$actor = self::primaryAgentNode($admin);
if ($actor !== null) {
return $node->isSameOrDescendantOf($actor);
}
// Check if admin is a platform account (bound via admin_user_site_roles)
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
if ($accessibleSiteIds !== null) {
// Platform account (site admin) can see all nodes in the site
return in_array((int) $node->admin_site_id, $accessibleSiteIds, true);
}
return false;
}
public static function playerAccessible(AdminUser $admin, Player $player): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
$actor = self::primaryAgentNode($admin);
if ($actor !== null) {
if ($player->agent_node_id === null) {
return false;
}
$playerAgent = AgentNode::query()->find((int) $player->agent_node_id);
if ($playerAgent === null) {
return false;
}
return $playerAgent->isSameOrDescendantOf($actor);
}
return self::isSiteOnlyOperator($admin);
}
public static function nodeManageableBy(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
if (! $admin->hasPermissionCode('agent.node.manage')) {
return false;
}
return self::nodeVisibleTo($admin, $node);
}
/** 占成/授信/回水仅可由上级或平台修改,代理本人不可改自己的 profile。 */
public static function nodeProfileEditableBy(AdminUser $admin, AgentNode $node): bool
{
if ($admin->isSuperAdmin()) {
return true;
}
// 一级代理 profile 仅超管可维护(站点总额度、占成、回水等)
if ($node->isRoot()) {
return false;
}
if (
! $admin->hasPermissionCode('agent.profile.manage')
&& ! $admin->hasPermissionCode('agent.node.manage')
) {
return false;
}
$actor = self::primaryAgentNode($admin);
if ($actor !== null) {
if ((int) $actor->id === (int) $node->id) {
return false;
}
return $node->isDescendantOf($actor);
}
if (! self::isSiteOnlyOperator($admin)) {
return false;
}
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
return in_array((int) $node->admin_site_id, $accessibleSiteIds ?? [], true);
}
/**
* @return Builder<AgentNode>
*/
public static function visibleNodesQuery(AdminUser $admin, int $adminSiteId): Builder
{
$query = AgentNode::query()
->where('admin_site_id', $adminSiteId)
->orderBy('path');
if ($admin->isSuperAdmin()) {
return $query;
}
// Agent account (bound via agent node) - check first
// Even if they also have site roles, agent binding takes precedence
$actor = self::primaryAgentNode($admin);
if ($actor !== null) {
if ((int) $actor->admin_site_id !== $adminSiteId) {
return $query->whereRaw('0 = 1');
}
return $query->where('path', 'like', $actor->path.'%');
}
// Check if admin is a platform account (bound via admin_user_site_roles)
$accessibleSiteIds = $admin->accessibleAdminSiteIds();
if ($accessibleSiteIds !== null) {
// Platform account (site admin) can see all nodes in the site
if (in_array($adminSiteId, $accessibleSiteIds, true)) {
return $query;
}
return $query->whereRaw('0 = 1');
}
return $query->whereRaw('0 = 1');
}
/**
* 玩家必须落在当前代理子树agent_node_id 必填,由迁移回填根代理)。
*
* 性能优化:原实现先 pluck('id') 全表扫代理树再 whereInN+1 + 中间数组大。
* 改用子查询SELECT id FROM agent_nodes WHERE path LIKE ?),单次 SQL
* 走 idx_agent_nodes_path 索引。
*
* @param Builder<Player> $query
*/
public static function applyToPlayerQuery(Builder $query, AdminUser $admin): void
{
if ($admin->isSuperAdmin()) {
return;
}
$actor = self::primaryAgentNode($admin);
if ($actor === null) {
if (! self::isSiteOnlyOperator($admin)) {
$query->whereRaw('0 = 1');
}
return;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
// 委托给统一子树过滤:先 count 一次(带 LIMIT 1 优化)确认子树非空,再用子查询展开。
self::applySubtreeFilter($query, 'agent_node_id', (string) $actor->path);
}
/**
* 在已有站点/代理范围上,再按指定节点子树收窄(超管筛选用)。
*
* @param Builder<Player> $query
*/
public static function applyRequestedAgentNodeFilter(Builder $query, AdminUser $admin, int $agentNodeId): void
{
$node = AgentNode::query()->find($agentNodeId);
if ($node === null || ! self::nodeVisibleTo($admin, $node)) {
$query->whereRaw('0 = 1');
return;
}
if (! \Illuminate\Support\Facades\Schema::hasColumn('players', 'agent_node_id')) {
return;
}
self::applySubtreeFilter($query, 'agent_node_id', (string) $node->path);
}
/**
* 按 path 前缀对指定列应用代理子树过滤。
*
* 用子查询 `IN (SELECT id FROM agent_nodes WHERE path LIKE ?)` 替代
* 「全表 pluck 再 whereIn」单次 SQL 走 idx_agent_nodes_path 索引,
* 避免 N+1 与中间大数组。子查询天然短路空子树(返回空集 → IN 永不命中)。
*
* 注意 $path / $column 由调用方控制且为已知安全值,不走用户输入;
* LIKE 绑定值仍用参数化。path 中 % / _ / \ 也已做转义,防止 LIKE 元字符注入。
*
* @param Builder<Player> $query
*/
private static function applySubtreeFilter(Builder $query, string $column, string $path): void
{
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
$prefix = $escaped.'%';
$query->whereIn($column, function ($subQuery) use ($prefix): void {
$subQuery->from('agent_nodes')->select('id')->whereRaw('path LIKE ?', [$prefix]);
});
}
}