Przeglądaj źródła

注册绑定和后台推送

doge 1 dzień temu
rodzic
commit
78ee2b278b

+ 20 - 0
app/Http/Controllers/admin/User.php

@@ -648,6 +648,26 @@ class User extends Controller
         return $this->success($data);
     }
 
+    /**
+     * 给无上级的会员补绑上级:type=agent 绑合营代理(superior_id 为代理 ID),type=member 绑上级会员(superior_id 为会员 ID)。
+     */
+    function bindSuperior(\App\Services\MemberSuperiorService $service)
+    {
+        try {
+            $params = request()->validate([
+                'member_id' => ['required', 'string', 'min:1'],
+                'type' => ['required', 'string', 'in:agent,member'],
+                'superior_id' => ['required', 'string', 'min:1', 'max:64'],
+            ]);
+            $result = $service->bind($params['member_id'], $params['type'], trim($params['superior_id']));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success($result);
+    }
+
     function setRechargeChannelGroup()
     {
         try {

+ 4 - 0
app/Http/Controllers/agent/ReferralController.php

@@ -47,6 +47,10 @@ class ReferralController extends AgentApiController
                 if ($user->agent_id !== null) {
                     return ['user_id' => (int) $user->id, 'agent_id' => (int) $agent->id];
                 }
+                // 代理线和会员邀请码同时存在时以会员邀请码为主:已有邀请人的会员不再挂到代理名下
+                if (!in_array(trim((string) $user->agent_user_code), ['', '0'], true)) {
+                    return ['user_id' => (int) $user->id, 'agent_id' => null];
+                }
                 $createdAt = $user->getRawOriginal('created_at');
                 if (!$createdAt || Carbon::parse($createdAt)->lt(now()->subMinutes(config('agent.referral_bind_minutes', 30)))) {
                     throw new \RuntimeException('推广归属只能在新会员注册后及时绑定');

+ 75 - 0
app/Services/MemberSuperiorService.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Agent\Agent;
+use App\Models\User;
+use App\Services\Sharing\ShareRelations;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 后台会员列表:给没有上级(无合营代理、无邀请人)的会员补绑上级。
+ * type=agent:superior_id 为代理 ID(agents.id),写 users.agent_id;
+ * type=member:superior_id 为上级会员的会员 ID(users.member_id),写 users.agent_user_code 并挂到分享关系树。
+ * 已有任何上级的会员不能改绑。
+ */
+class MemberSuperiorService
+{
+    public const TYPE_AGENT = 'agent';
+    public const TYPE_MEMBER = 'member';
+
+    public function bind(string $memberId, string $type, string $superiorId): array
+    {
+        return DB::transaction(function () use ($memberId, $type, $superiorId) {
+            // 与注册、分享关系绑定同一把锁,先锁关系再锁会员,避免死锁和并发重复绑定
+            if (!DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first()) {
+                throw new \RuntimeException('分享关系表尚未初始化,请执行迁移');
+            }
+            $user = User::query()->where('member_id', $memberId)->lockForUpdate()->first();
+            if (!$user) throw new \RuntimeException('会员不存在');
+            if ((int) $user->from === 2) throw new \RuntimeException('试玩会员不能绑定上级');
+            if ($this->hasSuperior($user)) throw new \RuntimeException('会员已有上级,不能修改');
+
+            if ($type === self::TYPE_AGENT) {
+                $agent = ctype_digit($superiorId) ? Agent::query()->find((int) $superiorId) : null;
+                if (!$agent) throw new \RuntimeException('代理不存在');
+                if ((int) $agent->status !== Agent::STATUS_ENABLED) throw new \RuntimeException('代理已禁用');
+                $user->agent_id = $agent->id;
+                $user->save();
+                return ['member_id' => (string) $user->member_id, 'type' => $type, 'agent_id' => (int) $agent->id, 'account' => (string) $agent->username];
+            }
+
+            if ($type !== self::TYPE_MEMBER) throw new \RuntimeException('绑定类型错误');
+            $parent = User::query()->where('member_id', $superiorId)->first();
+            if (!$parent) throw new \RuntimeException('上级会员不存在');
+            if ((int) $parent->id === (int) $user->id) throw new \RuntimeException('不能绑定自己');
+            if ((int) $parent->from === 2) throw new \RuntimeException('试玩会员不能作为上级');
+            $parentCode = $this->code($parent);
+            if ($parentCode === '') throw new \RuntimeException('上级会员没有邀请码');
+            $ownCode = $this->code($user);
+            if ($ownCode === '') throw new \RuntimeException('会员邀请码为空,请先人工核对');
+            // 校验上级关系已初始化、不能绑定自己的下级(成环),并写入分享关系树
+            (new ShareRelations())->bind((int) $user->id, (int) $parent->id, $ownCode);
+            $user->agent_user_code = $parentCode;
+            $user->save();
+            return ['member_id' => (string) $user->member_id, 'type' => $type, 'superior_member_id' => (string) $parent->member_id,
+                'account' => (string) ($parent->account ?: $parent->first_name)];
+        });
+    }
+
+    public function hasSuperior(User $user): bool
+    {
+        if ($user->agent_id !== null) return true;
+        $code = trim((string) ($user->agent_user_code ?? ''));
+        if ($code !== '' && $code !== '0') return true;
+        return DB::table('share_members')->where('user_id', $user->id)->whereNotNull('parent_user_id')->exists();
+    }
+
+    /** 会员自己的邀请码:分享关系表固化的优先,其次 users.user_code。 */
+    private function code(User $user): string
+    {
+        $code = trim((string) DB::table('share_members')->where('user_id', $user->id)->value('invitation_code'));
+        if ($code === '' || $code === '0') $code = trim((string) ($user->user_code ?? ''));
+        return $code === '0' ? '' : $code;
+    }
+}

+ 2 - 0
app/Services/UserService.php

@@ -166,6 +166,8 @@ class UserService extends BaseService
             $item['total_consume'] = BalanceLog::getTotalConsume($item->member_id);//用户累计消费总额
             $item['third_game_total_balance'] = data_get($item, 'third_game_total_balance');
             $item['superior'] = self::superior($item);
+            // 无合营代理、无邀请人时,列表可「绑定上级」(POST /admin/user/bindSuperior)
+            $item['can_bind_superior'] = (int) $item->from !== 2 && $item->agent_id === null && in_array(trim((string) $item->agent_user_code), ['', '0'], true);
             // 当前周期打码量:wager_required 所需、wager_completed 当前打码量、wager_remaining 剩余打码量(提现后重置)
             foreach (WageringService::summary($item->wallet) as $key => $value) {
                 $item[$key] = $value;

+ 1 - 0
docs/admin-websocket.md

@@ -260,6 +260,7 @@ bot-28 内以下写入会产生事件:
   - 支付通道代付回调的状态更新(`PaymentOrderService`)
   - 后台批量设置打款信息、批量锁定 / 解锁提现与代付(`PaymentOrder`、`Withdraw` 控制器)
   - 会员合并时转移的投注与支付订单(`SecretService`)
+- **代理审核**:`agent_applications`、`agent_plan_applications` 经模型的审核、方案申请自动登记;会员前台(melbet_sport-api)直接插入的代理申请,由 relay 每轮按主键游标扫描新记录登记 `created`(游标存缓存,首次启动从当前最大 id 开始,不补推历史)。
 - **在线状态**:不依赖写入点,由 relay 定期比对在线集合,bot-28 与 melbet_sport-api 的登录、活跃都能覆盖。
 
 以后新增不经模型的批量写入时,在写入后调用:

+ 3 - 1
docs/代理/推广注册.md

@@ -83,7 +83,9 @@ Content-Type: application/json
 1. 请求参数里带了代理邀请码 `agent_code`:按邀请码关联;
 2. 否则按注册页的域名(请求头 `Referer` / `Origin` 的主机名)匹配总后台给代理绑定的域名。
 
-只关联启用中的代理;匹配不到就正常注册、不挂代理。下面的解析和绑定接口保留,用于注册后补绑(30 分钟内)。
+只关联启用中的代理;匹配不到就正常注册、不挂代理。
+
+**以会员邀请码为主(2026-11-10 起)**:注册时同时填写了会员邀请码(`invitationCode` / `user_code`)的,上级是邀请人会员,不再关联代理;`POST /agent/referral/bind` 对已有邀请人的会员直接返回 `agent_id: null`,不报错。下面的解析和绑定接口保留,用于注册后补绑(30 分钟内)。
 
 注册接入顺序:
 

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

@@ -2,6 +2,14 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 会员绑定上级(2026-11-10)
+
+- [会员绑定上级-前端修改单](会员绑定上级-前端修改单.md):注册时代理域名和会员邀请码同时存在,**以会员邀请码为主**,会员前台不用改。总后台会员列表新增 `can_bind_superior`,无上级的会员可以通过 `POST /admin/user/bindSuperior` 绑定合营代理(代理 ID)或上级会员(会员 ID),绑定后不能修改。
+
+## 代理审核实时推送(2026-11-10)
+
+- [代理审核实时推送-前端修改单](代理审核实时推送-前端修改单.md):「代理申请审核」「合营方案申请审核」接入 WebSocket。待处理角标 `admin.stats.unprocessed` 新增 `agentApplication`、`agentPlanApplication`,数量变大时播报;两个审核列表订阅 `admin.agents.applications`、`admin.agents.plan_applications`(事件 `.application.created/updated`)自动插入或更新行。[后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md)已同步。
+
 ## 维护列表按层级返回(2026-11-10)
 
 - [维护列表按层级返回-前端修改单](维护列表按层级返回-前端修改单.md):`GET /admin/maintenance` 只分页整站和大类,子游戏 / 平台放在所属大类的 `children` 里,新增 `children_count`、`matched`。列表改为树形表格。

+ 111 - 0
docs/前端交付/代理审核实时推送-前端修改单.md

@@ -0,0 +1,111 @@
+# 代理审核实时推送-前端修改单(2026-11-10)
+
+## 功能
+
+总后台的两个代理审核页面接入后台 WebSocket 推送(Laravel Echo + Reverb,接入方式见[后台实时推送-前端接入](bot-28/后台/后台实时推送-前端接入.md)):
+
+| 页面 | HTTP 列表 | 推送频道 |
+|---|---|---|
+| 代理申请审核 | `GET /admin/agent/applications` | `admin.agents.applications` |
+| 合营方案申请审核 | `GET /admin/agent/plan-applications` | `admin.agents.plan_applications` |
+
+1. **播报**:有新的待审核申请时,和充值、提现一样响提示音、更新菜单角标。
+2. **列表刷新**:会员在前台提交代理申请、代理在代理后台申请方案、其他管理员审核通过或驳回后,打开着的审核列表自动插入或更新该行,不用手动刷新。
+
+## 一、播报与角标(全局)
+
+沿用已有的待处理角标频道 `admin.stats.unprocessed`(事件 `.stats.updated`),`data` 新增两个整数字段,HTTP 接口 `GET /admin/paymentOrder/unProcessed` 同步返回:
+
+| 字段 | 含义 | 对应菜单 |
+|---|---|---|
+| `agentApplication` | 代理申请待审核数(`status=pending`,不限日期) | 代理申请审核 |
+| `agentPlanApplication` | 合营方案申请待审核数(`status=pending`,不限日期) | 合营方案申请审核 |
+
+示例:
+
+```json
+{
+  "usdtRecharge": 0, "rgRecharge": 1, "rmbRecharge": 0,
+  "usdtWithdraw": 0, "rgWithdraw": 0, "rmbWithdraw": 2,
+  "agentRecharge": 0, "agentApplication": 3, "agentPlanApplication": 1, "shareClaim": 0
+}
+```
+
+前端处理:
+
+- 两个字段挂到对应菜单的角标上,0 不显示。
+- **播报**:收到 `.stats.updated` 时,与上一次的值比较,`agentApplication` 或 `agentPlanApplication` **变大**就播放提示音 / 弹出通知(文案建议「有新的代理申请待审核」「有新的方案申请待审核」),变小(已审核)不播报。和现有充值、提现的播报逻辑放在一起即可。
+- 首次进入后台请求 HTTP 接口拿初始值,只作为比较基准,不播报。
+- 字段不存在时按 0 处理(代理表未迁移的环境不返回这两个字段)。
+
+relay 每 5 秒检查一次,所以播报最多延迟约 5 秒。同一个 5 秒里审核掉一条、又进来一条时总数不变,不会播报——列表推送(下一节)仍会插入新行。
+
+## 二、列表实时刷新(审核页面)
+
+进入页面时订阅对应频道,离开时 `echo.leave()`。处理方式与订单列表相同(`seq` 去重、断线补发、`resync` 时重新拉列表、打开审核弹窗时暂停列表更新)。
+
+```js
+realtime.watch('admin.agents.applications',
+  ['application.created', 'application.updated', 'application.deleted'],
+  onApplicationEvent, () => fetchApplications());
+
+realtime.watch('admin.agents.plan_applications',
+  ['application.created', 'application.updated', 'application.deleted'],
+  onPlanApplicationEvent, () => fetchPlanApplications());
+```
+
+注意事件名是 **`application.*`**,不是 `order.*`;Echo 监听时前面加点:`.application.created`。
+
+事件对象(行主键 `key.id`):
+
+```json
+{
+  "seq": 2201,
+  "id": 5530,
+  "channel": "admin.agents.applications",
+  "event": "application.created",
+  "action": "created",
+  "key": { "id": 42 },
+  "data": {
+    "id": 42, "user_id": 1001, "member_id": "M1001", "member_account": "member1001",
+    "username": "newagent", "type": 2, "type_text": "入股", "status": "pending",
+    "agent_id": null, "reject_reason": "", "reviewed_by": null, "reviewed_at": null,
+    "created_at": "2026-11-10 10:00:00"
+  },
+  "source": "melbet_sport-api",
+  "created_at": "2026-11-10 10:00:01"
+}
+```
+
+`data` 与 HTTP 列表单行完全一致:
+
+| 频道 | `data` 等于 |
+|---|---|
+| `admin.agents.applications` | `GET /admin/agent/applications` 的 `data.data[]` 单行 |
+| `admin.agents.plan_applications` | `GET /admin/agent/plan-applications` 的 `data.list[]` 单行(含 `status_text`、`agent`、`profile`) |
+
+| `action` | 什么时候 | 前端处理 |
+|---|---|---|
+| `created` | 会员 / 代理提交了新申请 | 第一页、默认倒序、且符合当前筛选(`status`、`type`、`username` 等)时插到顶部;否则提示「有新申请」 |
+| `updated` | 被审核通过 / 驳回 | 按 `key.id` 替换当前页的行;新状态不满足当前筛选(例如筛选「待审核」)就移除该行 |
+| `deleted` | 目前没有删除入口,预留 | 按 `key.id` 移除 |
+
+列表顶部的总数 `total` 不随推送变化,需要时节流重新请求列表。
+
+### 权限
+
+两个频道只对有代理管理权限的管理员开放(与 `/admin/agent/*` 接口相同),同时要求有对应列表的按钮权限:
+
+- 代理申请审核:`admin/agent`
+- 合营方案申请审核:`admin/agent/plan-applications`
+
+没有权限时 `GET /admin/realtime/config` 的 `channels` 不包含它们,订阅授权返回 403;页面照常使用 HTTP 列表,不要重试。
+
+## 三、代理后台、会员前台
+
+不需要改。
+
+## 上线
+
+- 后端无新迁移;发布代码后按[后端部署文档](../admin-websocket.md)执行 `php artisan optimize` 并**重启 relay**(`supervisorctl restart bot28-admin-relay`),新频道和代理申请扫描才会生效。
+- 会员前台提交的代理申请由 relay 每轮扫描新记录登记推送,melbet_sport-api 不需要改动。relay 首次启动时从当前最新申请开始,不补推历史申请。

+ 1 - 1
docs/前端交付/会员列表上级显示代理-前端修改单.md

@@ -26,7 +26,7 @@
 | 都没有 | `null` |
 
 - `label`:**直接显示**,例如「合营:agent06」「入股:agent07」「邀请人:inviter01」;测试代理会加「(测试)」,例如「合营:agent08(测试)」。
-- 会员同时有代理和邀请人时,`superior` 显示**代理**;邀请人仍然在原来的 `agent` 字段里。
+- 会员同时有代理和邀请人时,`superior` 显示**代理**;邀请人仍然在原来的 `agent` 字段里。(2026-11-10 起注册时以会员邀请码为主,新会员不会再同时有两者,见[会员绑定上级-前端修改单](会员绑定上级-前端修改单.md)。)
 - 原来的 `agent` 字段不变(仍然是邀请人会员)。
 
 ## 前端要改的

+ 104 - 0
docs/前端交付/会员绑定上级-前端修改单.md

@@ -0,0 +1,104 @@
+# 会员绑定上级-前端修改单(2026-11-10)
+
+本次有两处改动:
+
+1. **注册时以会员邀请码为主**:会员前台不用改,只是归属规则变了。
+2. **总后台会员列表新增「绑定上级」**:前端需要新增按钮和弹窗。
+
+---
+
+## 一、注册:代理线和会员邀请码同时存在时,以会员邀请码为主
+
+会员从合营 / 入股代理绑定的域名(或带 `agent_code` 的链接)进入注册页,**又填写了会员邀请码**(`invitationCode` / `user_code`)时:
+
+| 情况 | 上级 |
+|---|---|
+| 只来自代理域名 / `agent_code` | 代理(`users.agent_id`) |
+| 只填了会员邀请码 | 邀请人会员 |
+| **两个都有** | **邀请人会员**,不再挂到代理名下 |
+| 都没有 | 无上级 |
+
+- 会员前台注册接口 `POST /api/user/register` 的参数不变,前端不用改。
+- 注册后补绑代理的接口 `POST /agent/referral/bind` 也按这条规则处理:会员已有邀请人时**不报错**,返回 `{"user_id":92385294,"agent_id":null}`,表示没有挂到代理名下。前端照常调用就行,不要把 `agent_id` 为 `null` 当成失败提示给会员。
+- 总后台会员列表「上级」列(`superior`)对这类会员显示「邀请人:账号」。
+
+---
+
+## 二、总后台会员列表:给无上级的会员绑定上级
+
+会员列表里**没有上级**(`superior` 为 `null`)的会员,可以由管理员补绑一个上级:可以绑**合营代理**(填代理 ID),也可以绑**会员**(填会员 ID)。已经有上级的会员不能改绑。
+
+### 1. 列表新增字段
+
+`GET /admin/user`(会员列表)每个会员新增:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `can_bind_superior` | bool | `true` 时显示「绑定上级」按钮。条件是:不是试玩会员、没有所属代理、也没有邀请人 |
+
+后台实时推送的会员行(`admin.users` 频道)用的是同一个接口的数据,也带这个字段。
+
+### 2. 绑定接口
+
+`POST /admin/user/bindSuperior`
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `member_id` | string | 是 | 要绑定上级的会员,也就是列表行的 `member_id` |
+| `type` | string | 是 | `agent` 绑合营代理;`member` 绑会员 |
+| `superior_id` | string | 是 | `type=agent` 时填**代理 ID**(代理列表的 `id`);`type=member` 时填上级的**会员 ID**(会员列表的 `member_id`) |
+
+请求示例:
+
+```json
+{"member_id":"92385294","type":"agent","superior_id":"6"}
+```
+
+```json
+{"member_id":"92385294","type":"member","superior_id":"92385001"}
+```
+
+成功响应:
+
+```json
+{"code":0,"msg":"OK","data":{"member_id":"92385294","type":"agent","agent_id":6,"account":"agent06"}}
+```
+
+```json
+{"code":0,"msg":"OK","data":{"member_id":"92385294","type":"member","superior_member_id":"92385001","account":"inviter01"}}
+```
+
+`account` 是上级的账号(代理账号或会员账号),可以用在成功提示里。
+
+失败时 `code` 不为 `0`,**直接把 `msg` 提示给管理员**。常见的有:
+
+| `msg` | 原因 |
+|---|---|
+| 会员不存在 | `member_id` 有误 |
+| 会员已有上级,不能修改 | 会员已经有代理或邀请人(例如列表数据过时了) |
+| 试玩会员不能绑定上级 | 要绑定的是试玩会员 |
+| 代理不存在 / 代理已禁用 | 代理 ID 有误,或者代理已停用 |
+| 上级会员不存在 | 会员 ID 有误 |
+| 不能绑定自己 | 上级填了会员自己 |
+| 不能绑定自己或自己的下级 | 上级是这个会员的下级,绑定后会形成循环 |
+| 试玩会员不能作为上级 | 上级是试玩会员 |
+| 推广会员关系尚未初始化 / 上级会员没有邀请码 | 上级会员的推广数据有问题,需要后端核对 |
+
+### 3. 前端要改的
+
+1. 会员列表操作列:`can_bind_superior` 为 `true` 时显示「绑定上级」按钮,否则不显示。
+2. 点击后打开弹窗:
+   - **上级类型**:单选「合营代理」(`agent`)/「会员」(`member`),默认「合营代理」。
+   - **上级 ID**:输入框。选「合营代理」时提示「请输入代理 ID」,选「会员」时提示「请输入会员 ID」。
+   - 会员信息只读显示(会员 ID、账号),方便确认。
+   - 确认前提示:**绑定后不能修改**。
+3. 提交 `POST /admin/user/bindSuperior`。成功后提示「绑定成功」并刷新当前行或列表,这时「上级」列会显示「合营:agent06」或「邀请人:inviter01」,按钮也会消失;失败就提示 `msg`。
+4. (可选)上级类型为「合营代理」时,ID 输入框可以改成代理下拉,选项来自代理列表,提交的仍然是代理 `id`。
+
+### 验收
+
+1. 无上级的会员有「绑定上级」按钮;已有代理、已有邀请人的会员和试玩会员都没有这个按钮。
+2. 填代理 ID 绑定后,「上级」列显示「合营:代理账号」(入股代理显示「入股:账号」),这个会员也出现在该代理后台的「所有会员」里。
+3. 填会员 ID 绑定后,「上级」列显示「邀请人:账号」,上级会员的推广下级里能看到这个会员。
+4. 再次绑定,或者填自己、自己的下级、不存在的 ID、已停用的代理时,都会提示对应错误,数据不变。
+5. 从代理域名进入并填写了会员邀请码注册的会员,「上级」显示「邀请人:账号」,不显示代理。

+ 1 - 0
routes/admin.php

@@ -477,6 +477,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/registerTrend', [User::class, 'registerTrend']);
             Route::get('/registerDomain', [User::class, 'registerDomain']);
             Route::post('/setRechargeChannelGroup', [User::class, 'setRechargeChannelGroup']);
+            Route::post('/bindSuperior', [User::class, 'bindSuperior']);
             Route::post('/setPassword', [User::class, 'setPassword']);
             Route::post('/freeze', [WalletFreeze::class, 'freeze']);
             Route::post('/unfreeze', [WalletFreeze::class, 'unfreeze']);

+ 94 - 0
tests/Integration/AdminBindSuperiorTest.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\User as AdminUser;
+use App\Models\Agent\Agent;
+use App\Services\MemberSuperiorService;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 后台会员列表:无上级的会员可补绑合营代理(代理 ID)或上级会员(会员 ID),已有上级不能改。 */
+class AdminBindSuperiorTest extends TestCase
+{
+    use AgentTestEnvironment { setUp as private agentSetUp; }
+
+    protected function setUp(): void
+    {
+        $this->agentSetUp();
+        Schema::table('users', function (Blueprint $t) {
+            $t->integer('from')->default(1); $t->string('account')->default('');
+            $t->string('user_code')->nullable(); $t->string('agent_user_code')->nullable();
+        });
+        Schema::create('share_members', function (Blueprint $t) {
+            $t->unsignedBigInteger('user_id')->primary(); $t->unsignedBigInteger('parent_user_id')->nullable();
+            $t->string('invitation_code', 100)->default(''); $t->string('binding_status', 24)->default('root');
+            $t->unsignedInteger('level')->default(1); $t->dateTime('bound_at'); $t->timestamps();
+        });
+        Schema::create('share_tree', function (Blueprint $t) {
+            $t->unsignedBigInteger('ancestor_user_id'); $t->unsignedBigInteger('descendant_user_id');
+            $t->unsignedInteger('depth'); $t->dateTime('bound_at')->default('1970-01-01 00:00:00');
+            $t->primary(['ancestor_user_id', 'descendant_user_id']);
+        });
+        Schema::create('share_accounts', function (Blueprint $t) {
+            $t->unsignedBigInteger('user_id')->primary(); $t->timestamps();
+        });
+        DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']);
+    }
+
+    private function member(string $memberId, array $extra = []): int
+    {
+        $id = DB::table('users')->insertGetId($extra + ['member_id' => $memberId, 'username' => $memberId, 'account' => $memberId,
+            'user_code' => 'C' . $memberId]);
+        if (($extra['from'] ?? 1) !== 2) {
+            DB::table('share_members')->insert(['user_id' => $id, 'invitation_code' => 'C' . $memberId, 'bound_at' => '2026-09-01 00:00:00']);
+            DB::table('share_tree')->insert(['ancestor_user_id' => $id, 'descendant_user_id' => $id, 'depth' => 0]);
+        }
+        return $id;
+    }
+
+    private function call(array $params): array
+    {
+        $request = Request::create('/admin/user/bindSuperior', 'POST', $params);
+        $request->user = (object) ['id' => 1];
+        $this->app->instance('request', $request);
+        return (new AdminUser())->bindSuperior(new MemberSuperiorService())->getData(true);
+    }
+
+    public function test_binds_agent_or_member_only_when_no_superior(): void
+    {
+        $agent = $this->agent();
+        $this->member('1001');
+        $b = $this->member('1002');
+        $c = $this->member('1003');
+        $this->member('1004', ['from' => 2]);
+
+        $data = $this->call(['member_id' => '1001', 'type' => 'agent', 'superior_id' => (string) $agent->id]);
+        $this->assertSame(0, $data['code'], (string) $data['msg']);
+        $this->assertSame($agent->id, (int) DB::table('users')->where('member_id', '1001')->value('agent_id'));
+        $this->assertSame('会员已有上级,不能修改', $this->call(['member_id' => '1001', 'type' => 'member', 'superior_id' => '1002'])['msg']);
+
+        $data = $this->call(['member_id' => '1003', 'type' => 'member', 'superior_id' => '1002']);
+        $this->assertSame(0, $data['code'], (string) $data['msg']);
+        $this->assertSame('C1002', DB::table('users')->where('id', $c)->value('agent_user_code'));
+        $this->assertSame($b, (int) DB::table('share_members')->where('user_id', $c)->value('parent_user_id'));
+        $this->assertTrue(DB::table('share_tree')->where(['ancestor_user_id' => $b, 'descendant_user_id' => $c, 'depth' => 1])->exists());
+        $this->assertSame('会员已有上级,不能修改', $this->call(['member_id' => '1003', 'type' => 'agent', 'superior_id' => (string) $agent->id])['msg']);
+
+        $this->assertSame('不能绑定自己或自己的下级', $this->call(['member_id' => '1002', 'type' => 'member', 'superior_id' => '1003'])['msg'], 'no cycle');
+        $this->assertSame('不能绑定自己', $this->call(['member_id' => '1002', 'type' => 'member', 'superior_id' => '1002'])['msg']);
+        $this->assertSame('上级会员不存在', $this->call(['member_id' => '1002', 'type' => 'member', 'superior_id' => '9999'])['msg']);
+        $this->assertSame('代理不存在', $this->call(['member_id' => '1002', 'type' => 'agent', 'superior_id' => '9999'])['msg']);
+        $this->assertSame('试玩会员不能作为上级', $this->call(['member_id' => '1002', 'type' => 'member', 'superior_id' => '1004'])['msg']);
+        $this->assertSame('试玩会员不能绑定上级', $this->call(['member_id' => '1004', 'type' => 'member', 'superior_id' => '1002'])['msg']);
+        $this->assertNotSame(0, $this->call(['member_id' => '1002', 'type' => 'other', 'superior_id' => '1'])['code']);
+
+        Agent::query()->whereKey($agent->id)->update(['status' => Agent::STATUS_DISABLED]);
+        $this->assertSame('代理已禁用', $this->call(['member_id' => '1002', 'type' => 'agent', 'superior_id' => (string) $agent->id])['msg']);
+        $this->assertNull(DB::table('users')->where('id', $b)->value('agent_id'));
+    }
+}