添加权限

This commit is contained in:
zlt 2026-08-26 16:51:11 +08:00
parent e1a81619dd
commit 1be155c478
7 changed files with 632 additions and 15 deletions

View File

@ -24,6 +24,15 @@ PMS_REDIS_URL=redis://:密码@192.168.16.150:6379/8
# ---- 管理页面 ---- # ---- 管理页面 ----
PMS_WEB_PORT=38100 PMS_WEB_PORT=38100
# ---- 登录会话密钥 (2026-08 加权限) ----
# PMS 自带登录: 手机号+密码交给第三方 bshop 校验, 通过后 PMS 自签一张会话票 (cookie)。
# 这一项是**密钥**, 与下面 QMT 两把密钥同规矩: 只在这里维护, 不入库、不进 ParamStore、
# 不上管理页面、不进代码。开着登录 (PMS_AUTH_ENABLED 默认 true) 却留空时, 服务拒绝放行。
# 生成一段随机密钥 (任选一条):
# python -c "import secrets; print(secrets.token_urlsafe(48))"
# openssl rand -base64 48
PMS_SESSION_SECRET=
# ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ---- # ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ----
# 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。 # 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。
# 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器): # 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器):

219
app/web/auth.py Normal file
View File

@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
"""
PMS 登录与权限 认证解析 / 角色判定 / 会话票签验 / 接口鉴权判定纯函数
=======================================================================
方案见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
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Optional
# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。
ROLE_ADMIN = "交易系统管理员"
ROLE_TRADER = "交易员"
# ============================================================ 一、认证:解析 bshop 返回
def parse_bshop_login(resp_json: dict) -> dict:
"""把 bshop 登录返回解析成统一结构(不判角色,角色交给 role_from_permissions
入参是已经 json 解析好的字典真正发 HTTP 的是 call_bshop_login
成功: {"ok": True, "phone", "username", "permissions": [...], "orgs": [...]}
失败: {"ok": False, "error": "..."}
permissions 每条归一成 "system_key:menu_key" 字符串方便后面比对
"""
if not isinstance(resp_json, dict):
return {"ok": False, "error": "登录服务返回的不是 JSON 对象"}
if resp_json.get("code") != 200:
return {"ok": False, "error": str(resp_json.get("msg") or "登录失败")}
data = resp_json.get("data") or {}
user = data.get("user") or {}
phone = str(user.get("phone") or "").strip()
username = str(user.get("username") or phone or "").strip()
if not phone:
return {"ok": False, "error": "登录返回里没有手机号"}
perms = []
for p in (data.get("permissions") or []):
sk = str((p or {}).get("system_key") or "").strip()
mk = str((p or {}).get("menu_key") or "").strip()
if sk:
perms.append(sk + ":" + mk)
orgs = []
for o in (data.get("orgs") or []):
orgs.append({"id": (o or {}).get("id"), "org_name": (o or {}).get("org_name")})
return {"ok": True, "phone": phone, "username": username,
"permissions": perms, "orgs": orgs}
def call_bshop_login(login_url: str, phone: str, password: str, timeout: int = 10) -> dict:
"""真正发 HTTP 调 bshop 登录。**本模块里唯一碰网络的函数。**
multipart/form-data 提交与对方 curl 一致网络或解析失败也回统一的
{"ok": False, "error"} 结构绝不抛出去
"""
import requests # 局部导入:纯函数单测既不装也不连任何东西
try:
r = requests.post(
login_url,
files={"phone": (None, str(phone)), "password": (None, str(password))},
timeout=timeout,
)
except Exception as e:
return {"ok": False, "error": "连不上登录服务: " + type(e).__name__ + ": " + str(e)}
try:
body = r.json()
except Exception:
return {"ok": False, "error": "登录服务返回非 JSON (HTTP " + str(r.status_code) + ")"}
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无权进入
- 否则 -> 交易员
permissions "system_key:menu_key" 字符串列表parse_bshop_login 已归一
"""
have = set(permissions or [])
admin = _markers(admin_perms_csv)
trader = _markers(trader_perms_csv)
if admin & have:
return ROLE_ADMIN
if trader and not (trader & have):
return None
return ROLE_TRADER
# ============================================================ 三、会话票签验
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64d(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def sign_session(payload: dict, secret: str) -> str:
"""{phone, username, role, exp} 签成会话票字符串。"""
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"),
sort_keys=True).encode("utf-8")
b = _b64e(body)
sig = hmac.new(secret.encode("utf-8"), b.encode("ascii"), hashlib.sha256).digest()
return b + "." + _b64e(sig)
def make_session(phone: str, username: str, role: str, secret: str,
ttl_hours: int, now: Optional[float] = None) -> str:
"""签发一张带过期时间的会话票。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)
def verify_session(cookie: str, secret: str, now: Optional[float] = None) -> Optional[dict]:
"""验会话票。签名不对、过期、格式坏、空票或空密钥一律返回 None否则返回 payload。"""
if not cookie or not secret:
return None
try:
b, sig = cookie.split(".", 1)
except ValueError:
return None
expect = hmac.new(secret.encode("utf-8"), b.encode("ascii"), hashlib.sha256).digest()
try:
got = _b64d(sig)
except Exception:
return None
if not hmac.compare_digest(expect, got):
return None
try:
payload = json.loads(_b64d(b).decode("utf-8"))
except Exception:
return None
base = time.time() if now is None else now
if int(payload.get("exp") or 0) < base:
return None
return payload
# ============================================================ 四、接口鉴权判定
# 交易员放行的写接口(白名单)。其余任何写接口一律只给管理员 —— 「写操作默认拒绝」。
# 路径按段匹配,"*" 吃掉一段路径参数(如 {command_id})。
_TRADER_WRITE = [
("POST", ("api", "commands")), # 下命令
("POST", ("api", "commands", "*", "cancel")), # 撤命令
("POST", ("api", "commands", "*", "replan")), # 改期重排
("POST", ("api", "proposals", "*", "decide")), # 拍板提议
]
# 只给管理员的读接口(其余 GET 所有登录人可看)。
_ADMIN_GET = [
("GET", ("api", "ops", "rebuild-preflight")),
("GET", ("api", "ops", "rebuild-accept")),
("GET", ("api", "ops", "downstream-schema")),
]
def _seg(path: str) -> tuple:
return tuple(s for s in (path or "").strip("/").split("/") if s != "")
def _match(pattern: tuple, segs: tuple) -> bool:
if len(pattern) != len(segs):
return False
return all(p == "*" or p == s for p, s in zip(pattern, segs))
def authz_decision(method: str, path: str) -> str:
"""判这个请求所需的最低角色,返回 "admin"(要交易系统管理员)或 "any"(任何登录人可)。
规则写方法POST/PUT/PATCH/DELETE不在交易员白名单里就要管理员
GET/HEAD 默认放行仅少数运维读接口要管理员
只对已登录且不在公开白名单的请求调用公开白名单在 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:
if m == method and _match(pat, segs):
return "any"
return "admin"
# 公开路径:不需要登录也放行。首页 / 必须放行,否则登录框自己都加载不出来。
_PUBLIC_EXACT = {"/", "/health", "/favicon.ico",
"/api/auth/login", "/api/auth/logout", "/api/me"}
def is_public_path(path: str) -> bool:
p = (path or "/")
if p in _PUBLIC_EXACT or (p.rstrip("/") or "/") in _PUBLIC_EXACT:
return True
if p == "/static" or p.startswith("/static/"):
return True
return False

View File

@ -17,7 +17,7 @@ import logging
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import Body, FastAPI, Query from fastapi import Body, FastAPI, Query, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from config.settings import settings from config.settings import settings
@ -26,6 +26,7 @@ from app.core import tradedays as td
from app.db import session as dbs from app.db import session as dbs
from app.repo import downstream_repo, pms_repo from app.repo import downstream_repo, pms_repo
from app.services import command_service, industry, ledger_service, param_store, portfolio from app.services import command_service, industry, ledger_service, param_store, portfolio
from app.web import auth as authmod
logging.basicConfig(level=logging.INFO, logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s") format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
@ -49,6 +50,34 @@ except Exception as _e: # 挂不上不影响任何 API, 页面照常
logger.warning("静态目录挂载失败 (不影响接口): %s", _e) logger.warning("静态目录挂载失败 (不影响接口): %s", _e)
# ================================================================ 登录闸 (中间件)
@app.middleware("http")
async def _auth_guard(request: Request, call_next):
"""除公开白名单外都要有效会话票; 交易员碰管理员接口回 403。
关键: 中间件里只 return JSONResponse, raise 在这里抛异常会变成 500 而非 401/403"""
if not settings.PMS_AUTH_ENABLED:
return await call_next(request)
path = request.url.path
if authmod.is_public_path(path):
return await call_next(request)
if not settings.PMS_SESSION_SECRET:
# 开着登录却没配密钥: 失败即关门, 绝不用空密钥签/验票
logger.error("PMS_AUTH_ENABLED 开着但 PMS_SESSION_SECRET 为空 —— 受保护请求一律拒绝")
return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"},
status_code=503)
sess = authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE),
settings.PMS_SESSION_SECRET)
if not sess:
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"},
status_code=403)
request.state.user = sess
return await call_next(request)
def ok(fn, *args, **kw): def ok(fn, *args, **kw):
"""统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。""" """统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。"""
try: try:
@ -131,6 +160,59 @@ def index():
return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"}) return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"})
# ================================================================ 登录与权限接口
@app.post("/api/auth/login")
def api_auth_login(payload: dict = Body(default=None)):
"""手机号+密码 -> 调 bshop 校验 -> 判角色 -> 签会话票写 cookie。前端发 JSON。"""
payload = payload or {}
phone = str(payload.get("phone") or "").strip()
password = str(payload.get("password") or "")
if not phone or not password:
return JSONResponse({"ok": False, "error": "手机号和密码都要填"}, status_code=400)
res = authmod.call_bshop_login(settings.PMS_BSHOP_LOGIN_URL, phone, password,
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:
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,
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 []})
resp.set_cookie(settings.PMS_SESSION_COOKIE, token,
max_age=settings.PMS_SESSION_TTL_HOURS * 3600,
httponly=True, samesite="lax", path="/")
return resp
@app.post("/api/auth/logout")
def api_auth_logout():
"""退出: 清掉会话 cookie。"""
resp = JSONResponse({"ok": True})
resp.delete_cookie(settings.PMS_SESSION_COOKIE, path="/")
return resp
@app.get("/api/me")
def api_me(request: Request):
"""当前登录人与角色。关掉登录时回一个合成的管理员, 前端就不弹登录框也不藏东西。"""
if not settings.PMS_AUTH_ENABLED:
return {"ok": True, "auth_enabled": False,
"user": {"phone": "-", "username": "本地(未开登录)"}, "role": authmod.ROLE_ADMIN}
secret = settings.PMS_SESSION_SECRET
sess = (authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), secret)
if secret else None)
if not sess:
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")}
@app.get("/api/overview") @app.get("/api/overview")
def api_overview(): def api_overview():
return ok(portfolio.overview) return ok(portfolio.overview)

View File

@ -344,11 +344,40 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
.oprow2 .d{font-size:11.5px;color:var(--ink-2);line-height:1.5;} .oprow2 .d{font-size:11.5px;color:var(--ink-2);line-height:1.5;}
.oprow2 .b{flex:none;width:92px;} .oprow2 .b{flex:none;width:92px;}
.oprow2 .b .el-button{width:100%;} .oprow2 .b .el-button{width:100%;}
/* 登录遮罩与登录人胶囊 (2026-08 加权限) */
.login-mask{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:rgba(20,20,18,.55);backdrop-filter:blur(4px);}
.login-card{width:320px;background:var(--surface);border:1px solid var(--hair);border-radius:14px;
box-shadow:0 12px 40px rgba(0,0,0,.28);padding:26px 24px;}
.login-card h2{margin:0 0 4px;font-size:18px;}
.login-sub{color:var(--muted);font-size:13px;margin-bottom:16px;}
.login-in{width:100%;box-sizing:border-box;border:1px solid var(--hair);border-radius:9px;
padding:10px 12px;font-size:14px;margin-bottom:10px;background:var(--surface-2);color:var(--ink);}
.login-in:focus{outline:none;border-color:var(--accent);}
.login-err{color:var(--up);font-size:12.5px;margin:2px 0 10px;}
.login-btn{width:100%;border:0;border-radius:9px;background:var(--accent);color:#fff;
padding:11px;font-size:14px;font-weight:600;cursor:pointer;}
.login-btn:disabled{opacity:.6;cursor:default;}
.chip.hd-user{background:var(--accent-weak);color:var(--accent);} .chip.hd-user .dot{background:var(--accent);}
</style> </style>
</head> </head>
<body> <body>
<div id="app" class="app" v-cloak> <div id="app" class="app" v-cloak>
<!-- 登录遮罩: 未登录时盖住整页 (2026-08 加权限) -->
<div v-if="authReady && !authed" class="login-mask">
<div class="login-card">
<h2>PMS 持仓管理系统</h2>
<div class="login-sub">请用手机号和密码登录</div>
<input class="login-in" v-model.trim="loginForm.phone" placeholder="手机号"
@keyup.enter="doLogin" autocomplete="username"/>
<input class="login-in" v-model="loginForm.password" type="password" placeholder="密码"
@keyup.enter="doLogin" autocomplete="current-password"/>
<div class="login-err" v-if="loginErr">{{ loginErr }}</div>
<button class="login-btn" :disabled="loginBusy" @click="doLogin">{{ loginBusy ? '登录中…' : '登录' }}</button>
</div>
</div>
<div class="hd"> <div class="hd">
<h1>PMS 持仓管理<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}</span></h1> <h1>PMS 持仓管理<span class="ver">v{{ health.version || '-' }} · {{ health.now || '' }}</span></h1>
<span class="chip" :class="marketOpen?'ok':'idle'"><span class="dot"></span>{{ marketState }}</span> <span class="chip" :class="marketOpen?'ok':'idle'"><span class="dot"></span>{{ marketState }}</span>
@ -376,7 +405,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div v-if="macroGateOn" style="color:var(--warn)">大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。</div> <div v-if="macroGateOn" style="color:var(--warn)">大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。</div>
<div v-if="mzSig.advice && !mzSig.advice.adopted" style="margin-top:4px"> <div v-if="mzSig.advice && !mzSig.advice.adopted" style="margin-top:4px">
<b>今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}%</b> <b>今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}%</b>
<button class="tbtn pri" style="margin-left:8px" @click="macroAdopt(mzSig.key)">采纳并下命令</button> <button v-if="isAdmin" class="tbtn pri" style="margin-left:8px" @click="macroAdopt(mzSig.key)">采纳并下命令</button>
<div class="muted" style="font-size:11px">{{ mzSig.advice.why }} · 当日有效,隔日作废</div> <div class="muted" style="font-size:11px">{{ mzSig.advice.why }} · 当日有效,隔日作废</div>
</div> </div>
<div class="muted" v-if="mzSig.last_command" style="font-size:11px">最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}</div> <div class="muted" v-if="mzSig.last_command" style="font-size:11px">最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}</div>
@ -394,7 +423,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="spacer"></div> <div class="spacer"></div>
<div class="seg"> <div class="seg">
<button :class="{on:mode==='trader'}" @click="mode='trader'">交易员</button> <button :class="{on:mode==='trader'}" @click="mode='trader'">交易员</button>
<button :class="{on:mode==='ops'}" @click="mode='ops'">运维</button> <button v-if="isAdmin" :class="{on:mode==='ops'}" @click="mode='ops'">运维</button>
</div> </div>
<button class="tbtn hd-report" @click="openPlan">选股计划</button> <button class="tbtn hd-report" @click="openPlan">选股计划</button>
@ -403,6 +432,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="muted hd-refresh-at" style="font-size:11.5px;cursor:pointer" @click="autoRefresh=!autoRefresh" <span class="muted hd-refresh-at" style="font-size:11.5px;cursor:pointer" @click="autoRefresh=!autoRefresh"
:title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}<span v-if="lastRefresh"> · {{ lastRefresh }}</span></span> :title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}<span v-if="lastRefresh"> · {{ lastRefresh }}</span></span>
<button class="tbtn pri" :disabled="loading" @click="loadAll">{{ loading ? '刷新中…' : '刷新' }}</button> <button class="tbtn pri" :disabled="loading" @click="loadAll">{{ loading ? '刷新中…' : '刷新' }}</button>
<span class="chip hd-user" v-if="authed" :title="me.phone"><span class="dot"></span>{{ me.username || me.phone }} · {{ me.role }}</span>
<button class="tbtn sm" v-if="authed" @click="doLogout">退出</button>
</div> </div>
<el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon <el-alert v-if="err" class="banner" :title="err" type="error" effect="dark" show-icon
@ -841,7 +872,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column label="下单量 / 已成交" width="130" align="right"><template #default="s"><span class="mono">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</span></template></el-table-column> <el-table-column label="下单量 / 已成交" width="130" align="right"><template #default="s"><span class="mono">{{ s.row.qty }} / {{ s.row.exec_qty||0 }}</span></template></el-table-column>
<el-table-column label="限价" width="82" align="right"><template #default="s"><span class="mono">{{ s.row.limit_price||'—' }}</span></template></el-table-column> <el-table-column label="限价" width="82" align="right"><template #default="s"><span class="mono">{{ s.row.limit_price||'—' }}</span></template></el-table-column>
<el-table-column label="进展" min-width="130"><template #default="s"><span class="chip idle"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column> <el-table-column label="进展" min-width="130"><template #default="s"><span class="chip idle"><span class="dot"></span>{{ tx('insStatus', s.row.status) }}</span></template></el-table-column>
<el-table-column label="操作" width="76"><template #default="s"><button class="tbtn sm" @click="cancelIns(s.row.instruction_id)">撤单</button></template></el-table-column> <el-table-column label="操作" width="76"><template #default="s"><button v-if="isAdmin" class="tbtn sm" @click="cancelIns(s.row.instruction_id)">撤单</button></template></el-table-column>
</el-table> </el-table>
</el-tab-pane> </el-tab-pane>
<el-tab-pane name="done"> <el-tab-pane name="done">
@ -1012,7 +1043,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div class="st" style="color:var(--warn)" v-if="s.buy_paused"> <div class="st" style="color:var(--warn)" v-if="s.buy_paused">
这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入; 这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入;
卖出、以及把当天做T买的那部分卖回去都不受影响。</div> 卖出、以及把当天做T买的那部分卖回去都不受影响。</div>
<div style="margin-top:6px"> <div style="margin-top:6px" v-if="isAdmin">
<el-button v-if="s.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.strategy_id,'PAUSED')">暂停</el-button> <el-button v-if="s.status==='ACTIVE'" size="small" @click="setStrategyStatus(s.strategy_id,'PAUSED')">暂停</el-button>
<el-button v-else-if="s.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.strategy_id,'ACTIVE')">恢复</el-button> <el-button v-else-if="s.status==='PAUSED'" size="small" type="success" @click="setStrategyStatus(s.strategy_id,'ACTIVE')">恢复</el-button>
<el-button v-if="s.buy_paused" size="small" type="warning" @click="resumeBuy(s)">恢复买入</el-button> <el-button v-if="s.buy_paused" size="small" type="warning" @click="resumeBuy(s)">恢复买入</el-button>
@ -1228,8 +1259,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-alert> </el-alert>
<template #footer> <template #footer>
<el-button size="small" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button> <el-button v-if="isAdmin" size="small" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button>
<el-button size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂上并生效</el-button> <el-button v-if="isAdmin" size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂上并生效</el-button>
</template> </template>
</el-dialog> </el-dialog>
@ -1255,7 +1286,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-tabs v-model="tab" type="border-card"> <el-tabs v-model="tab" type="border-card">
<!-- ==================== ① 参数设置 ==================== --> <!-- ==================== ① 参数设置 ==================== -->
<el-tab-pane label="参数设置" name="params"> <el-tab-pane label="参数设置" name="params" v-if="isAdmin">
<div class="row" style="margin-bottom:10px"> <div class="row" style="margin-bottom:10px">
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span> <span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
<div class="spacer"></div> <div class="spacer"></div>
@ -1390,9 +1421,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
@click="cancelCmd(s.row.command_id)">撤销</el-button> @click="cancelCmd(s.row.command_id)">撤销</el-button>
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'" <el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
@click="replan(s.row.command_id)">重规划</el-button> @click="replan(s.row.command_id)">重规划</el-button>
<el-button v-if="cmdArchivable(s.row) && !s.row.archived_at" size="small" <el-button v-if="isAdmin && cmdArchivable(s.row) && !s.row.archived_at" size="small"
@click="archiveRow('commands', s.row.command_id, true)">移除</el-button> @click="archiveRow('commands', s.row.command_id, true)">移除</el-button>
<el-button v-if="s.row.archived_at" size="small" type="info" <el-button v-if="isAdmin && s.row.archived_at" size="small" type="info"
@click="archiveRow('commands', s.row.command_id, false)">恢复显示</el-button> @click="archiveRow('commands', s.row.command_id, false)">恢复显示</el-button>
</template> </template>
</el-table-column> </el-table-column>
@ -1516,7 +1547,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-table-column prop="origin_type" label="来源" width="80"></el-table-column> <el-table-column prop="origin_type" label="来源" width="80"></el-table-column>
<el-table-column label="操作" width="90"> <el-table-column label="操作" width="90">
<template #default="s"> <template #default="s">
<el-button size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button> <el-button v-if="isAdmin" size="small" @click="cancelIns(s.row.instruction_id)">撤销</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -1586,7 +1617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、 scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个 有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 --> 发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
<el-tab-pane label="ws 通道" name="ws"> <el-tab-pane label="ws 通道" name="ws" v-if="isAdmin">
<div class="row" style="margin-bottom:10px"> <div class="row" style="margin-bottom:10px">
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small"> <el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag> 进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
@ -1921,11 +1952,24 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<script> <script>
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue; const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
const api = axios.create({ timeout: 60000 }); const api = axios.create({ timeout: 60000 });
// 收到 401 就弹登录框: setup() 里把 _onUnauth 挂上 (拦截器在 setup 外, 够不到 authed)。
let _onUnauth = null;
api.interceptors.response.use(r => r, e => {
if (e && e.response && e.response.status === 401 && _onUnauth) _onUnauth();
return Promise.reject(e);
});
createApp({ createApp({
setup() { setup() {
const tab = ref('cmd'), loading = ref(false), err = ref(''); const tab = ref('cmd'), loading = ref(false), err = ref('');
const mode = ref('trader'); 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 loginForm = reactive({ phone: '', password: '' });
const loginBusy = ref(false), loginErr = ref('');
_onUnauth = () => { authed.value = false; };
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({}); const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref(''); const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref(''); const positions = ref([]), lots = ref([]), lotsOf = ref('');
@ -2356,7 +2400,47 @@ createApp({
const r = await api[method](url, body); const r = await api[method](url, body);
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; } if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
return r.data; return r.data;
} catch (e) { err.value = url + ' → ' + e.message; return { ok: false, error: e.message }; } } catch (e) {
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) || '无权限: 需要交易系统管理员';
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
}
err.value = url + ' → ' + e.message; return { ok: false, error: e.message };
}
}
// ---- 登录闭环 (2026-08 加权限)
async function checkMe() {
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 };
authed.value = true;
if (!isAdmin.value && mode.value === 'ops') mode.value = 'trader';
} else { authed.value = false; }
} catch (e) { authed.value = false; }
authReady.value = true;
}
async function doLogin() {
loginErr.value = '';
if (!loginForm.phone || !loginForm.password) { loginErr.value = '手机号和密码都要填'; return; }
loginBusy.value = true;
try {
const d = (await api.post('/api/auth/login',
{ phone: loginForm.phone, password: loginForm.password })).data || {};
if (d.ok) { loginForm.password = ''; await checkMe(); loadAll(); loadPlan(); startAutoRefresh(); }
else { loginErr.value = d.error || '登录失败'; }
} catch (e) {
loginErr.value = (e.response && e.response.data && e.response.data.error)
|| ('登录失败: ' + e.message);
} finally { loginBusy.value = false; }
}
async function doLogout() {
try { await api.post('/api/auth/logout', {}); } catch (e) {}
authed.value = false;
me.value = { phone: '', username: '', role: '' };
} }
async function loadParams() { async function loadParams() {
@ -2891,7 +2975,10 @@ createApp({
refreshTimer = setInterval(() => { if (autoRefresh.value) refreshLive(); }, 30000); refreshTimer = setInterval(() => { if (autoRefresh.value) refreshLive(); }, 30000);
} }
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); }); onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
onMounted(() => { loadAll(); loadPlan(); startAutoRefresh(); }); // 首屏 + 启动自动刷新 onMounted(async () => {
await checkMe();
if (authed.value) { loadAll(); loadPlan(); startAutoRefresh(); }
}); // 首屏: 先验登录, 已登录才拉数据与启动自动刷新
// ═══════════ 交易员工作台改版 (2026-08-26) ═══════════ // ═══════════ 交易员工作台改版 (2026-08-26) ═══════════
// 一、融合消息栏: 把四类消息合成一条按时间倒序的流。 // 一、融合消息栏: 把四类消息合成一条按时间倒序的流。
// 数据全部来自页面已经在拉的接口, 后端一个字都不用改。 // 数据全部来自页面已经在拉的接口, 后端一个字都不用改。
@ -3103,6 +3190,7 @@ createApp({
msgLatest, msgLevelClass, setMsgKind, msgGo, msgLatest, msgLevelClass, setMsgKind, msgGo,
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther, sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
lFold, rFold, railOpen, foldRail, closeRail, openRailSide, lFold, rFold, railOpen, foldRail, closeRail, openRailSide,
authed, authReady, me, isAdmin, loginForm, loginBusy, loginErr, doLogin, doLogout,
sectClosed, toggleSect }; sectClosed, toggleSect };
} }
}).use(ElementPlus).mount('#app'); }).use(ElementPlus).mount('#app');

View File

@ -49,6 +49,22 @@ class Settings(BaseSettings):
PMS_WEB_HOST: str = "0.0.0.0" # 管理页面 (FastAPI + 单页) PMS_WEB_HOST: str = "0.0.0.0" # 管理页面 (FastAPI + 单页)
PMS_WEB_PORT: int = 38100 PMS_WEB_PORT: int = 38100
# =========================================================
# 一之二、登录与权限 (PMS 自带登录 + 两个角色: 交易系统管理员 / 交易员)
# =========================================================
# 认证接第三方登录系统 bshop: 手机号+密码, 登录成功的返回里直接带这个人的权限清单。
# 角色从返回的 permissions 里认「管理员标记」权限得出; 会话用 PMS 自签票 (HMAC) 维持,
# 服务端不存会话。除 PMS_SESSION_SECRET 外都是普通业务配置; 那一项是**密钥**, 只从 .env
# 注入 (见下「密钥」纪律), 代码默认留空。
PMS_AUTH_ENABLED: bool = True # 登录总开关; False = 回到无登录的旧行为 (回滚用)
PMS_SESSION_SECRET: str = "" # 会话票签名密钥 (HMAC)。**只从 .env 注入**; 开着登录却为空时一律拒绝放行
PMS_SESSION_TTL_HOURS: int = 12 # 会话有效期 (小时)
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_TRADER_PERMS: str = "" # 进入 PMS 至少要占的权限; 留空=任何能登录的人都算交易员
# ========================================================= # =========================================================
# 二、业务参数「初值」(运行时以 pms_runtime_param 表为准, 页面可调) # 二、业务参数「初值」(运行时以 pms_runtime_param 表为准, 页面可调)
# ========================================================= # =========================================================

View File

@ -32,6 +32,8 @@
接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/ 接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/
名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/ 名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/
判分脚本聚合与对照分组 (41 ) 判分脚本聚合与对照分组 (41 )
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
接口鉴权(写操作默认拒绝) (14 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
560 560
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
@ -47,7 +49,8 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py", "test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py", "test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py", "test_batch13_units.py", "test_batch14_units.py", "test_batch15_units.py",
"test_batch16_units.py", "test_batch17_units.py", "test_wiring.py"] "test_batch16_units.py", "test_batch17_units.py",
"test_batch18_units.py", "test_wiring.py"]
def main(): def main():

View File

@ -0,0 +1,200 @@
# -*- 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/adoptPOST /api/proposals.../archive .../cancel
结尾段区分), GET 默认放行仅少数运维读接口要管理员; 公开白名单放行首页与登录接口
运行: python scripts/test_batch18_units.py
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.web import auth as A # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
# 与用户给的 bshop 登录成功返回同构的样例 (裁剪了权限条数, 字段与层级一致)
SAMPLE = {
"code": 200, "msg": "登录成功",
"data": {
"access_token": "SMxzzyfoIJogItsmiR9Qv0NdnFAoKE-F5WdJPDhzZOA",
"token_type": "bearer", "expires_in": 604800,
"user": {"phone": "18795373439", "username": "18795373439", "wechat_bound": True},
"permissions": [
{"system_key": "band", "menu_key": "-", "system_name": "波段系统后端", "menu_name": "波段系统后端"},
{"system_key": "band", "menu_key": "bkld", "system_name": "波段系统后端", "menu_name": "板块轮动"},
{"system_key": "band", "menu_key": "yhyqx", "system_name": "波段系统后端", "menu_name": "用户与权限"},
{"system_key": "band_web", "menu_key": "traded", "system_name": "波段系统网页端", "menu_name": "波段监控"},
],
"orgs": [{"id": 277097889622, "org_name": "高姐"},
{"id": 489281497140, "org_name": "天盟资本"}],
},
}
# ---------------------------------------------------------------- 一、角色判定
@case("角色: 命中管理员标记 -> 交易系统管理员")
def _role_admin():
r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
assert r == A.ROLE_ADMIN, r
@case("角色: 只有普通权限 -> 交易员")
def _role_trader():
r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "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("角色: 配了交易员准入却一条不占 -> None (无权进入)")
def _role_denied():
r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-")
assert r is None, r
@case("角色: 管理员标记可多条, 命中任一即算")
def _role_admin_multi():
r = A.role_from_permissions(["band:yhyqx"], "pms:admin, band:yhyqx", "")
assert r == A.ROLE_ADMIN, r
# ---------------------------------------------------------------- 二、会话票
@case("会话: 签发-校验往返, payload 字段齐全")
def _sess_roundtrip():
tok = A.make_session("18795373439", "老王", A.ROLE_ADMIN, "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
@case("会话: 篡改正文或签名 -> None")
def _sess_tampered():
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
@case("会话: 过期 / 换密钥 / 空密钥 / 空票 一律拒")
def _sess_reject():
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, "空密钥没拒"
assert A.verify_session("", "s3cr3t", now=1001.0) is None, "空票没拒"
# ---------------------------------------------------------------- 三、bshop 解析
@case("解析: 真实样例 -> 手机号/用户名/权限/组织")
def _parse_ok():
r = A.parse_bshop_login(SAMPLE)
assert r["ok"] is True, r
assert r["phone"] == "18795373439" and r["username"] == "18795373439", r
assert "band:bkld" in r["permissions"] and "band:-" in r["permissions"], r["permissions"]
assert "band:yhyqx" in r["permissions"], r["permissions"]
assert len(r["orgs"]) == 2 and r["orgs"][0]["org_name"] == "高姐", r["orgs"]
@case("解析: 样例接角色 —— 默认标记判交易员, 复用 band:yhyqx 判管理员")
def _parse_then_role():
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
@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
assert A.parse_bshop_login("nope")["ok"] is False
# ---------------------------------------------------------------- 四、接口鉴权
@case("鉴权: 交易员白名单 4 条写接口放行 (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")]:
assert A.authz_decision(m, path) == "any", (m, path, A.authz_decision(m, path))
@case("鉴权: 写操作默认拒绝 —— 白名单外一律要管理员")
def _authz_admin_default():
admin_paths = [
("POST", "/api/params"),
("POST", "/api/ops/reconcile"),
("POST", "/api/strategies"),
("POST", "/api/strategies/S1/status"),
("POST", "/api/industry/import"),
("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"),
]
for m, path in admin_paths:
assert A.authz_decision(m, path) == "admin", (m, path, A.authz_decision(m, path))
@case("鉴权: GET 默认放行, 仅三条运维读接口要管理员")
def _authz_gets():
for path in ["/api/ops/rebuild-preflight", "/api/ops/rebuild-accept",
"/api/ops/downstream-schema"]:
assert A.authz_decision("GET", path) == "admin", path
for path in ["/api/overview", "/api/positions", "/api/ledger", "/api/proposals"]:
assert A.authz_decision("GET", path) == "any", path
@case("公开白名单: 首页/静态/登录接口放行, 业务接口不放行")
def _public():
for p in ["/", "/health", "/favicon.ico", "/static", "/static/vendor/vue.global.js",
"/api/auth/login", "/api/auth/logout", "/api/me"]:
assert A.is_public_path(p) is True, p
for p in ["/api/params", "/api/overview", "/api/commands"]:
assert A.is_public_path(p) is False, p
def main():
passed, failed = 0, []
for name, fn in RESULTS:
try:
fn()
passed += 1
print(f"{name}")
except Exception as e:
failed.append((name, e))
print(f"{name}: {type(e).__name__}: {e}")
traceback.print_exc()
print()
if failed:
print(f"FAILED {len(failed)}/{len(RESULTS)}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()