diff --git a/app/web/auth.py b/app/web/auth.py
index 5a965d0..618c524 100644
--- a/app/web/auth.py
+++ b/app/web/auth.py
@@ -7,14 +7,11 @@ PMS 登录与权限 —— 认证解析 / 角色判定 / 会话票签验 / 接
(scripts/test_batch18_units.py)。唯一碰网络的是 call_bshop_login 一个函数,
它把真正的 HTTP 调用隔离出来,其余全部可离线测。
-三块职责:
- 一、认证:解析第三方登录系统 bshop 的登录返回(parse_bshop_login)。
- 二、角色:从 bshop 返回的权限清单里认出系统管理员 / 交易员(role_from_permissions)。
- 三、会话与鉴权:自签会话票(make_session / verify_session),以及按「方法 + 路径」
- 判这个请求要什么角色(authz_decision)。
+一个人可以同时持有多个角色(既是系统管理员又是交易员),所以角色一律按「集合」处理,
+不折叠成单一角色。
-会话票格式:base64url( json({phone, username, role, exp}) ) + "." + base64url( hmac_sha256 )
- —— 只用标准库 hmac / hashlib,不引新依赖。exp 是 Unix 秒。
+会话票格式:base64url( json({phone, username, roles, exp}) ) + "." + base64url( hmac_sha256 )
+ —— 只用标准库 hmac / hashlib,不引新依赖。exp 是 Unix 秒。roles 是角色字符串列表。
"""
from __future__ import annotations
@@ -23,7 +20,7 @@ import hashlib
import hmac
import json
import time
-from typing import Optional
+from typing import List, Optional
# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。
ROLE_ADMIN = "系统管理员"
@@ -32,7 +29,7 @@ ROLE_TRADER = "交易员"
# ============================================================ 一、认证:解析 bshop 返回
def parse_bshop_login(resp_json: dict) -> dict:
- """把 bshop 登录返回解析成统一结构(不判角色,角色交给 role_from_permissions)。
+ """把 bshop 登录返回解析成统一结构(不判角色,角色交给 roles_from_permissions)。
入参是已经 json 解析好的字典(真正发 HTTP 的是 call_bshop_login)。
成功: {"ok": True, "phone", "username", "permissions": [...], "orgs": [...]}
@@ -83,28 +80,35 @@ def call_bshop_login(login_url: str, phone: str, password: str, timeout: int = 1
return parse_bshop_login(body)
-# ============================================================ 二、角色判定
+# ============================================================ 二、角色判定(可多角色)
def _markers(csv: str) -> set:
"""把 "pms:admin, band:yhyqx" 这类逗号分隔配置切成集合。"""
return {t.strip() for t in (csv or "").split(",") if t.strip()}
-def role_from_permissions(permissions, admin_perms_csv: str,
- trader_perms_csv: str = "") -> Optional[str]:
- """按权限清单判角色。
- - 命中任一「管理员标记」权限 -> 系统管理员
- - 否则若配置了「交易员准入」且一条都不占 -> None(无权进入)
- - 否则 -> 交易员
+def roles_from_permissions(permissions, admin_perms_csv: str,
+ trader_perms_csv: str = "") -> List[str]:
+ """返回这个人持有的**全部**角色(一个人可以同时是系统管理员和交易员)。
+ - 系统管理员:权限里含 PMS_ADMIN_PERMS 任一条。
+ - 交易员:未配 PMS_TRADER_PERMS(留空=人人都是交易员),或权限里含其任一条。
+ 两个角色都不占 -> 空列表,代表无权进入 PMS。
permissions 是 "system_key:menu_key" 字符串列表(parse_bshop_login 已归一)。
+ 返回顺序固定为 [系统管理员, 交易员] 中此人占到的那些。
"""
have = set(permissions or [])
admin = _markers(admin_perms_csv)
trader = _markers(trader_perms_csv)
+ roles: List[str] = []
if admin & have:
- return ROLE_ADMIN
- if trader and not (trader & have):
- return None
- return ROLE_TRADER
+ roles.append(ROLE_ADMIN)
+ if (not trader) or (trader & have):
+ roles.append(ROLE_TRADER)
+ return roles
+
+
+def is_admin(roles) -> bool:
+ """会话/前端统一用这个判管理员,避免各处各写一遍。"""
+ return ROLE_ADMIN in (roles or [])
# ============================================================ 三、会话票签验
@@ -117,7 +121,7 @@ def _b64d(s: str) -> bytes:
def sign_session(payload: dict, secret: str) -> str:
- """把 {phone, username, role, exp} 签成会话票字符串。"""
+ """把 {phone, username, roles, exp} 签成会话票字符串。"""
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"),
sort_keys=True).encode("utf-8")
b = _b64e(body)
@@ -125,12 +129,13 @@ def sign_session(payload: dict, secret: str) -> str:
return b + "." + _b64e(sig)
-def make_session(phone: str, username: str, role: str, secret: str,
+def make_session(phone: str, username: str, roles, secret: str,
ttl_hours: int, now: Optional[float] = None) -> str:
- """签发一张带过期时间的会话票。now 可注入,便于单测。"""
+ """签发一张带过期时间的会话票。roles 是角色列表。now 可注入,便于单测。"""
base = time.time() if now is None else now
exp = int(base + int(ttl_hours) * 3600)
- return sign_session({"phone": phone, "username": username, "role": role, "exp": exp}, secret)
+ return sign_session({"phone": phone, "username": username,
+ "roles": list(roles), "exp": exp}, secret)
def verify_session(cookie: str, secret: str, now: Optional[float] = None) -> Optional[dict]:
diff --git a/app/web/main.py b/app/web/main.py
index ac8b194..7a8ef8c 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -71,8 +71,8 @@ async def _auth_guard(request: Request, call_next):
return JSONResponse({"ok": False, "error": "未登录或登录已过期", "auth": "login_required"},
status_code=401)
if (authmod.authz_decision(request.method, path) == "admin"
- and sess.get("role") != authmod.ROLE_ADMIN):
- return JSONResponse({"ok": False, "error": "无权限: 该操作需要交易系统管理员", "auth": "forbidden"},
+ and not authmod.is_admin(sess.get("roles"))):
+ return JSONResponse({"ok": False, "error": "无权限: 该操作需要系统管理员", "auth": "forbidden"},
status_code=403)
request.state.user = sess
return await call_next(request)
@@ -173,16 +173,16 @@ def api_auth_login(payload: dict = Body(default=None)):
settings.PMS_BSHOP_TIMEOUT)
if not res.get("ok"):
return JSONResponse({"ok": False, "error": res.get("error") or "登录失败"}, status_code=401)
- role = authmod.role_from_permissions(res["permissions"], settings.PMS_ADMIN_PERMS,
- settings.PMS_TRADER_PERMS)
- if role is None:
+ roles = authmod.roles_from_permissions(res["permissions"], settings.PMS_ADMIN_PERMS,
+ settings.PMS_TRADER_PERMS)
+ if not roles:
return JSONResponse({"ok": False, "error": "这个账号没有持仓管理系统的使用权限"}, status_code=403)
if not settings.PMS_SESSION_SECRET:
return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"}, status_code=503)
- token = authmod.make_session(res["phone"], res["username"], role,
+ token = authmod.make_session(res["phone"], res["username"], roles,
settings.PMS_SESSION_SECRET, settings.PMS_SESSION_TTL_HOURS)
resp = JSONResponse({"ok": True, "user": {"phone": res["phone"], "username": res["username"]},
- "role": role, "orgs": res.get("orgs") or []})
+ "roles": roles, "orgs": res.get("orgs") or []})
resp.set_cookie(settings.PMS_SESSION_COOKIE, token,
max_age=settings.PMS_SESSION_TTL_HOURS * 3600,
httponly=True, samesite="lax", path="/")
@@ -202,7 +202,7 @@ def api_me(request: Request):
"""当前登录人与角色。关掉登录时回一个合成的管理员, 前端就不弹登录框也不藏东西。"""
if not settings.PMS_AUTH_ENABLED:
return {"ok": True, "auth_enabled": False,
- "user": {"phone": "-", "username": "本地(未开登录)"}, "role": authmod.ROLE_ADMIN}
+ "user": {"phone": "-", "username": "本地(未开登录)"}, "roles": [authmod.ROLE_ADMIN, authmod.ROLE_TRADER]}
secret = settings.PMS_SESSION_SECRET
sess = (authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), secret)
if secret else None)
@@ -210,7 +210,7 @@ def api_me(request: Request):
return JSONResponse({"ok": False, "auth": "login_required"}, status_code=401)
return {"ok": True, "auth_enabled": True,
"user": {"phone": sess.get("phone"), "username": sess.get("username")},
- "role": sess.get("role")}
+ "roles": sess.get("roles") or []}
@app.get("/api/overview")
diff --git a/app/web/static/index.html b/app/web/static/index.html
index 6c7ee4d..af5b1d3 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -432,7 +432,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ autoRefresh ? '自动' : '手动' }} · {{ lastRefresh }}
- {{ me.username || me.phone }} · {{ me.role }}
+ {{ me.username || me.phone }} · {{ rolesText }}
@@ -1965,8 +1965,9 @@ createApp({
const mode = ref('trader');
// ---- 登录与角色 (2026-08 加权限)
const authed = ref(false), authReady = ref(false);
- const me = ref({ phone: '', username: '', role: '' });
- const isAdmin = computed(() => me.value.role === '交易系统管理员');
+ const me = ref({ phone: '', username: '', roles: [] });
+ const isAdmin = computed(() => (me.value.roles || []).includes('系统管理员'));
+ const rolesText = computed(() => (me.value.roles || []).join(' · '));
const loginForm = reactive({ phone: '', password: '' });
const loginBusy = ref(false), loginErr = ref('');
_onUnauth = () => { authed.value = false; };
@@ -2404,7 +2405,7 @@ createApp({
const st = e && e.response && e.response.status;
if (st === 401) { authed.value = false; return { ok: false, error: '未登录' }; }
if (st === 403) {
- const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要交易系统管理员';
+ const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
}
err.value = url + ' → ' + e.message; return { ok: false, error: e.message };
@@ -2416,7 +2417,7 @@ createApp({
try {
const d = (await api.get('/api/me')).data || {};
if (d.ok) {
- me.value = { phone: d.user.phone, username: d.user.username, role: d.role };
+ me.value = { phone: d.user.phone, username: d.user.username, roles: d.roles || [] };
authed.value = true;
if (!isAdmin.value && mode.value === 'ops') mode.value = 'trader';
} else { authed.value = false; }
@@ -2440,7 +2441,7 @@ createApp({
async function doLogout() {
try { await api.post('/api/auth/logout', {}); } catch (e) {}
authed.value = false;
- me.value = { phone: '', username: '', role: '' };
+ me.value = { phone: '', username: '', roles: [] };
}
async function loadParams() {
@@ -3190,7 +3191,7 @@ createApp({
msgLatest, msgLevelClass, setMsgKind, msgGo,
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
lFold, rFold, railOpen, foldRail, closeRail, openRailSide,
- authed, authReady, me, isAdmin, loginForm, loginBusy, loginErr, doLogin, doLogout,
+ authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout,
sectClosed, toggleSect };
}
}).use(ElementPlus).mount('#app');
diff --git a/config/settings.py b/config/settings.py
index edab206..e2622f6 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -50,7 +50,7 @@ class Settings(BaseSettings):
PMS_WEB_PORT: int = 38100
# =========================================================
- # 一之二、登录与权限 (PMS 自带登录 + 两个角色: 交易系统管理员 / 交易员)
+ # 一之二、登录与权限 (PMS 自带登录 + 两个角色: 系统管理员 / 交易员)
# =========================================================
# 认证接第三方登录系统 bshop: 手机号+密码, 登录成功的返回里直接带这个人的权限清单。
# 角色从返回的 permissions 里认「管理员标记」权限得出; 会话用 PMS 自签票 (HMAC) 维持,
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
PMS_SESSION_COOKIE: str = "pms_session" # 会话 cookie 名
PMS_BSHOP_LOGIN_URL: str = "https://bshop.bmbs.tech/authProxy/api/auth/login" # 第三方登录接口
PMS_BSHOP_TIMEOUT: int = 10 # 调 bshop 登录的超时 (秒)
- PMS_ADMIN_PERMS: str = "pms:admin" # 「交易系统管理员」标记权限, "system_key:menu_key", 逗号分隔可多条
+ PMS_ADMIN_PERMS: str = "pms:admin" # 「系统管理员」标记权限, "system_key:menu_key", 逗号分隔可多条
PMS_TRADER_PERMS: str = "" # 进入 PMS 至少要占的权限; 留空=任何能登录的人都算交易员
# =========================================================
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index c299a8a..5ea5117 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -33,7 +33,7 @@
名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/
判分脚本聚合与对照分组 (41 例)
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
- 接口鉴权(写操作默认拒绝) (14 例)
+ 接口鉴权(维护类归管理员) (16 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
共 560 例
任一子集失败即整体失败 (退出码 1)。
diff --git a/scripts/test_batch18_units.py b/scripts/test_batch18_units.py
index a1513c2..8cd2030 100644
--- a/scripts/test_batch18_units.py
+++ b/scripts/test_batch18_units.py
@@ -1,15 +1,12 @@
# -*- coding: utf-8 -*-
"""
-第十八批: 登录与权限 (系统管理员 / 交易员) 的纯逻辑 —— 不联网、不连库
+第十八批: 登录与权限 (系统管理员 / 交易员, 可同时持有) 的纯逻辑 —— 不联网、不连库
=====================================================================
-本批只测 app/web/auth.py 里的纯函数, 不导入 app.web.main (那会拉起 fastapi 与一堆
-基础设施依赖)。覆盖四件事:
- 1. 角色判定 role_from_permissions: 管理员标记命中/未中、交易员准入闸放行与拦截;
- 2. 会话票 make_session / verify_session: 签发-校验往返, 篡改/过期/换密钥/空密钥一律拒;
- 3. bshop 返回解析 parse_bshop_login: 喂真实样例抽出手机号/用户名/权限/组织, code!=200 判失败;
- 4. 接口鉴权 authz_decision + is_public_path: 分工按前端两块视图划分 —— 交易员工作台上能点到
- 的都归交易员 (下单/撤单/拍板/策略/参数微调/大盘冷热采纳), 只有运维视图里的系统维护
- 操作 (ops/* 、ws-channel、industry 导入、人工补录提议) 归管理员; 公开白名单放行首页与登录接口。
+只测 app/web/auth.py 里的纯函数, 不导入 app.web.main。覆盖四件事:
+ 1. 角色判定 roles_from_permissions: 返回**角色集合**, 一个人可同时是管理员和交易员;
+ 2. 会话票 make_session / verify_session: roles 列表原样签发-校验, 篡改/过期/换密钥/空票一律拒;
+ 3. bshop 返回解析 parse_bshop_login: 喂真实样例抽字段, code!=200 判失败;
+ 4. 接口鉴权 authz_decision + is_public_path: 只有运维维护类接口归管理员, 其余登录人都放行。
运行: python scripts/test_batch18_units.py
"""
import os
@@ -30,7 +27,6 @@ def case(name):
return deco
-# 与用户给的 bshop 登录成功返回同构的样例 (裁剪了权限条数, 字段与层级一致)
SAMPLE = {
"code": 200, "msg": "登录成功",
"data": {
@@ -49,49 +45,56 @@ SAMPLE = {
}
-# ---------------------------------------------------------------- 一、角色判定
-@case("角色: 命中管理员标记 => 管理员")
-def _role_admin():
- r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
- assert r == A.ROLE_ADMIN, r
+# ---------------------------------------------------------------- 一、角色判定(可多角色)
+@case("角色: 管理员标记 + 交易员开放 => 同时是管理员和交易员")
+def _roles_both_open():
+ r = A.roles_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
+ assert r == [A.ROLE_ADMIN, A.ROLE_TRADER], r
-@case("角色: 只有普通权限 => 交易员")
-def _role_trader():
- r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "pms:admin", "")
- assert r == A.ROLE_TRADER, r
+@case("角色: 只有普通权限 (交易员开放) => 只交易员")
+def _roles_trader_only():
+ r = A.roles_from_permissions(["band:bkld"], "pms:admin", "")
+ assert r == [A.ROLE_TRADER], r
-@case("角色: 配了交易员准入且占其一 => 交易员")
-def _role_trader_gate_pass():
- r = A.role_from_permissions(["band:ccgl"], "pms:admin", "band:ccgl,pms:-")
- assert r == A.ROLE_TRADER, r
+@case("角色: 配了交易员准入, 有管理员无交易员权限 => 只管理员")
+def _roles_admin_only():
+ r = A.roles_from_permissions(["pms:admin"], "pms:admin", "band:ccgl")
+ assert r == [A.ROLE_ADMIN], r
-@case("角色: 配了交易员准入却一条不占 => None (无权进入)")
-def _role_denied():
- r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-")
- assert r is None, r
+@case("角色: 配了交易员准入, 两者都占 => 同时两个角色 (用户场景)")
+def _roles_both_gated():
+ r = A.roles_from_permissions(["pms:admin", "band:ccgl"], "pms:admin", "band:ccgl")
+ assert r == [A.ROLE_ADMIN, A.ROLE_TRADER], r
-@case("角色: 管理员标记可多条, 命中任一即算")
-def _role_admin_multi():
- r = A.role_from_permissions(["band:yhyqx"], "pms:admin, band:yhyqx", "")
- assert r == A.ROLE_ADMIN, r
+@case("角色: 配了交易员准入却一条不占 => 空 (无权进入)")
+def _roles_denied():
+ r = A.roles_from_permissions(["band:x"], "pms:admin", "band:ccgl")
+ assert r == [], r
+
+
+@case("角色: is_admin 辅助判定")
+def _is_admin_helper():
+ assert A.is_admin([A.ROLE_ADMIN, A.ROLE_TRADER]) is True
+ assert A.is_admin([A.ROLE_TRADER]) is False
+ assert A.is_admin([]) is False
# ---------------------------------------------------------------- 二、会话票
-@case("会话: 签发-校验往返, payload 字段齐全")
+@case("会话: roles 列表签发-校验往返, 字段齐全")
def _sess_roundtrip():
- tok = A.make_session("18795373439", "老王", A.ROLE_ADMIN, "s3cr3t", 12, now=1000.0)
+ tok = A.make_session("18795373439", "老王", [A.ROLE_ADMIN, A.ROLE_TRADER], "s3cr3t", 12, now=1000.0)
p = A.verify_session(tok, "s3cr3t", now=1001.0)
assert p and p["phone"] == "18795373439" and p["username"] == "老王", p
- assert p["role"] == A.ROLE_ADMIN and p["exp"] == 1000 + 12 * 3600, p
+ assert p["roles"] == [A.ROLE_ADMIN, A.ROLE_TRADER] and p["exp"] == 1000 + 12 * 3600, p
@case("会话: 篡改正文或签名 => None")
def _sess_tampered():
- tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 12, now=1000.0)
+ tok = A.make_session("186", "u", [A.ROLE_TRADER], "s3cr3t", 12, now=1000.0)
b, sig = tok.split(".", 1)
assert A.verify_session(b + "x." + sig, "s3cr3t", now=1001.0) is None
assert A.verify_session(b + "." + sig + "x", "s3cr3t", now=1001.0) is None
@@ -99,7 +102,7 @@ def _sess_tampered():
@case("会话: 过期 / 换密钥 / 空密钥 / 空票 一律拒")
def _sess_reject():
- tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 1, now=1000.0)
+ tok = A.make_session("186", "u", [A.ROLE_TRADER], "s3cr3t", 1, now=1000.0)
assert A.verify_session(tok, "s3cr3t", now=1000.0 + 3600 + 1) is None, "过期没拒"
assert A.verify_session(tok, "other", now=1001.0) is None, "换密钥没拒"
assert A.verify_session(tok, "", now=1001.0) is None, "空密钥没拒"
@@ -117,11 +120,11 @@ def _parse_ok():
assert len(r["orgs"]) == 2 and r["orgs"][0]["org_name"] == "高姐", r["orgs"]
-@case("解析: 样例接角色 —— 默认标记判交易员, 复用 band:yhyqx 判管理员")
-def _parse_then_role():
+@case("解析: 样例接角色 —— 默认标记只交易员, band:yhyqx 标记则管理员+交易员")
+def _parse_then_roles():
r = A.parse_bshop_login(SAMPLE)
- assert A.role_from_permissions(r["permissions"], "pms:admin", "") == A.ROLE_TRADER
- assert A.role_from_permissions(r["permissions"], "band:yhyqx", "") == A.ROLE_ADMIN
+ assert A.roles_from_permissions(r["permissions"], "pms:admin", "") == [A.ROLE_TRADER]
+ assert A.roles_from_permissions(r["permissions"], "band:yhyqx", "") == [A.ROLE_ADMIN, A.ROLE_TRADER]
@case("解析: code!=200 / 缺手机号 / 非字典 => 失败")