doge 1 день назад
Родитель
Сommit
cc48e26ce5

+ 6 - 0
app/Http/Controllers/admin/PaymentOrder.php

@@ -42,6 +42,12 @@ class PaymentOrder extends Controller
                 + (int)\App\Models\Agent\AgentRecharge::where('type', 2)->where('status', 0)->count();
         } catch (\Illuminate\Database\QueryException $e) {
         }
+        // 代理申请(会员前台提交)与合营代理分成方案申请:所有待审核持续提示,不限制申请日期。
+        try {
+            $data['agentApplication'] = (int)DB::table('agent_applications')->where('status', 'pending')->count();
+            $data['agentPlanApplication'] = (int)DB::table('agent_plan_applications')->where('status', 'pending')->count();
+        } catch (\Illuminate\Database\QueryException $e) {
+        }
         // 分享赚钱领取审核:所有未审核申请持续提示,不限制申请日期。
         $data['shareClaim'] = (int)DB::table('share_claims')->where('status', 'pending')->count();
         return $this->success($data);

+ 2 - 0
app/Services/AdminRealtime/AdminRealtime.php

@@ -27,6 +27,8 @@ final class AdminRealtime
         'payment_orders' => ['updated_at', 'is_send'],
         'withdraws' => ['updated_at', 'is_send'],
         'manual_audits' => ['updated_at'],
+        'agent_applications' => ['updated_at'],
+        'agent_plan_applications' => ['updated_at'],
         'users' => [],
     ];
 

+ 3 - 0
app/Services/AdminRealtime/AdminRealtimeAccess.php

@@ -39,6 +39,9 @@ final class AdminRealtimeAccess
         if ($adminId === 1) {
             return $channels;
         }
+        if (!in_array($adminId, config('agent.admin_ids', [1]), true)) {
+            $channels = array_values(array_diff($channels, AdminRealtimeRows::AGENT_CHANNELS));
+        }
         $roleIds = collect(RoleUserService::findAll(['user_id' => $adminId]))->pluck('role_id')->toArray() ?: [-1];
         $granted = [];
         foreach (RoleService::model()::with(['menus' => function ($query) {

+ 45 - 0
app/Services/AdminRealtime/AdminRealtimeAgentApplications.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Services\AdminRealtime;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 会员代理申请由 melbet_sport-api 直接插入 agent_applications,不经 bot-28 模型钩子。
+ * relay 每轮按主键游标找出新申请并登记 created 事件;审核等修改在 bot-28 内经模型钩子登记。
+ *
+ * 游标存缓存:首次运行(或缓存丢失)从当前最大 id 开始,不补推历史申请;
+ * 列表与待处理角标不受影响,前端重连时会重新拉列表。
+ */
+final class AdminRealtimeAgentApplications
+{
+    private const CURSOR = 'admin_realtime:agent_applications_cursor';
+    private const BATCH = 200;
+
+    public static function scan(): void
+    {
+        if (!config('admin_realtime.enabled')) return;
+        try {
+            $cursor = Cache::get(self::CURSOR);
+            if ($cursor === null) {
+                Cache::forever(self::CURSOR, (int) DB::table('agent_applications')->max('id'));
+                return;
+            }
+            $ids = DB::table('agent_applications')->where('id', '>', (int) $cursor)
+                ->orderBy('id')->limit(self::BATCH)->pluck('id')->map(static fn($id): int => (int) $id)->all();
+            if (!$ids) return;
+
+            // 先登记再推进游标;登记失败抛出,下轮重试(最多重复,不会漏)。
+            $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            DB::table('admin_realtime_events')->insert(array_map(static fn(int $id): array => [
+                'resource' => 'agent_applications', 'resource_id' => (string) $id, 'action' => 'created',
+                'source' => 'melbet_sport-api', 'created_at' => $now,
+            ], $ids));
+            Cache::forever(self::CURSOR, max($ids));
+        } catch (\Throwable $e) {
+            Log::channel('realtime')->error('admin_realtime_agent_application_scan_failed', ['error' => $e->getMessage()]);
+        }
+    }
+}

+ 2 - 0
app/Services/AdminRealtime/AdminRealtimeFeed.php

@@ -35,6 +35,7 @@ final class AdminRealtimeFeed
     {
         self::scanOnline();
         AdminRealtimeSummaries::scan();
+        AdminRealtimeAgentApplications::scan();
         $limit = max(1, min(1000, (int) config('admin_realtime.batch_size', 200)));
         $gapFrom = now('Asia/Shanghai')->subSeconds(max(0, (int) config('admin_realtime.gap_seconds', 60)))->format('Y-m-d H:i:s');
         [$cursor, $rows] = DB::transaction(function () use ($cursor, $limit, $gapFrom): array {
@@ -179,6 +180,7 @@ final class AdminRealtimeFeed
         if ($resource === AdminRealtimeExchangeList::RESOURCE) return AdminRealtimeExchangeList::EVENT;
         if (isset(AdminRealtimeSummaries::DEFINITIONS[$resource])) return 'stats.updated';
         if ($resource === AdminRealtime::USER_ONLINE) return 'user.' . $action;
+        if (in_array($resource, ['agent_applications', 'agent_plan_applications'], true)) return 'application.' . $action;
         return ($resource === 'users' ? 'user.' : 'order.') . $action;
     }
 

+ 11 - 0
app/Services/AdminRealtime/AdminRealtimeRows.php

@@ -10,6 +10,8 @@ use App\Http\Controllers\admin\Order;
 use App\Http\Controllers\admin\SportGameOrder;
 use App\Http\Controllers\admin\Wallet;
 use App\Http\Controllers\admin\Withdraw;
+use App\Services\Agent\AgentApplicationService;
+use App\Services\Agent\AgentPlanApplicationService;
 use App\Services\ManualAuditService;
 use App\Services\OnlineUserService;
 use App\Services\PaymentOrderService;
@@ -48,9 +50,14 @@ final class AdminRealtimeRows
         'admin.users.online' => ['/admin/online/users', 'admin/online/users', 'member_id'],
         'admin.stats.platform_count' => ['/admin/online/platformCount', 'admin/online/platformCount', 'scope'],
         'admin.stats.unprocessed' => ['/admin/paymentOrder/unProcessed', 'admin/paymentOrder/unProcessed', 'scope'],
+        'admin.agents.applications' => ['/admin/agent/applications', 'admin/agent', 'id'],
+        'admin.agents.plan_applications' => ['/admin/agent/plan-applications', 'admin/agent/plan-applications', 'id'],
         AdminRealtimeExchangeList::CHANNEL => ['/admin/operation/exchangeList', 'admin/operation/exchangeList', 'scope'],
     ];
 
+    /** 代理相关频道,另需管理员在 config('agent.admin_ids') 内(与 agent.admin 中间件一致) */
+    public const AGENT_CHANNELS = ['admin.agents.applications', 'admin.agents.plan_applications'];
+
     /** 表 => 默认频道(payment_orders 按 type 分流,见 paymentOrders) */
     private const RESOURCE_CHANNELS = [
         'sport_order' => 'admin.orders.sport',
@@ -63,6 +70,8 @@ final class AdminRealtimeRows
         'withdraws' => 'admin.orders.withdraw',
         'manual_audits' => 'admin.orders.manual_audit',
         'users' => 'admin.users',
+        'agent_applications' => 'admin.agents.applications',
+        'agent_plan_applications' => 'admin.agents.plan_applications',
         AdminRealtime::USER_ONLINE => 'admin.users.online',
     ];
 
@@ -102,6 +111,8 @@ final class AdminRealtimeRows
             'third_game_orders' => app(ThirdGameOrderService::class)->paginate(['id' => $id])['list'] ?? [],
             'manual_audits' => ManualAuditService::list(['id' => $id])['data'] ?? [],
             'users' => UserService::paginate(['id' => $id])['data'] ?? [],
+            'agent_applications' => app(AgentApplicationService::class)->listing(['id' => $id])['data'] ?? [],
+            'agent_plan_applications' => app(AgentPlanApplicationService::class)->paginate(['id' => $id])['list'] ?? [],
         };
         $row = self::pick($rows, 'id', $id);
 

+ 2 - 1
app/Services/Agent/AgentApplicationService.php

@@ -16,11 +16,12 @@ class AgentApplicationService
     public function listing(array $input): array
     {
         $data = Validator::make($input, [
-            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'between:1,100'],
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'between:1,100'], 'id' => ['nullable', 'integer', 'min:1'],
             'status' => ['nullable', 'in:pending,approved,rejected'], 'username' => ['nullable', 'string', 'max:64'],
             'member_id' => ['nullable', 'string', 'max:64'], 'type' => ['nullable', 'integer', Rule::in(array_keys(Agent::TYPE_NAMES))],
         ])->validate();
         $query = DB::table('agent_applications as a')->join('users as u', 'u.id', '=', 'a.user_id');
+        if (!empty($data['id'])) $query->where('a.id', (int) $data['id']);
         if (!empty($data['status'])) $query->where('a.status', $data['status']);
         if (!empty($data['username'])) $query->where('a.username', 'like', '%' . $data['username'] . '%');
         if (!empty($data['member_id'])) $query->where('u.member_id', $data['member_id']);

+ 1 - 0
app/Services/Agent/AgentPlanApplicationService.php

@@ -57,6 +57,7 @@ class AgentPlanApplicationService
         $limit = min(100, max(1, (int) ($params['limit'] ?? 20)));
         $query = AgentPlanApplication::query()->with(['agent:id,type,username,real_name,balance,margin_amount', 'profile']);
         if ($agent) $query->where('agent_id', $agent->id);
+        if (!empty($params['id'])) $query->where('id', (int) $params['id']);
         if (!empty($params['agent_id'])) $query->where('agent_id', (int) $params['agent_id']);
         if (!empty($params['status'])) $query->where('status', $params['status']);
         if (!empty($params['username'])) {

+ 32 - 8
app/Services/MaintenanceService.php

@@ -48,17 +48,41 @@ class MaintenanceService
         $this->ensure();
         $texts = [];
         foreach (DB::table('maintenance_translations')->get() as $text) $texts[$text->maintenance_id][$text->language] = ['content' => $text->content];
-        $rows = [];
+        // 平台、开关、状态、语言:作用在每一行(大类和子项各自判断)
+        $hasFilter = !empty($params['platform']) || isset($params['enabled']) || !empty($params['state']) || !empty($params['language']);
+        $matches = function (array $row) use ($params, $texts): bool {
+            if (!empty($params['platform']) && !in_array($params['platform'], $row['platforms'], true)) return false;
+            if (isset($params['enabled']) && $row['enabled'] !== (int) $params['enabled']) return false;
+            if (!empty($params['state']) && $row['state'] !== $params['state']) return false;
+            if (!empty($params['language']) && !isset($texts[$row['id']][$params['language']])) return false;
+            return true;
+        };
+        $top = [];
+        $children = [];
         foreach (DB::table('maintenance_settings')->orderBy('id')->get() as $record) {
             $row = $this->dto((array) $record, $texts[$record->id] ?? []);
-            if (!empty($params['scope_code']) && $row['scope_code'] !== $params['scope_code']) continue;
-            if (!empty($params['parent_scope']) && $row['parent_scope'] !== $params['parent_scope']) continue;
-            if (!empty($params['platform']) && !in_array($params['platform'], $row['platforms'], true)) continue;
-            if (isset($params['enabled']) && $row['enabled'] !== (int) $params['enabled']) continue;
-            if (!empty($params['state']) && $row['state'] !== $params['state']) continue;
-            if (!empty($params['language']) && !isset($texts[$record->id][$params['language']])) continue;
-            $rows[] = $row;
+            if ($row['parent_scope'] === null) $top[$row['scope_code']] = $row;
+            else $children[$row['parent_scope']][] = $row;
+        }
+        // 父级缺失的子项(正常不会出现)当作顶层显示,避免丢失
+        foreach ($children as $parent => $rows) {
+            if (!isset($top[$parent])) { foreach ($rows as $row) $top[$row['scope_code']] = $row; unset($children[$parent]); }
+        }
+        $scope = $params['scope_code'] ?? '';
+        $scopeParent = $scope !== '' ? Policy::parentScope($scope) : null;
+        $rows = [];
+        foreach ($top as $code => $row) {
+            if (!empty($params['parent_scope']) && $code !== $params['parent_scope']) continue;
+            if ($scope !== '' && $code !== ($scopeParent ?? $scope)) continue;
+            $kids = $children[$code] ?? [];
+            if ($scopeParent !== null) $kids = array_values(array_filter($kids, fn ($kid) => $kid['scope_code'] === $scope));
+            $shown = $hasFilter ? array_values(array_filter($kids, $matches)) : $kids;
+            // 只按子项查询时,大类只是容器,不算命中
+            $self = $scopeParent === null && (!$hasFilter || $matches($row));
+            if (!$self && $shown === []) continue;
+            $rows[] = $row + ['matched' => $self, 'children_count' => count($children[$code] ?? []), 'children' => $shown];
         }
+        // 分页只按顶层(整站、大类);子游戏 / 平台在所属大类的 children 里
         return ['total' => count($rows), 'data' => array_slice($rows, ((int) ($params['page'] ?? 1) - 1) * (int) ($params['limit'] ?? 20), (int) ($params['limit'] ?? 20))];
     }
 

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 维护列表按层级返回(2026-11-10)
+
+- [维护列表按层级返回-前端修改单](维护列表按层级返回-前端修改单.md):`GET /admin/maintenance` 只分页整站和大类,子游戏 / 平台放在所属大类的 `children` 里,新增 `children_count`、`matched`。列表改为树形表格。
+
 ## 会员列表「上级」显示代理(2026-11-10)
 
 - [会员列表上级显示代理-前端修改单](会员列表上级显示代理-前端修改单.md):总后台会员列表新增 `superior`(上级)和 `system_agent`(所属代理)。「上级邀请人」列改为显示 `superior.label`,例如「合营:agent06」「入股:agent07」「邀请人:inviter01」。

+ 7 - 3
docs/前端交付/bot-28/后台/后台实时推送-前端接入.md

@@ -69,7 +69,7 @@ Authorization: Bearer <后台管理员 token>
 
 - `key`、`ws_host`、`ws_port`、`force_tls` 对应 Echo 的 `key`、`wsHost`、`wsPort`/`wssPort`、`forceTLS`。`key` 是公开值,不是密钥。
 - `auth_endpoint` 相对后台 API 域名,拼接方式与其它后台接口一致。
-- `channels` 按当前管理员列表权限过滤;不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
+- `channels` 按当前管理员列表权限过滤,两个代理审核频道另需该管理员有代理管理权限(与 `/admin/agent/*` 一致);不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
 - `last_seq` 是首次补发的起点,`session_expires_at` 是本次 WebSocket 连接的到期时间戳(不是登录失效时间,后台登录态已改为滑动续期,见《后台登录态滑动续期-前端说明》),不要据此主动登出。
 - HTTP 请求失败、业务 `code != 0` 时保留 HTTP 列表;返回「实时推送未启用」时不要持续重试。
 
@@ -118,6 +118,8 @@ echo.private('admin.orders.withdraw')
 | `admin.orders.payment_withdraw` | `/admin/withdraw/rmb`(`type=2/4`) | `id` | 同上 |
 | `admin.orders.withdraw` | `/admin/withdraw` | `id` | 同上 |
 | `admin.orders.manual_audit` | `/admin/manualAudit` | `id` | 同上 |
+| `admin.agents.applications` | `/admin/agent/applications`(代理申请审核) | `id` | `.application.created` `.application.updated` `.application.deleted` |
+| `admin.agents.plan_applications` | `/admin/agent/plan-applications`(合营方案申请审核) | `id` | 同上 |
 | `admin.users` | `/admin/user` | `id` | `.user.created` |
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
 | `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
@@ -269,6 +271,8 @@ Echo 回调收到的对象:
 | `admin.orders.third_party_deposit` | `/admin/thirdPartyDeposit` 单行 | `id`、`member_id`、`username`、`first_name`、`payment_company`、`payment_company_name`、`payment_method`、`channel_code`、`amount`、`fee`、`before_balance`、`after_balance`、`order_no`、`gateway_order_no`、`submitted_at`、`credited_at`、`platform`、`status`、`status_text` |
 | `admin.orders.payment_withdraw` | `/admin/withdraw/rmb` 单行(按订单的 `type` 取 2 或 4) | `payment_orders` 字段、`first_name`、`user_admin_note` |
 | `admin.orders.manual_audit` | `/admin/manualAudit` 单行 | `id`、`request_id`、`admin_id`、`member_id`、`username`、`first_name`、`transaction_type(_text)`、`change_type`、`level_before/after(_name)`、`amount`、`signed_amount`、`before_balance`、`after_balance`、`status(_text)`、`remark`、`failure_reason`、`created_at` |
+| `admin.agents.applications` | `/admin/agent/applications` 单行 | `id`、`user_id`、`member_id`、`member_account`、`username`、`type`、`type_text`、`status`、`agent_id`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at` |
+| `admin.agents.plan_applications` | `/admin/agent/plan-applications` 的 `list[]` 单行 | `agent_plan_applications` 字段、`status_text`、`agent`(`id`、`type`、`username`、`real_name`、`balance`、`margin_amount`)、`profile`(方案) |
 | `admin.users` | `/admin/user` 单行 | `users` 字段、`wallet`、`level`、`agent`、`total_consume`、`third_game_total_balance` |
 
 字段含义与各列表接口文档一致,这里不重复。
@@ -295,9 +299,9 @@ Echo 回调收到的对象:
 | 频道 | `data` 字段(均为整数) |
 | --- | --- |
 | `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
-| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`shareClaim` |
+| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`agentRecharge`、`agentApplication`、`agentPlanApplication`、`shareClaim` |
 
-平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径,`shareClaim` 则统计全部未审核申请、不限申请日期。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
+平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径;`agentRecharge`、`agentApplication`(代理申请待审核)、`agentPlanApplication`(合营方案申请待审核)、`shareClaim` 统计全部待处理、不限申请日期。代理三项仅在代理表已迁移时返回。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
 
 relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。
 

+ 59 - 0
docs/前端交付/维护列表按层级返回-前端修改单.md

@@ -0,0 +1,59 @@
+# 维护列表按层级返回-前端修改单(2026-11-10)
+
+## 改动
+
+`GET /admin/maintenance`(整站与游戏维护列表)原来把整站、大类、子游戏 / 平台**平铺**在一个列表里分页,子项会被分到别的页,很难找到。现在改为**按层级返回**:
+
+- `data` 里只有**整站和大类**(真人、电子、彩票、体育、电竞、捕鱼、棋牌、F1体育、F1彩票),分页和 `total` 只按这一层;
+- 子游戏 / 平台放在所属大类的 **`children`** 数组里。
+
+其他接口(`/detail`、`/submit`、`/status`、`/options`、`/logs`)不变。
+
+## 返回结构
+
+```json
+{
+  "total": 10,
+  "data": [
+    {"id": 1, "scope_code": "site", "scope_name": "整站", "scope_level": "site", "enabled": 0, "state": "disabled",
+     "matched": true, "children_count": 0, "children": []},
+    {"id": 3, "scope_code": "third:2", "scope_name": "电子", "scope_level": "category", "enabled": 0, "state": "disabled",
+     "matched": true, "children_count": 2,
+     "children": [
+       {"id": 21, "scope_code": "third:2:pg", "scope_name": "电子 / PG电子", "scope_level": "subcategory", "parent_scope": "third:2", "enabled": 1, "state": "active"},
+       {"id": 22, "scope_code": "third:2:jdb", "scope_name": "电子 / JDB", "scope_level": "subcategory", "parent_scope": "third:2", "enabled": 0, "state": "disabled"}
+     ]}
+  ]
+}
+```
+
+每一行(包括 `children` 里的每一项)仍然是原来的详情结构(`id`、`scope_code`、`scope_name`、`enabled`、`state`、`platforms`、时间、`translations` 等)。新增字段:
+
+| 字段 | 说明 |
+|---|---|
+| `children` | 大类下的子游戏 / 平台;整站为空数组 |
+| `children_count` | 大类下子项的总数(不受筛选影响),可以显示成「电子(2)」 |
+| `matched` | 这一行本身是否符合筛选条件。`false` 表示这个大类只是作为容器显示,因为它下面有子项符合 |
+
+## 筛选规则
+
+| 参数 | 行为 |
+|---|---|
+| `platform` / `enabled` / `state` / `language` | 大类和子项各自判断:大类本身符合、或有子项符合时显示;`children` 里只放符合的子项 |
+| `parent_scope` | 只返回这个大类,`children` 为它的子项 |
+| `scope_code` | 大类代码:只返回这个大类;子项代码:返回它所在的大类(`matched=false`),`children` 里只有这个子项 |
+
+例如筛选 `enabled=1`:如果「F1彩票」本身没开,但「加拿大28」开了维护,会返回「F1彩票」(`matched=false`),`children` 里只有「加拿大28」。
+
+## 前端要改的
+
+1. 列表改为**树形表格**(可展开的行):顶层显示 `data`,展开显示 `children`。
+2. `matched=false` 的大类行可以置灰,表示只是容器、本身不符合筛选。
+3. 编辑、开关、详情仍然用每一行自己的 `id`,子项和大类的操作方式不变。
+4. 分页只针对顶层,子项全部在 `children` 里,不需要再翻页查找。
+
+## 验收
+
+1. 打开维护列表,第一页能看到整站和全部大类;展开「电子」能看到 PG、JDB 等平台,展开「F1彩票」能看到各个彩种。
+2. 只给某个子游戏开维护,用「已开启」筛选,能在它的大类下面找到它。
+3. 在子项上开关、编辑,结果和以前一样。

+ 7 - 3
docs/总后台/后台实时推送-前端接入.md

@@ -69,7 +69,7 @@ Authorization: Bearer <后台管理员 token>
 
 - `key`、`ws_host`、`ws_port`、`force_tls` 对应 Echo 的 `key`、`wsHost`、`wsPort`/`wssPort`、`forceTLS`。`key` 是公开值,不是密钥。
 - `auth_endpoint` 相对后台 API 域名,拼接方式与其它后台接口一致。
-- `channels` 按当前管理员列表权限过滤;不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
+- `channels` 按当前管理员列表权限过滤,两个代理审核频道另需该管理员有代理管理权限(与 `/admin/agent/*` 一致);不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
 - `last_seq` 是首次补发的起点,`session_expires_at` 是本次 WebSocket 连接的到期时间戳(不是登录失效时间,后台登录态已改为滑动续期,见《后台登录态滑动续期-前端说明》),不要据此主动登出。
 - HTTP 请求失败、业务 `code != 0` 时保留 HTTP 列表;返回「实时推送未启用」时不要持续重试。
 
@@ -118,6 +118,8 @@ echo.private('admin.orders.withdraw')
 | `admin.orders.payment_withdraw` | `/admin/withdraw/rmb`(`type=2/4`) | `id` | 同上 |
 | `admin.orders.withdraw` | `/admin/withdraw` | `id` | 同上 |
 | `admin.orders.manual_audit` | `/admin/manualAudit` | `id` | 同上 |
+| `admin.agents.applications` | `/admin/agent/applications`(代理申请审核) | `id` | `.application.created` `.application.updated` `.application.deleted` |
+| `admin.agents.plan_applications` | `/admin/agent/plan-applications`(合营方案申请审核) | `id` | 同上 |
 | `admin.users` | `/admin/user` | `id` | `.user.created` |
 | `admin.users.online` | `/admin/online/users` | `member_id` | `.user.online` `.user.offline` |
 | `admin.stats.platform_count` | `/admin/online/platformCount` | `scope` | `.stats.updated` |
@@ -269,6 +271,8 @@ Echo 回调收到的对象:
 | `admin.orders.third_party_deposit` | `/admin/thirdPartyDeposit` 单行 | `id`、`member_id`、`username`、`first_name`、`payment_company`、`payment_company_name`、`payment_method`、`channel_code`、`amount`、`fee`、`before_balance`、`after_balance`、`order_no`、`gateway_order_no`、`submitted_at`、`credited_at`、`platform`、`status`、`status_text` |
 | `admin.orders.payment_withdraw` | `/admin/withdraw/rmb` 单行(按订单的 `type` 取 2 或 4) | `payment_orders` 字段、`first_name`、`user_admin_note` |
 | `admin.orders.manual_audit` | `/admin/manualAudit` 单行 | `id`、`request_id`、`admin_id`、`member_id`、`username`、`first_name`、`transaction_type(_text)`、`change_type`、`level_before/after(_name)`、`amount`、`signed_amount`、`before_balance`、`after_balance`、`status(_text)`、`remark`、`failure_reason`、`created_at` |
+| `admin.agents.applications` | `/admin/agent/applications` 单行 | `id`、`user_id`、`member_id`、`member_account`、`username`、`type`、`type_text`、`status`、`agent_id`、`reject_reason`、`reviewed_by`、`reviewed_at`、`created_at` |
+| `admin.agents.plan_applications` | `/admin/agent/plan-applications` 的 `list[]` 单行 | `agent_plan_applications` 字段、`status_text`、`agent`(`id`、`type`、`username`、`real_name`、`balance`、`margin_amount`)、`profile`(方案) |
 | `admin.users` | `/admin/user` 单行 | `users` 字段、`wallet`、`level`、`agent`、`total_consume`、`third_game_total_balance` |
 
 字段含义与各列表接口文档一致,这里不重复。
@@ -295,9 +299,9 @@ Echo 回调收到的对象:
 | 频道 | `data` 字段(均为整数) |
 | --- | --- |
 | `admin.stats.platform_count` | `total`、`apple`、`android`、`pc`、`mobile`、`wechat`、`unknown` |
-| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`shareClaim` |
+| `admin.stats.unprocessed` | `usdtRecharge`、`rgRecharge`、`rmbRecharge`、`usdtWithdraw`、`rgWithdraw`、`rmbWithdraw`、`agentRecharge`、`agentApplication`、`agentPlanApplication`、`shareClaim` |
 
-平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径,`shareClaim` 则统计全部未审核申请、不限申请日期。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
+平台统计的 `wechat` 已包含在 `mobile` 中。订单角标沿用原接口的近 24 小时口径;`agentRecharge`、`agentApplication`(代理申请待审核)、`agentPlanApplication`(合营方案申请待审核)、`shareClaim` 统计全部待处理、不限申请日期。代理三项仅在代理表已迁移时返回。权限分别沿用 `admin/online/platformCount` 和 `admin/paymentOrder/unProcessed`。
 
 relay 默认每 5 秒读取这两项统计,仅在值变化时登记并推送,首次扫描也会发送快照。该机制覆盖跨仓库写入、在线平台变化、活跃及订单时间窗口到期;不依赖 HTTP 列表刷新触发。两频道同样支持 `seq` 去重和断线补发。
 

+ 26 - 2
docs/总后台/整站与游戏维护-后台接口.md

@@ -133,13 +133,37 @@ options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{
 | 参数 | 说明 |
 |---|---|
 | page / limit | 默认 1 / 20,limit 最大 100,page 最大 1000000 |
-| scope_code | 固定范围代码,精确匹配 |
+| scope_code | 范围代码,精确匹配(子项代码会返回它所在的大类) |
+| parent_scope | 大类代码,只看这个大类及其子项 |
 | platform | PC/H5/APP,包含该平台 |
 | enabled | 0/1,手动开关 |
 | state | disabled/scheduled/active/expired,当前计算状态 |
 | language | zh/en/vi,仅筛选该语言有填写文案的设置,不影响实际维护拦截 |
 
-返回 `data:{"total":10,"data":[详情结构...]}`。所有筛选先执行,再计数和分页。
+返回 `data:{"total":10,"data":[…]}`。**按层级返回**:`data` 里只有整站和大类(分页只按这一层),子游戏 / 平台放在所属大类的 `children` 里:
+
+```json
+{"total": 10, "data": [
+  {"id": 1, "scope_code": "site", "scope_level": "site", "enabled": 0, "matched": true, "children_count": 0, "children": [], "...": "详情结构"},
+  {"id": 9, "scope_code": "third:2", "scope_name": "电子", "scope_level": "category", "enabled": 0, "matched": true, "children_count": 2,
+   "children": [
+     {"id": 21, "scope_code": "third:2:pg", "scope_name": "电子 / PG电子", "scope_level": "subcategory", "parent_scope": "third:2", "enabled": 1, "...": "详情结构"},
+     {"id": 22, "scope_code": "third:2:jdb", "scope_name": "电子 / JDB", "scope_level": "subcategory", "parent_scope": "third:2", "enabled": 0, "...": "详情结构"}
+   ]}
+]}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `children` | 这个大类下的子游戏 / 平台(详情结构,`scope_level=subcategory`);整站为空数组 |
+| `children_count` | 这个大类下子项的总数(不受筛选影响) |
+| `matched` | 这一行本身是否符合筛选条件;`false` 表示只是作为容器显示(它的子项符合) |
+
+筛选规则:
+- `platform` / `enabled` / `state` / `language` 对大类和子项分别判断:大类本身符合、或有子项符合时显示;`children` 里只放符合的子项。
+- `parent_scope`:只返回这个大类,`children` 为它的全部子项(同时有其他筛选时只放符合的)。
+- `scope_code` 是大类时只返回这个大类;是子项时返回它的大类(`matched=false`),`children` 里只有这个子项。
+- `total` 和分页都按顶层(整站、大类)计算。
 
 ## 操作日志
 

+ 48 - 1
tests/Integration/AdminRealtimeTest.php

@@ -90,7 +90,7 @@ class AdminRealtimeTest extends TestCase
         DB::table('users')->insert(['member_id' => '9001', 'user_id' => 'u9001', 'first_name' => 'Alice', 'admin_note' => 'vip', 'real_name' => '王小明']);
     }
 
-    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Model::unsetEventDispatcher(); Model::clearBootedModels(); Facade::clearResolvedInstances(); parent::tearDown(); }
 
     private function events(): array
     {
@@ -179,6 +179,53 @@ class AdminRealtimeTest extends TestCase
         ], $this->events());
     }
 
+    public function test_agent_application_audit_lists_push_rows_and_member_submissions(): void
+    {
+        Schema::create('agents', function (Blueprint $t) {
+            $t->id(); $t->integer('type')->default(1); $t->string('username'); $t->string('real_name')->default('');
+            $t->decimal('balance', 20, 4)->default(0); $t->decimal('margin_amount', 20, 4)->default(0);
+        });
+        // 用真实迁移建表:Eloquent 按模型类缓存可批量赋值的列,手写缺列的表会影响后续测试
+        foreach (['2026_10_29_140000_create_agent_applications.php', '2026_11_08_150000_add_type_to_agent_applications.php'] as $file) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
+        }
+        Schema::create('agent_profit_commission_profiles', function (Blueprint $t) { $t->id(); $t->string('name'); $t->unsignedBigInteger('owner_agent_id')->nullable(); $t->timestamps(); });
+        Schema::create('agent_plan_applications', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('profile_id'); $t->string('profile_name')->default('');
+            $t->string('status')->default('pending'); $t->string('remark')->default(''); $t->string('reject_reason')->default('');
+            $t->unsignedBigInteger('reviewed_by')->nullable(); $t->dateTime('reviewed_at')->nullable(); $t->timestamps();
+        });
+        DB::table('agent_applications')->insert(['user_id' => 1, 'username' => 'old']);
+        $this->consume(0); // 首轮只记下游标,不补推历史申请
+        $this->assertSame([], $this->events());
+
+        // 会员前台(melbet_sport-api)直接插表
+        DB::table('agent_applications')->insert(['user_id' => 1, 'username' => 'newagent', 'type' => 2, 'created_at' => '2026-10-31 10:00:00']);
+        DB::table('agents')->insert(['id' => 5, 'username' => 'joint5']);
+        DB::table('agent_profit_commission_profiles')->insert(['id' => 3, 'name' => 'A方案']);
+        $plan = \App\Models\Agent\AgentPlanApplication::create(['agent_id' => 5, 'profile_id' => 3, 'profile_name' => 'A方案', 'status' => 'pending']);
+        $batch = $this->consume(0);
+        $events = collect($batch['events'])->keyBy('channel');
+        $app = $events['admin.agents.applications'];
+        $this->assertSame(['application.created', 'created', ['id' => 2], 'melbet_sport-api'], [$app['event'], $app['action'], $app['key'], $app['source']]);
+        $this->assertSame('9001', $app['data']['member_id']);
+        $this->assertSame('入股', $app['data']['type_text']);
+        $this->assertSame(['application.created', ['id' => $plan->id]], [$events['admin.agents.plan_applications']['event'], $events['admin.agents.plan_applications']['key']]);
+        $this->assertSame('joint5', $events['admin.agents.plan_applications']['data']['agent']['username']);
+        $this->assertSame('待审核', $events['admin.agents.plan_applications']['data']['status_text']);
+
+        // 后台审核(经模型)推 updated
+        \App\Models\Agent\AgentApplication::find(2)->update(['status' => 'rejected', 'reject_reason' => 'no']);
+        $plan->update(['status' => 'approved', 'reviewed_by' => 1]);
+        $updated = collect($this->consume($batch['cursor'])['events'])->keyBy('channel');
+        $this->assertSame(['application.updated', 'rejected'], [$updated['admin.agents.applications']['event'], $updated['admin.agents.applications']['data']['status']]);
+        $this->assertSame('已通过', $updated['admin.agents.plan_applications']['data']['status_text']);
+
+        $channels = AdminRealtimeAccess::allowedChannels(1);
+        $this->assertContains('admin.agents.applications', $channels);
+        $this->assertContains('admin.agents.plan_applications', $channels);
+    }
+
     public function test_feed_resolves_rows_with_the_list_endpoint_shape(): void
     {
         $this->assertSame(0, AdminRealtimeFeed::startCursor());

+ 33 - 10
tests/Integration/MaintenanceTest.php

@@ -107,7 +107,10 @@ class MaintenanceTest extends TestCase
         $this->assertSame(10, DB::table('maintenance_settings')->count());
         $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
         $this->assertSame(ShareDefaults::categories(), Policy::CATEGORIES);
-        $this->assertSame(10 + count(self::GAME_LIST) + count(Policy::SPORTS), $this->service->listing([])['total']);
+        $all = $this->service->listing([]);
+        $this->assertSame(10, $all['total'], 'only site and categories are paged');
+        $this->assertSame(count(self::GAME_LIST) + count(Policy::SPORTS), array_sum(array_map(fn ($r) => count($r['children']), $all['data'])), 'subcategories are nested');
+        $this->assertSame([], $all['data'][0]['children'], 'site has no children');
         $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
         $this->assertNull($this->decide());
         $this->assertContains('UTC', $this->service->options()['timezones']);
@@ -171,6 +174,21 @@ class MaintenanceTest extends TestCase
         $edited = $this->service->save(['id' => $row['id'], 'timezone' => 'America/New_York']);
         $this->assertSame($row['starts_at_utc'], $edited['starts_at_utc']); $this->assertSame('2026-10-25 21:00:00', $edited['starts_at']);
         $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2'])['total']);
+        $this->assertSame([], $this->service->listing(['scope_code' => 'third:2'])['data'][0]['children'], 'third:2 has no subcategories in this fixture');
+    }
+
+    public function test_enabled_child_shows_under_its_parent_even_when_parent_is_off(): void
+    {
+        $this->service->listing([]); // 子项的维护行在第一次读取列表时补齐
+        $this->save([], 'self:lottery:pc28');
+        $list = $this->service->listing(['enabled' => 1]);
+        $this->assertSame(['self:lottery'], array_column($list['data'], 'scope_code'));
+        $this->assertFalse($list['data'][0]['matched'], 'parent itself is off');
+        $this->assertSame(['self:lottery:pc28'], array_column($list['data'][0]['children'], 'scope_code'));
+        $this->assertSame(count(self::GAME_LIST), $list['data'][0]['children_count'], 'children_count counts all subcategories');
+        $off = collect($this->service->listing(['enabled' => 0])['data'])->firstWhere('scope_code', 'self:lottery');
+        $this->assertTrue($off['matched']);
+        $this->assertNotContains('self:lottery:pc28', array_column($off['children'], 'scope_code'));
     }
 
     public function test_subcategory_can_be_maintained_without_closing_its_category(): void
@@ -185,10 +203,13 @@ class MaintenanceTest extends TestCase
             ['item_type' => 'platform', 'plat_type' => 'bad code', 'game_type' => 2, 'name' => '忽略', 'status' => 1],
         ]);
         $list = $this->service->listing(['parent_scope' => 'third:2']);
-        $this->assertSame(['third:2:pg', 'third:2:jdb'], array_column($list['data'], 'scope_code'));
-        $this->assertSame('电子 / PG电子', $list['data'][0]['scope_name']);
-        $this->assertSame('电子 / JDB', $list['data'][1]['scope_name']);
-        $this->assertSame('subcategory', $list['data'][0]['scope_level']);
+        $this->assertSame(1, $list['total']);
+        $this->assertSame(['third:2', 'category', 2], [$list['data'][0]['scope_code'], $list['data'][0]['scope_level'], $list['data'][0]['children_count']]);
+        $kids = $list['data'][0]['children'];
+        $this->assertSame(['third:2:pg', 'third:2:jdb'], array_column($kids, 'scope_code'));
+        $this->assertSame('电子 / PG电子', $kids[0]['scope_name']);
+        $this->assertSame('电子 / JDB', $kids[1]['scope_name']);
+        $this->assertSame('subcategory', $kids[0]['scope_level']);
         $row = $this->save(['translations' => ['zh' => ['content' => 'PG维护']]], 'third:2:pg');
         $this->assertSame('third:2', $row['parent_scope']);
         $this->assertSame('third:2:pg', $row['subcategory']);
@@ -200,7 +221,9 @@ class MaintenanceTest extends TestCase
         $this->save(['platforms' => ['H5']]);
         $this->assertSame('site', $this->decide('H5', 'third:2', 'third:2:pg')['scope_code']);
         $this->assertSame('third:2:pg', $this->decide('PC', 'third:2', 'third:2:pg')['scope_code']);
-        $lottery = $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0];
+        $parent = $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0];
+        $this->assertSame(['self:lottery', false], [$parent['scope_code'], $parent['matched']], 'searching a child returns its parent as container');
+        $lottery = $parent['children'][0];
         $this->assertSame('F1彩票 / 加拿大28', $lottery['scope_name']);
         $this->assertSame('self:lottery', $lottery['parent_scope']);
         $this->assertSame('site', $this->service->detail($this->id())['scope_level']);
@@ -208,9 +231,9 @@ class MaintenanceTest extends TestCase
         $this->assertContains(['value' => 'third:2:pg', 'label' => '电子 / PG电子', 'parent' => 'third:2'], $this->service->options()['subcategories']);
         $this->assertSame(['self:lottery:pc28', 'self:lottery:jisu28', 'self:lottery:lhc:3', 'self:lottery:lhc:1', 'self:lottery:lhc:4',
             'self:lottery:lhc:2', 'self:lottery:jisu:1', 'self:lottery:jisu:2', 'self:lottery:jisu:3', 'self:lottery:jisu:6',
-            'self:lottery:jisu:4', 'self:lottery:jisu:5', 'self:lottery:jisu:7'], array_column($this->service->listing(['parent_scope' => 'self:lottery'])['data'], 'scope_code'));
-        $this->assertSame('F1彩票 / 新澳门六合彩', $this->service->listing(['scope_code' => 'self:lottery:lhc:1'])['data'][0]['scope_name']);
-        $sports = $this->service->listing(['parent_scope' => 'self:sport'])['data'];
+            'self:lottery:jisu:4', 'self:lottery:jisu:5', 'self:lottery:jisu:7'], array_column($this->service->listing(['parent_scope' => 'self:lottery'])['data'][0]['children'], 'scope_code'));
+        $this->assertSame('F1彩票 / 新澳门六合彩', $this->service->listing(['scope_code' => 'self:lottery:lhc:1'])['data'][0]['children'][0]['scope_name']);
+        $sports = $this->service->listing(['parent_scope' => 'self:sport'])['data'][0]['children'];
         $this->assertSame(['足球', '篮球', '乒乓球', '排球', '板球', '网球'], array_map(fn ($r) => explode(' / ', $r['scope_name'])[1], $sports));
         $this->save([], 'self:sport:basketball');
         $this->assertSame('self:sport:basketball', $this->decide('PC', 'self:sport', 'self:sport:basketball')['scope_code']);
@@ -258,7 +281,7 @@ class MaintenanceTest extends TestCase
         $this->assertFalse(DB::table('maintenance_translations')->where('maintenance_id', $child['id'])->exists());
 
         $migration->up();
-        $this->assertSame(0, $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0]['enabled']);
+        $this->assertSame(0, $this->service->listing(['scope_code' => 'self:lottery:pc28'])['data'][0]['children'][0]['enabled']);
     }
 
     #[DataProvider('invalidInputs')]