doge 3 päivää sitten
vanhempi
sitoutus
efe18036f1

+ 2 - 2
app/Http/Middleware/AgentAdminMiddleware.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Middleware;
 
+use App\Services\Agent\AgentAdminAccess;
 use Closure;
 use Illuminate\Http\Request;
 
@@ -9,8 +10,7 @@ class AgentAdminMiddleware
 {
     public function handle(Request $request, Closure $next)
     {
-        $adminId = (int) ($request->user->id ?? 0);
-        if (!in_array($adminId, config('agent.admin_ids', [1]), true)) {
+        if (!AgentAdminAccess::allowed((int) ($request->user->id ?? 0))) {
             return response()->json([
                 'code' => -1,
                 'timestamp' => time(),

+ 2 - 1
app/Services/AdminRealtime/AdminRealtimeAccess.php

@@ -2,6 +2,7 @@
 
 namespace App\Services\AdminRealtime;
 
+use App\Services\Agent\AgentAdminAccess;
 use App\Services\MenuService;
 use App\Services\RoleService;
 use App\Services\RoleUserService;
@@ -39,7 +40,7 @@ final class AdminRealtimeAccess
         if ($adminId === 1) {
             return $channels;
         }
-        if (!in_array($adminId, config('agent.admin_ids', [1]), true)) {
+        if (!AgentAdminAccess::allowed($adminId)) {
             $channels = array_values(array_diff($channels, AdminRealtimeRows::AGENT_CHANNELS));
         }
         $roleIds = collect(RoleUserService::findAll(['user_id' => $adminId]))->pluck('role_id')->toArray() ?: [-1];

+ 1 - 1
app/Services/AdminRealtime/AdminRealtimeRows.php

@@ -56,7 +56,7 @@ final class AdminRealtimeRows
         AdminRealtimeExchangeList::CHANNEL => ['/admin/operation/exchangeList', 'admin/operation/exchangeList', 'scope'],
     ];
 
-    /** 代理相关频道,另需管理员在 config('agent.admin_ids') 内(与 agent.admin 中间件一致) */
+    /** 代理相关频道,另需通过 AgentAdminAccess(白名单或角色勾选了代理菜单,与 agent.admin 中间件一致) */
     public const AGENT_CHANNELS = ['admin.agents.applications', 'admin.agents.plan_applications'];
 
     /** 表 => 默认频道(payment_orders 按 type 分流,见 paymentOrders) */

+ 38 - 0
app/Services/Agent/AgentAdminAccess.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Services\Agent;
+
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 总后台「代理管理」模块的准入判断,agent.admin 中间件、站内信代理收件人、代理实时频道共用。
+ *
+ * 满足其一即可进入:
+ *  - 管理员在 AGENT_ADMIN_IDS 白名单内(默认仅超级管理员 1);
+ *  - 管理员的角色被勾选了代理模块下的任意菜单或按钮(uri 以 agent / admin/agent 开头)。
+ *
+ * 这里只决定能否进入模块;具体到每个接口的按钮权限仍由 CheckButtonPermission 校验。
+ */
+final class AgentAdminAccess
+{
+    public static function allowed(int $adminId): bool
+    {
+        if ($adminId <= 0) {
+            return false;
+        }
+        if (in_array($adminId, config('agent.admin_ids', [1]), true)) {
+            return true;
+        }
+
+        return DB::table('role_user as ru')
+            ->join('role_menu as rm', 'rm.role_id', '=', 'ru.role_id')
+            ->join('menus as m', 'm.id', '=', 'rm.menu_id')
+            ->where('ru.user_id', $adminId)
+            ->where(static function ($query) {
+                $query->where('m.uri', 'like', 'agent%')
+                    ->orWhere('m.uri', 'admin/agent')
+                    ->orWhere('m.uri', 'like', 'admin/agent/%');
+            })
+            ->exists();
+    }
+}

+ 1 - 1
app/Services/SiteMessageService.php

@@ -18,7 +18,7 @@ class SiteMessageService
 
     public static function canManageAgents(): bool
     {
-        return in_array((int) (request()->user->id ?? 0), config('agent.admin_ids', [1]), true);
+        return \App\Services\Agent\AgentAdminAccess::allowed((int) (request()->user->id ?? 0));
     }
 
     public function options(): array

+ 1 - 1
docs/总后台/代理入股购买记录-前端接入.md

@@ -2,7 +2,7 @@
 
 入股代理在代理后台按比例购买股份(`POST /agent/equity/orders`),这里查看全部代理的购买记录,并可以取消长期未付款的订单。
 
-沿用总后台登录凭证,`/admin/agent/*` 只对 `AGENT_ADMIN_IDS` 里的管理员开放。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。金额、比例都是 4 位小数的字符串,比例单位是 %。
+沿用总后台登录凭证,`/admin/agent/*` 对 `AGENT_ADMIN_IDS` 里的管理员,以及角色勾选了代理模块菜单/按钮的管理员开放。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。金额、比例都是 4 位小数的字符串,比例单位是 %。
 
 ## 0. 菜单与权限
 

+ 1 - 1
docs/总后台/代理列表.md

@@ -1,6 +1,6 @@
 # 总后台-代理列表
 
-接口前缀:`/admin/agent`。全部接口需要总后台管理员 Token,且管理员 ID 必须在后端 `AGENT_ADMIN_IDS` 白名单中(默认只有 ID `1`):
+接口前缀:`/admin/agent`。全部接口需要总后台管理员 Token,且管理员 ID 在后端 `AGENT_ADMIN_IDS` 白名单中(默认只有 ID `1`),或其角色勾选了代理模块的菜单/按钮:
 
 ```http
 Authorization: Bearer <admin_token>

+ 1 - 1
docs/总后台/代理合营与入股-前端接入.md

@@ -2,7 +2,7 @@
 
 给总后台前端看。
 
-所有接口沿用总后台登录态,返回结构不变:`{"code":0,"timestamp":…,"msg":"OK","data":…}`。`/admin/agent/*` 仍然只对 `AGENT_ADMIN_IDS` 里的管理员开放。金额都是 4 位小数的字符串,比例单位是 %。
+所有接口沿用总后台登录态,返回结构不变:`{"code":0,"timestamp":…,"msg":"OK","data":…}`。`/admin/agent/*` 对 `AGENT_ADMIN_IDS` 里的管理员,以及角色勾选了代理模块菜单/按钮的管理员开放。金额都是 4 位小数的字符串,比例单位是 %。
 
 ## 改动一览
 

+ 1 - 1
docs/总后台/代理域名管理-前端接入.md

@@ -2,7 +2,7 @@
 
 「代理」菜单下新增子菜单「代理域名管理」,用于维护可分配给代理的域名,查看每个域名是否在使用、绑定在哪个代理。
 
-沿用总后台登录凭证,`/admin/agent/*` 只对 `AGENT_ADMIN_IDS` 里的管理员开放。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
+沿用总后台登录凭证,`/admin/agent/*` 对 `AGENT_ADMIN_IDS` 里的管理员,以及角色勾选了代理模块菜单/按钮的管理员开放。返回结构:`{"code":0,"timestamp":…,"msg":"OK","data":…}`,业务错误时 `code` 非 0,`msg` 可以直接展示。
 
 ## 0. 菜单与权限
 

+ 1 - 1
docs/总后台/代理盈亏佣金比例.md

@@ -1,6 +1,6 @@
 # 总后台-代理盈亏佣金比例
 
-需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 必须在 `AGENT_ADMIN_IDS` 白名单中。此列表只维护官方盈亏方案;一级代理给二级配置的自建方案在代理后台。
+需要管理员请求头 `Authorization: Bearer <admin_token>`,且管理员 ID 在 `AGENT_ADMIN_IDS` 白名单中,或其角色勾选了代理模块的菜单/按钮。此列表只维护官方盈亏方案;一级代理给二级配置的自建方案在代理后台。
 
 ## 1. 比例方案列表
 

+ 1 - 1
docs/总后台/信息管理-后台接口.md

@@ -17,7 +17,7 @@
 | POST | `/admin/message/delete` | 撤回,保留后台记录 |
 | GET | `/admin/message/logs` | 信息日记 |
 
-沿用后台登录和按钮权限。`options/detail` 复用 `admin/message`,收件人搜索复用 `admin/message/send`;发送、撤回和日志各用对应接口按钮权限。代理搜索及代理类信息的发送、查看、撤回需要现有代理管理授权(`AGENT_ADMIN_IDS`)。无代理授权的管理员,列表和日志也只返回用户类信息。
+沿用后台登录和按钮权限。`options/detail` 复用 `admin/message`,收件人搜索复用 `admin/message/send`;发送、撤回和日志各用对应接口按钮权限。代理搜索及代理类信息的发送、查看、撤回需要代理管理授权(在 `AGENT_ADMIN_IDS` 白名单内,或角色勾选了代理模块的菜单/按钮)。无代理授权的管理员,列表和日志也只返回用户类信息。
 
 ## 选项
 

+ 75 - 0
tests/Integration/AgentAdminAccessTest.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Middleware\AgentAdminMiddleware;
+use App\Services\Agent\AgentAdminAccess;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class AgentAdminAccessTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['agent' => ['admin_ids' => [1, 9]]]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY, uri TEXT, type INTEGER)');
+        DB::statement('CREATE TABLE bot_role_menu (role_id INTEGER, menu_id INTEGER)');
+        DB::statement('CREATE TABLE bot_role_user (role_id INTEGER, user_id INTEGER)');
+        DB::table('menus')->insert([
+            ['id' => 1, 'uri' => 'user', 'type' => 1], ['id' => 2, 'uri' => 'admin/user', 'type' => 2],
+            ['id' => 3, 'uri' => 'agentSettings', 'type' => 1], ['id' => 4, 'uri' => 'admin/agent', 'type' => 2],
+            ['id' => 5, 'uri' => 'admin/agent/recharges/audit', 'type' => 2], ['id' => 6, 'uri' => 'admin/agentless', 'type' => 2],
+        ]);
+        // 角色 10:只有会员菜单;11:代理页面菜单;12:代理列表按钮;13:代理子按钮;14:前缀相似但不属于代理
+        DB::table('role_menu')->insert([
+            ['role_id' => 10, 'menu_id' => 1], ['role_id' => 10, 'menu_id' => 2], ['role_id' => 11, 'menu_id' => 3],
+            ['role_id' => 12, 'menu_id' => 4], ['role_id' => 13, 'menu_id' => 5], ['role_id' => 14, 'menu_id' => 6],
+        ]);
+        DB::table('role_user')->insert([
+            ['role_id' => 10, 'user_id' => 2], ['role_id' => 11, 'user_id' => 3], ['role_id' => 12, 'user_id' => 4],
+            ['role_id' => 13, 'user_id' => 5], ['role_id' => 14, 'user_id' => 6], ['role_id' => 10, 'user_id' => 7], ['role_id' => 12, 'user_id' => 7],
+        ]);
+    }
+
+    protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+
+    public function test_whitelist_or_role_with_agent_menu_grants_access(): void
+    {
+        $this->assertTrue(AgentAdminAccess::allowed(1));
+        $this->assertTrue(AgentAdminAccess::allowed(9), '白名单内的管理员不需要角色');
+        $this->assertFalse(AgentAdminAccess::allowed(0));
+        $this->assertFalse(AgentAdminAccess::allowed(8), '没有角色');
+        $this->assertFalse(AgentAdminAccess::allowed(2), '角色没有勾选代理菜单');
+        $this->assertTrue(AgentAdminAccess::allowed(3), '勾选代理页面菜单');
+        $this->assertTrue(AgentAdminAccess::allowed(4), '勾选代理列表按钮');
+        $this->assertTrue(AgentAdminAccess::allowed(5), '勾选代理子按钮');
+        $this->assertFalse(AgentAdminAccess::allowed(6), 'admin/agentless 不属于代理模块');
+        $this->assertTrue(AgentAdminAccess::allowed(7), '多个角色中任一个有代理菜单即可');
+    }
+
+    public function test_middleware_follows_role_menus(): void
+    {
+        $call = function (int $adminId) {
+            $request = Request::create('/admin/agent'); $request->user = (object) ['id' => $adminId];
+            return (new AgentAdminMiddleware())->handle($request, static fn () => 'passed');
+        };
+        $this->assertSame('passed', $call(4));
+        $this->assertSame('无代理管理权限', $call(2)->getData(true)['msg']);
+        DB::table('role_menu')->where('role_id', 12)->delete();
+        $this->assertSame('无代理管理权限', $call(4)->getData(true)['msg'], '取消勾选后立即失效');
+    }
+}

+ 1 - 0
tests/Integration/AgentApplicationTest.php

@@ -265,6 +265,7 @@ class AgentApplicationTest extends TestCase
             $this->assertContains('admin.jwt', $route->gatherMiddleware());
             $this->assertContains('agent.admin', $route->gatherMiddleware());
         }
+        foreach (['menus (id INTEGER PRIMARY KEY, uri TEXT)', 'role_menu (role_id INTEGER, menu_id INTEGER)', 'role_user (role_id INTEGER, user_id INTEGER)'] as $table) DB::statement('CREATE TABLE bot_' . $table);
         $this->request(); request()->user = (object) ['id' => 20, 'username' => 'ordinary-admin'];
         $called = false;
         $response = (new \App\Http\Middleware\AgentAdminMiddleware())->handle(request(), function () use (&$called) { $called = true; });

+ 1 - 0
tests/Integration/SiteMessageTest.php

@@ -53,6 +53,7 @@ class SiteMessageTest extends TestCase
             $t->id(); $t->string('user_id'); $t->string('member_id'); $t->string('account'); $t->string('username'); $t->string('first_name');
             $t->string('language')->nullable(); $t->integer('from'); $t->dateTime('delete_time')->nullable(); $t->timestamps();
         });
+        foreach (['menus (id INTEGER PRIMARY KEY, uri TEXT)', 'role_menu (role_id INTEGER, menu_id INTEGER)', 'role_user (role_id INTEGER, user_id INTEGER)'] as $table) DB::statement('CREATE TABLE bot_' . $table);
         Schema::create('agents', function (Blueprint $t) { $t->id(); $t->string('username'); $t->string('real_name'); $t->integer('status')->default(1); $t->timestamps(); });
         Schema::create('operation_audits', function (Blueprint $t) {
             $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');