# 外管后台

## 系统边界

外管后台是独立于主后台、Databi 和 Finance 的浏览器入口：

- 主后台 `/operations/external-admin-users` 继续使用主后台 JWT、RBAC 和全局 App 选择器，负责查找 App 用户、签发外管账号、配置权限、启停账号和重置密码。
- 外管入口 `/external-admin/` 使用独立账号、服务端 opaque session 和 CSRF token，不读取主后台 token 或 localStorage。
- 外管账号、会话、登录日志和操作日志分别保存在 `external_admin_accounts`、`external_admin_sessions`、`external_admin_login_logs`、`external_admin_operation_logs`。
- 登录只提交外管账号和密码，不提交或选择 App。账号名全局唯一，服务端通过账号行自动确定 `app_code` 并固化到会话；Fami 账号只能访问 Fami 数据，请求头缺省时由会话补齐，伪造其他 App 时直接拒绝。

## 账号签发和岗位边界

创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户，再提交服务端返回的稳定 `user_id`。昵称不参与绑定，避免重名用户拿到外管凭据。

- 外管身份只使用 `local`、`country_manager`（国家外管经理）和 `external_super_admin`（外管SuperAdmin），不会创建或修改 App 经理、BD Leader 等业务身份。历史账号由迁移统一成为无上级的国家外管经理。
- 外管团队邀请使用独立的 `external_bd`、`external_agency`、`external_host` 类型和 `parent_owner_user_id` 归属。它们不会回填或复用普通 App 的 Manager → BD Leader → BD、Agency、Host 关系；Lalu 现有邀请、主播和公会链路继续只读取原字段。
- Local 不允许上级；国家外管经理可不绑定上级，也可绑定同 App、同当前区域的 active Local；外管SuperAdmin 必须绑定同 App、同当前区域的 active 国家外管经理。身份创建后不可修改。
- 上级绑定使用账号 `permission_revision` 做乐观锁；真实变化与会话撤销同事务提交。`GET /parent-candidates` 只返回与目标用户当前区域一致的合法身份候选。
- `platform-admin`：查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create` 和 `external-admin-user:permissions`，避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。
- `ops-admin`、`operations-specialist`：查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。
- 外管登录账号名跨 App 全局唯一；绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查，数据库唯一索引负责收敛并发创建竞态。
- 创建、重置后的密码可直接使用，不要求首次登录修改；停用或重置仍会撤销该账号的全部活跃会话。

## 权限配置

- `GET /api/v1/admin/external-admin-users/permission-catalog` 返回固定业务权限目录；服务端只接受目录内 code，不接受通配符或主后台 RBAC code。旧 `bd-manager:list`、`super-admin:list`、`team:view` 即使残留在历史 JSON 中也不再生效。
- `GET /api/v1/admin/external-admin-users/:id/permissions` 按当前 App 返回权限快照和 `revision`；`PUT` 必须提交 `permissions` 与 `expectedRevision`。版本不一致返回 409，防止两个管理员互相覆盖。
- 创建请求省略 `permissions` 时保留兼容行为，使用完整默认目录；显式 `[]` 表示零业务权限。无论是否省略字段，调用方都必须具备权限委派能力。
- 权限实际变化与撤销该账号全部活跃会话在同一事务提交，并递增 `permission_revision`；相同快照重复提交不递增版本、不重复撤销会话。
- 特权道具、用户称号和房间背景图在 owner 资源模型可可靠分类前按一个原子权限包校验，不能只选择其中一项。外管资源与 VIP 发放使用 `manager_center` 来源，由 Wallet 在写事务内再次检查经理可发放规则；主后台仍使用原有来源。

## 外管能力

外管只注册显式白名单路由，不提供主后台的通用代理能力：

- 用户：用户列表、资料编辑、封禁列表、封禁和解封。
- 组织：主播、公会、BD，以及仅对 Local/国家外管经理开放的外管SuperAdmin列表。旧 BD Manager 与我的团队路由不再注册。
- 房间：房间列表、房间编辑、持久封禁和人工解封；外管不能修改房间区域。
- 发放：特权道具、靓号、用户称号、房间背景图和临时财富/VIP 体验卡；外管直接 VIP 路由不再注册。
- 运营：Banner 创建、编辑和删除。

每次认证都会从绑定 App 用户实时解析 active `region_id`，再按 `Local → 国家外管经理 → 外管SuperAdmin` 重建 owner scope roots。区域为 0、用户/区域失效或父级链失效时，认证和改密仍可用，但全部业务路由 fail-closed；区域和团队事实不会复制到 admin 数据库。

BD、Agency 和 Host 均只创建 pending 邀请：user-service 在同一事务写入邀请、命令幂等事实和 outbox，activity-service 将邀请转换为带 Accept/Reject 动作的 `im_confirm`，notice-service 再通过腾讯云 IM 投递给目标用户的 C2C 会话。目标用户在 App 消息列表确认后，activity-service 才调用 user-service 接受邀请；接受事务会重新校验双方区域、所选 BD/Agency 及外管 owner 归属，全部有效才创建关系。拒绝、取消和幂等重试不会创建关系。

## 登录安全

- Session cookie 为 HttpOnly，作用域仅 `/api/v1/external`；写请求同时校验 CSRF cookie、header 和服务端 hash。
- Session 绝对有效期默认 12 小时。每次请求都会确认账号和 App 仍为 active；App 停用后旧会话不能绕过 `/auth/me` 直接调用业务接口。
- 同一账号连续失败 5 次后锁定 15 分钟；账号、密码或账号所属 App 不可用均对外返回相同的 HTTP 401 / 业务码 40100，前端按当前语言渲染本地化文案。
- Redis 在账号查询、bcrypt 和登录日志之前，先按系统全局 300、单真实 IP 30、全局账号名 10 执行 60 秒固定窗口限流；命中唯一账号后，再按服务端解析出的 App 执行单 App 120 限流。两个阶段不会重复消耗系统/IP/账号计数，请求中的旧版 `appCode` 字段即使存在也会被忽略。
- 登录请求体上限为 8 KiB；限流 Redis 在 200 ms 内不可用时 fail-closed 返回 503，不降级为可绕过的单机计数。
- 认证接口统一返回 `Cache-Control: no-store`。

## 上线步骤

1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql`、`099_external_admin_credential_delegation.sql`、`100_external_admin_global_username.sql`、`101_external_admin_permission_assignment.sql`、`121_external_admin_identity_hierarchy.sql` 和 `122_external_banner_region_scope_index.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划，再执行同一查询确认结果为空。121 只在小型凭据表增加定长身份、可空父级、自引用约束和层级索引，默认值直接将旧账号解释为国家外管经理；不读取用户、房间或钱包业务表。组合索引与外键需要校验该凭据表，并可能因 MySQL 8 小版本差异重建表，因此先确认行数并避开外管账号集中创建时段。122 构建区域 Banner 复合索引时会扫描 `admin_app_banners`，上线前检查表大小并避开 Banner 集中发布窗口。
2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动，运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。
3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback；不要信任 `0.0.0.0/0`、`::/0` 或整段未知私网。
4. 构建并发布前端 `dist/external-admin/`，保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。
5. 在 EdgeOne/WAF 对精确路径 `POST /api/v1/external/auth/login` 配置按真实客户端 IP 的 token-bucket/挑战规则，建议基线 30 次/分钟、burst 10；同时限制源站直连。WAF 负责 Redis 前的大包和连接洪峰，应用层 Redis 负责系统、App 和账号维度的正确性。

## 上线验收

- 登录请求不带 App 即可进入账号所属租户；即使旧客户端伪造其他 `appCode`，也必须登录到账号行所属 App。Fami 账号登录后请求 Lalu 的 `X-App-Code` 必须返回 403，省略 header 时仍只能看到 Fami 数据。
- 外管账号或 App 停用后，已有会话直接访问任一业务接口必须返回 401。
- 创建或重置密码后可直接进入已授权页面；历史 `password_change_required` 标记也不能阻断登录或业务接口。
- 用旧 `revision` 更新权限必须返回 409；成功减少权限后旧会话必须立即返回 401，原权限接口必须返回 403。重复提交相同快照时版本保持不变。
- 从同一真实 IP 连发超过阈值的登录请求必须返回 429 和 `Retry-After`；伪造 `X-Forwarded-For` 不能改变限流身份。
- Redis 临时不可用时新登录应在约 200 ms 后返回 503；恢复后不需要重启服务。
- `/external-admin/`、一个桌面深链和一个移动端深链均返回外管独立 HTML，不能回退到主后台入口。
- 停用、密码重置、封禁、房间编辑、资源/VIP 发放和 Banner 创建均应在外管操作日志中留下 App、外管账号、请求 ID、资源 ID 和结果。
