feat: Phase 2 Sider 菜单由 FBA 服务端授权驱动 + 菜单/角色播种脚本(Epic #159)

This commit is contained in:
2026-08-06 10:04:36 +08:00
parent 5b673fcdc7
commit 067ba0abe1
4 changed files with 267 additions and 8 deletions
+10 -1
View File
@@ -76,7 +76,16 @@ docker exec -it fba_postgres psql -U postgres -d fba # 进数据库
- **Phase 1**:iAOP 前端 `web/shared/session.js` 改为调用
`/fba/api/v1/auth/login`(JSON 登录)与 `/fba/api/v1/auth/codes`
(授权码)做登录与鉴权,现有 `core/auth` 退役。
- **Phase 2**:Sider 菜单由 FBA `sys_menu` 接口驱动,替代前端硬编码菜单。
- **Phase 2**:Sider 菜单由 FBA `sys_menu` 接口驱动(`GET /fba/api/v1/sys/menus/sidebar`,
服务端按角色授权过滤),替代前端硬编码角色过滤。**需先执行菜单播种**:
```bash
python3 deploy/fba/seed_iaop_menus.py \
--base http://127.0.0.1:8001/api/v1 \
--username admin --password '<FBA管理员密码>'
```
脚本幂等:注册 iAOP 6 模块菜单(含子菜单)、创建 `engineer`/`viewer`
角色并按 PRD 8.2 分配菜单。之后在 FBA 后台给用户分配角色即可。
未播种时前端自动降级为本地角色过滤,菜单不会空白。
- **Phase 3**:FBA UI(Vue3 + AntDV)构建时设置
`VITE_GLOB_API_URL=http://<域名>/fba`,产物由 nginx 挂在 `/fba-admin/`,
作为用户/角色/菜单管理界面。
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""iAOP 菜单/角色播种到 FBA(Epic #159 · Phase 2)。
把 iAOP 的 6 个功能模块注册为 FBA sys_menu,并创建 engineer / viewer 角色、
按 PRD 8.2 三级权限分配菜单,使 Sider 菜单可由 FBA 服务端按角色授权驱动
(前端 web/shared/session.js 的 menuTree() 消费 /sys/menus/sidebar)。
命名约定(与 session.js 的匹配逻辑对应,改动需双侧同步):
· 模块菜单 name = 模块 key(cockpit/chat/studio/admin/users/mobile)
· 子菜单 name = 子页 key(import/hyper/layout/version/models/kb/alerts/audit)
· studio/admin 为目录(type=0)挂子菜单(type=1),其余为叶子菜单(type=1)
幂等:按 name 查重,已存在的菜单/角色跳过创建,只补齐缺失项;
角色菜单关联每次按目标集合全量刷新(PUT /sys/roles/{id}/menus)。
用法(服务器上执行,仅需 Python 3.8+ 标准库):
python3 seed_iaop_menus.py \
--base http://127.0.0.1:8001/api/v1 \
--username admin --password '<FBA管理员密码>'
说明:登录走 /auth/login/swagger(HTTP Basic,免图形验证码),
专为运维/调试通道;不依赖 LOGIN_CAPTCHA_ENABLED 开关。
"""
import argparse
import base64
import getpass
import json
import sys
import urllib.error
import urllib.request
# ---- iAOP 模块注册表(镜像 web/shared/session.js 的 MODULES,顺序即菜单顺序) ----
MODULES = [
{"key": "cockpit", "title": "配置化驾驶舱", "icon": "🚀", "path": "/cockpit/"},
{"key": "chat", "title": "对话助手", "icon": "💬", "path": "/chat/assistant.html"},
{"key": "studio", "title": "模板配置台", "icon": "🧩", "path": "/studio/", "children": [
{"key": "import", "title": "点位导入"},
{"key": "hyper", "title": "模型超参"},
{"key": "layout", "title": "驾驶舱编排"},
{"key": "version", "title": "版本发布"},
]},
{"key": "admin", "title": "管理控制台", "icon": "⚙️", "path": "/admin/", "children": [
{"key": "models", "title": "模型管理"},
{"key": "kb", "title": "知识库管理"},
{"key": "alerts", "title": "告警确认"},
{"key": "audit", "title": "审计查询"},
]},
{"key": "users", "title": "用户与角色", "icon": "👤", "path": "/auth/users.html"},
{"key": "mobile", "title": "移动端驾驶舱", "icon": "📱", "path": "/mobile/"},
]
# 角色 → 可见模块(PRD 8.2;admin 通常为 is_superuser,无需分配,看到全部)
ROLE_MENUS = {
"viewer": ["cockpit", "chat", "mobile"],
"engineer": ["cockpit", "chat", "mobile", "studio"],
}
class FbaClient:
def __init__(self, base: str, username: str, password: str):
self.base = base.rstrip("/")
cred = base64.b64encode(f"{username}:{password}".encode()).decode()
d = self._req("POST", "/auth/login/swagger",
headers={"Authorization": f"Basic {cred}"})
self.token = d["data"]["access_token"]
print(f"[ok] 登录成功:{username}({d['data']['user']['username']})")
def _req(self, method: str, path: str, body=None, headers=None):
h = {"Content-Type": "application/json"}
if getattr(self, "token", None):
h["Authorization"] = f"Bearer {self.token}"
if headers:
h.update(headers)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.base + path, data=data,
headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as r:
d = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
raise SystemExit(f"[fail] {method} {path} → HTTP {e.code}: {detail}")
if d.get("code") != 200:
raise SystemExit(f"[fail] {method} {path} → {d.get('msg')}")
return d
def get(self, path):
return self._req("GET", path)["data"]
def post(self, path, body):
return self._req("POST", path, body)
def put(self, path, body):
return self._req("PUT", path, body)
def flatten_menu_names(tree, out=None):
"""菜单树 → {name: id}(含子级)"""
out = out if out is not None else {}
for node in tree or []:
if node.get("name"):
out[node["name"]] = node["id"]
flatten_menu_names(node.get("children"), out)
return out
def main():
ap = argparse.ArgumentParser(description="iAOP 菜单/角色播种到 FBA")
ap.add_argument("--base", default="http://127.0.0.1:8001/api/v1",
help="FBA API 根(直连容器端口用 8001;走 nginx 用 http://<host>/fba/api/v1)")
ap.add_argument("--username", default="admin")
ap.add_argument("--password", default=None, help="缺省时交互输入")
args = ap.parse_args()
password = args.password or getpass.getpass("FBA 管理员密码: ")
cli = FbaClient(args.base, args.username, password)
# ---- 1. 播种菜单(幂等:按 name 查重) --------------------------------
existing = flatten_menu_names(cli.get("/sys/menus"))
created, skipped = 0, 0
def ensure_menu(name, title, path, mtype, sort, parent_id=None):
nonlocal created, skipped
if name in existing:
skipped += 1
return existing[name]
body = {
"title": title, "name": name, "path": path,
"parent_id": parent_id, "sort": sort, "icon": None,
"type": mtype, # 0目录 1菜单
"component": None, "perms": f"iaop:{name}",
"status": 1, "display": 1, "cache": 0,
"link": None, "remark": "iAOP 模块(seed_iaop_menus.py)",
}
cli.post("/sys/menus", body)
created += 1
# 创建后重新拉取以拿到 id(接口不回传 id)
nonlocal_existing = flatten_menu_names(cli.get("/sys/menus"))
existing.update(nonlocal_existing)
print(f"[ok] 创建菜单 {name}({title})")
return existing[name]
for i, m in enumerate(MODULES):
children = m.get("children")
if children:
pid = ensure_menu(m["key"], m["title"], m["path"], 0, i)
for j, c in enumerate(children):
ensure_menu(c["key"], c["title"], f"{m['path']}#{c['key']}",
1, j, parent_id=pid)
else:
ensure_menu(m["key"], m["title"], m["path"], 1, i)
print(f"[ok] 菜单:新建 {created},已存在跳过 {skipped}")
# ---- 2. 播种角色并分配菜单 -------------------------------------------
menu_ids = flatten_menu_names(cli.get("/sys/menus"))
def module_menu_ids(mod_key):
"""模块目录 id + 其子菜单 id(叶子模块仅自身 id)"""
ids = [menu_ids[mod_key]]
mod = next(m for m in MODULES if m["key"] == mod_key)
for c in mod.get("children", []):
ids.append(menu_ids[c["key"]])
return ids
roles = {r["name"]: r["id"] for r in cli.get("/sys/roles/all")}
for role_name, mod_keys in ROLE_MENUS.items():
if role_name in roles:
rid = roles[role_name]
print(f"[ok] 角色已存在:{role_name}(id={rid}),刷新菜单关联")
else:
cli.post("/sys/roles", {
"name": role_name, "status": 1,
"is_filter_scopes": True,
"remark": "iAOP 角色(seed_iaop_menus.py)",
})
roles = {r["name"]: r["id"] for r in cli.get("/sys/roles/all")}
rid = roles[role_name]
print(f"[ok] 创建角色:{role_name}(id={rid})")
ids = []
for k in mod_keys:
ids.extend(module_menu_ids(k))
cli.put(f"/sys/roles/{rid}/menus", {"menus": ids})
print(f"[ok] 角色 {role_name} 菜单:{', '.join(mod_keys)}({len(ids)} 项)")
# ---- 3. 验证 sidebar ---------------------------------------------------
side = cli.get("/sys/menus/sidebar")
names = [n.get("name") for n in side or []]
print(f"[ok] 当前管理员 sidebar 可见菜单:{names}")
print("\n完成。后续:在 FBA 后台创建用户并分配 engineer/viewer 角色,"
"前端 Sider 即按服务端授权渲染(admin 为超管可见全部)。")
if __name__ == "__main__":
sys.exit(main())