diff --git a/.env.example b/.env.example index 5444212..abe7f2f 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,16 @@ PMS_WEB_PORT=38100 # openssl rand -base64 48 PMS_SESSION_SECRET= +# ---- 登录角色标记 (2026-08 加权限) ---- +# 角色从 bshop 登录返回的权限清单里认。两项都可逗号分隔多条; 联测时直接指到测试账号真有的权限即可。 +# 系统管理员 = 权限里含 PMS_ADMIN_PERMS 任一条 (system_key:menu_key)。默认 pms:admin; +# 想先拿现成权限联测, 就临时指到管理员账号有的某条, 比如 band:yhyqx (用户与权限)。 +PMS_ADMIN_PERMS=band:yhyqx # 含这条权限的账号 = 系统管理员(示例用“用户与权限”) +# 交易员准入 = 进入 PMS 至少要占 PMS_TRADER_PERMS 任一条; 留空 = 任何能登录的人都是交易员。 +# 想把交易员也限成某条权限 (只给持仓管理权限的人才是交易员), 就指到那条, 比如 band:ccgl。 +# 机器:部署服务器 tlai4090(.env 里) +PMS_TRADER_PERMS=band:ccgl # 进 PMS 至少要占这条 = 交易员;留空则任何能登录的人都是交易员 + # ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ---- # 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。 # 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器): diff --git a/app/web/auth.py b/app/web/auth.py index c0dfaac..5a965d0 100644 --- a/app/web/auth.py +++ b/app/web/auth.py @@ -2,14 +2,14 @@ """ PMS 登录与权限 —— 认证解析 / 角色判定 / 会话票签验 / 接口鉴权判定(纯函数) ======================================================================= -方案见「PMS 加登录与两个角色(交易系统管理员 / 交易员)」。本模块刻意只放纯函数: +方案见「PMS 加登录与两个角色(系统管理员 / 交易员)」。本模块刻意只放纯函数: 不连库、几乎不联网,喂进去字典或字符串就能算,便于单测 (scripts/test_batch18_units.py)。唯一碰网络的是 call_bshop_login 一个函数, 它把真正的 HTTP 调用隔离出来,其余全部可离线测。 三块职责: 一、认证:解析第三方登录系统 bshop 的登录返回(parse_bshop_login)。 - 二、角色:从 bshop 返回的权限清单里认出交易系统管理员 / 交易员(role_from_permissions)。 + 二、角色:从 bshop 返回的权限清单里认出系统管理员 / 交易员(role_from_permissions)。 三、会话与鉴权:自签会话票(make_session / verify_session),以及按「方法 + 路径」 判这个请求要什么角色(authz_decision)。 @@ -26,7 +26,7 @@ import time from typing import Optional # 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。 -ROLE_ADMIN = "交易系统管理员" +ROLE_ADMIN = "系统管理员" ROLE_TRADER = "交易员" @@ -92,7 +92,7 @@ def _markers(csv: str) -> set: def role_from_permissions(permissions, admin_perms_csv: str, trader_perms_csv: str = "") -> Optional[str]: """按权限清单判角色。 - - 命中任一「管理员标记」权限 -> 交易系统管理员 + - 命中任一「管理员标记」权限 -> 系统管理员 - 否则若配置了「交易员准入」且一条都不占 -> None(无权进入) - 否则 -> 交易员 permissions 是 "system_key:menu_key" 字符串列表(parse_bshop_login 已归一)。 @@ -159,20 +159,19 @@ def verify_session(cookie: str, secret: str, now: Optional[float] = None) -> Opt # ============================================================ 四、接口鉴权判定 -# 交易员放行的写接口(白名单)。其余任何写接口一律只给管理员 —— 「写操作默认拒绝」。 -# 路径按段匹配,"*" 吃掉一段路径参数(如 {command_id})。 -_TRADER_WRITE = [ - ("POST", ("api", "commands")), # 下命令 - ("POST", ("api", "commands", "*", "cancel")), # 撤命令 - ("POST", ("api", "commands", "*", "replan")), # 改期重排 - ("POST", ("api", "proposals", "*", "decide")), # 拍板提议 +# 分工按前端两块视图划分(用户 2026-08 拍板):交易员工作台(前端 mode=trader)上能点到 +# 的都归交易员;只有运维视图(mode=ops)里的系统维护操作归系统管理员。所以这里反过来只 +# 登记「只给管理员」的那一小撮运维接口,其余已登录的人(交易员)默认放行 —— 交易员的能力 +# 跟着他那一页走:日常下单、撤单、拍板、策略、参数微调、大盘冷热采纳,都算交易员的活。 +# 只给管理员的接口族(前缀匹配,任何方法都算): +_ADMIN_PREFIX = [ + ("api", "ops"), # 回放 / 对账 / 日终 / 日报 / 择时扫描 / plan-refresh 等全部运维操作 + ("api", "ws-channel"), # ws 直连通道管理 + ("api", "industry"), # 行业划分数据导入 ] - -# 只给管理员的读接口(其余 GET 所有登录人可看)。 -_ADMIN_GET = [ - ("GET", ("api", "ops", "rebuild-preflight")), - ("GET", ("api", "ops", "rebuild-accept")), - ("GET", ("api", "ops", "downstream-schema")), +# 只给管理员的单个接口(精确匹配方法与整条路径): +_ADMIN_EXACT = [ + ("POST", ("api", "proposals")), # 人工补录提议:影子期造数调试口,没有交易员入口 ] @@ -187,22 +186,19 @@ def _match(pattern: tuple, segs: tuple) -> bool: def authz_decision(method: str, path: str) -> str: - """判这个请求所需的最低角色,返回 "admin"(要交易系统管理员)或 "any"(任何登录人可)。 - 规则:写方法(POST/PUT/PATCH/DELETE)不在交易员白名单里就要管理员; - GET/HEAD 默认放行,仅少数运维读接口要管理员。 - 只对「已登录、且不在公开白名单」的请求调用(公开白名单在 is_public_path)。 + """判这个请求所需的最低角色,返回 "admin"(要系统管理员)或 "any"(任何登录人可)。 + 规则:命中「只给管理员」的运维接口族,或那条人工补录提议的调试口,就要管理员;其余 + 已登录的人都放行。只对「已登录、且不在公开白名单」的请求调用(白名单在 is_public_path)。 """ method = (method or "").upper() segs = _seg(path) - if method in ("GET", "HEAD"): - for m, pat in _ADMIN_GET: - if m == method and _match(pat, segs): - return "admin" - return "any" - for m, pat in _TRADER_WRITE: + for m, pat in _ADMIN_EXACT: if m == method and _match(pat, segs): - return "any" - return "admin" + return "admin" + for pref in _ADMIN_PREFIX: + if len(segs) >= len(pref) and tuple(segs[:len(pref)]) == pref: + return "admin" + return "any" # 公开路径:不需要登录也放行。首页 / 必须放行,否则登录框自己都加载不出来。 diff --git a/app/web/static/index.html b/app/web/static/index.html index ed53949..6c7ee4d 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -405,7 +405,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。
今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}% - +
{{ mzSig.advice.why }} · 当日有效,隔日作废
最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}
@@ -872,7 +872,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} - + @@ -1043,7 +1043,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入; 卖出、以及把当天做T买的那部分卖回去,都不受影响。
-
+
暂停 恢复 恢复买入 @@ -1259,8 +1259,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} @@ -1286,7 +1286,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} - +
页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。
@@ -1421,9 +1421,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} @click="cancelCmd(s.row.command_id)">撤销 重规划 - 移除 - 恢复显示 @@ -1547,7 +1547,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} @@ -1617,7 +1617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、 有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个 发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 --> - +
进程 {{ wsc.process_alive ? '在线':'不在线' }} diff --git a/scripts/test_batch18_units.py b/scripts/test_batch18_units.py index 84c308c..a1513c2 100644 --- a/scripts/test_batch18_units.py +++ b/scripts/test_batch18_units.py @@ -1,15 +1,15 @@ # -*- 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: 「写操作默认拒绝」——交易员白名单外的写接口 - 一律要管理员 (专门钉死 /api/macro/adopt、POST /api/proposals、.../archive 与 .../cancel - 结尾段区分), GET 默认放行仅少数运维读接口要管理员; 公开白名单放行首页与登录接口。 + 4. 接口鉴权 authz_decision + is_public_path: 分工按前端两块视图划分 —— 交易员工作台上能点到 + 的都归交易员 (下单/撤单/拍板/策略/参数微调/大盘冷热采纳), 只有运维视图里的系统维护 + 操作 (ops/* 、ws-channel、industry 导入、人工补录提议) 归管理员; 公开白名单放行首页与登录接口。 运行: python scripts/test_batch18_units.py """ import os @@ -50,25 +50,25 @@ SAMPLE = { # ---------------------------------------------------------------- 一、角色判定 -@case("角色: 命中管理员标记 -> 交易系统管理员") +@case("角色: 命中管理员标记 => 管理员") def _role_admin(): r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "") assert r == A.ROLE_ADMIN, r -@case("角色: 只有普通权限 -> 交易员") +@case("角色: 只有普通权限 => 交易员") def _role_trader(): r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "pms:admin", "") assert r == A.ROLE_TRADER, r -@case("角色: 配了交易员准入且占其一 -> 交易员") +@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("角色: 配了交易员准入却一条不占 -> None (无权进入)") +@case("角色: 配了交易员准入却一条不占 => None (无权进入)") def _role_denied(): r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-") assert r is None, r @@ -89,7 +89,7 @@ def _sess_roundtrip(): assert p["role"] == A.ROLE_ADMIN and p["exp"] == 1000 + 12 * 3600, p -@case("会话: 篡改正文或签名 -> None") +@case("会话: 篡改正文或签名 => None") def _sess_tampered(): tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 12, now=1000.0) b, sig = tok.split(".", 1) @@ -107,7 +107,7 @@ def _sess_reject(): # ---------------------------------------------------------------- 三、bshop 解析 -@case("解析: 真实样例 -> 手机号/用户名/权限/组织") +@case("解析: 真实样例 => 手机号/用户名/权限/组织") def _parse_ok(): r = A.parse_bshop_login(SAMPLE) assert r["ok"] is True, r @@ -124,7 +124,7 @@ def _parse_then_role(): assert A.role_from_permissions(r["permissions"], "band:yhyqx", "") == A.ROLE_ADMIN -@case("解析: code!=200 / 缺手机号 / 非字典 -> 失败") +@case("解析: code!=200 / 缺手机号 / 非字典 => 失败") def _parse_fail(): assert A.parse_bshop_login({"code": 401, "msg": "密码错误"})["ok"] is False assert A.parse_bshop_login({"code": 200, "data": {"user": {}}})["ok"] is False @@ -132,35 +132,44 @@ def _parse_fail(): # ---------------------------------------------------------------- 四、接口鉴权 -@case("鉴权: 交易员白名单 4 条写接口放行 (any)") +@case("鉴权: 交易员工作台上的写操作都放行 (any)") def _authz_trader_ok(): - for m, path in [("POST", "/api/commands"), - ("POST", "/api/commands/CMD-1/cancel"), - ("POST", "/api/commands/CMD-1/replan"), - ("POST", "/api/proposals/PRP-1/decide")]: + trader_paths = [ + ("POST", "/api/commands"), + ("POST", "/api/commands/CMD-1/cancel"), + ("POST", "/api/commands/CMD-1/replan"), + ("POST", "/api/commands/CMD-1/archive"), + ("POST", "/api/proposals/PRP-1/decide"), + ("POST", "/api/proposals/PRP-1/archive"), + ("POST", "/api/instructions/INS-1/cancel"), # 用户点名: 撤单归交易员 + ("POST", "/api/macro/adopt"), # 用户点名: 大盘冷热采纳归交易员 + ("POST", "/api/params"), # 工作台上有参数微调面板 + ("POST", "/api/strategies"), + ("POST", "/api/strategies/validate"), + ("POST", "/api/strategies/S1/status"), + ("POST", "/api/strategies/S1/resume-buy"), + ("POST", "/api/strategies/S1/archive"), + ] + for m, path in trader_paths: assert A.authz_decision(m, path) == "any", (m, path, A.authz_decision(m, path)) -@case("鉴权: 写操作默认拒绝 —— 白名单外一律要管理员") -def _authz_admin_default(): +@case("鉴权: 只有系统维护类接口归管理员") +def _authz_admin_only(): admin_paths = [ - ("POST", "/api/params"), ("POST", "/api/ops/reconcile"), - ("POST", "/api/strategies"), - ("POST", "/api/strategies/S1/status"), - ("POST", "/api/industry/import"), + ("POST", "/api/ops/daily-settle"), + ("POST", "/api/ops/macro-scan"), + ("POST", "/api/ops/plan-refresh"), ("POST", "/api/ws-channel/clear-resync"), - ("POST", "/api/instructions/INS-1/cancel"), - ("POST", "/api/macro/adopt"), # 评审点出的漏网写接口, 兜住 - ("POST", "/api/proposals"), # 人工补录=造数调试口, 归管理员 - ("POST", "/api/commands/CMD-1/archive"), # 结尾段 archive != cancel - ("POST", "/api/proposals/PRP-1/archive"), + ("POST", "/api/industry/import"), + ("POST", "/api/proposals"), # 人工补录 (无 id 段) = 造数调试口 ] for m, path in admin_paths: assert A.authz_decision(m, path) == "admin", (m, path, A.authz_decision(m, path)) -@case("鉴权: GET 默认放行, 仅三条运维读接口要管理员") +@case("鉴权: 运维读接口要管理员, 普通读接口谁都能看") def _authz_gets(): for path in ["/api/ops/rebuild-preflight", "/api/ops/rebuild-accept", "/api/ops/downstream-schema"]: