添加权限
This commit is contained in:
parent
e1a81619dd
commit
1be155c478
|
|
@ -24,6 +24,15 @@ PMS_REDIS_URL=redis://:密码@192.168.16.150:6379/8
|
|||
# ---- 管理页面 ----
|
||||
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) ----
|
||||
# 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。
|
||||
# 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -17,7 +17,7 @@ import logging
|
|||
import os
|
||||
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 config.settings import settings
|
||||
|
|
@ -26,6 +26,7 @@ from app.core import tradedays as td
|
|||
from app.db import session as dbs
|
||||
from app.repo import downstream_repo, pms_repo
|
||||
from app.services import command_service, industry, ledger_service, param_store, portfolio
|
||||
from app.web import auth as authmod
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
|
||||
|
|
@ -49,6 +50,34 @@ except Exception as _e: # 挂不上不影响任何 API, 页面照常
|
|||
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):
|
||||
"""统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。"""
|
||||
try:
|
||||
|
|
@ -131,6 +160,59 @@ def index():
|
|||
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")
|
||||
def api_overview():
|
||||
return ok(portfolio.overview)
|
||||
|
|
|
|||
|
|
@ -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 .b{flex:none;width:92px;}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<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>
|
||||
|
|
@ -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="mzSig.advice && !mzSig.advice.adopted" style="margin-top:4px">
|
||||
<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>
|
||||
<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="seg">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
:title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}<span v-if="lastRefresh"> · {{ lastRefresh }}</span></span>
|
||||
<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>
|
||||
|
||||
<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="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="操作" 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-tab-pane>
|
||||
<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">
|
||||
这只票现在只卖不买。决策系统提示{{ s.buy_paused.reason || '资金异动' }},已经停掉买入;
|
||||
卖出、以及把当天做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-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>
|
||||
|
|
@ -1228,8 +1259,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
</el-alert>
|
||||
|
||||
<template #footer>
|
||||
<el-button 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" @click="validateStrategy" :loading="stratDlg.busy">先校验</el-button>
|
||||
<el-button v-if="isAdmin" size="small" type="primary" @click="attachStrategy" :loading="stratDlg.busy">挂上并生效</el-button>
|
||||
</template>
|
||||
</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-tab-pane label="参数设置" name="params">
|
||||
<el-tab-pane label="参数设置" name="params" v-if="isAdmin">
|
||||
<div class="row" style="margin-bottom:10px">
|
||||
<span class="muted">页面改动持久化到 pms_runtime_param, 优先于 settings.py 初值; 基础设施连接串只在 .env 维护。</span>
|
||||
<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>
|
||||
<el-button size="small" :disabled="!canCancel(s.row) || s.row.cmd_class!=='task'"
|
||||
@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>
|
||||
<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>
|
||||
</template>
|
||||
</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 label="操作" width="90">
|
||||
<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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
|
@ -1586,7 +1617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
|
||||
有"这张单会真成交"的警告, 且不受 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">
|
||||
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
|
||||
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
|
||||
|
|
@ -1921,11 +1952,24 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
|||
<script>
|
||||
const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
|
||||
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({
|
||||
setup() {
|
||||
const tab = ref('cmd'), loading = ref(false), err = ref('');
|
||||
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 catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
|
||||
const positions = ref([]), lots = ref([]), lotsOf = ref('');
|
||||
|
|
@ -2356,7 +2400,47 @@ createApp({
|
|||
const r = await api[method](url, body);
|
||||
if (r.data && r.data.ok === false) { err.value = r.data.error || '请求失败'; }
|
||||
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() {
|
||||
|
|
@ -2891,7 +2975,10 @@ createApp({
|
|||
refreshTimer = setInterval(() => { if (autoRefresh.value) refreshLive(); }, 30000);
|
||||
}
|
||||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||||
onMounted(() => { loadAll(); loadPlan(); startAutoRefresh(); }); // 首屏 + 启动自动刷新
|
||||
onMounted(async () => {
|
||||
await checkMe();
|
||||
if (authed.value) { loadAll(); loadPlan(); startAutoRefresh(); }
|
||||
}); // 首屏: 先验登录, 已登录才拉数据与启动自动刷新
|
||||
// ═══════════ 交易员工作台改版 (2026-08-26) ═══════════
|
||||
// 一、融合消息栏: 把四类消息合成一条按时间倒序的流。
|
||||
// 数据全部来自页面已经在拉的接口, 后端一个字都不用改。
|
||||
|
|
@ -3103,6 +3190,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,
|
||||
sectClosed, toggleSect };
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,22 @@ class Settings(BaseSettings):
|
|||
PMS_WEB_HOST: str = "0.0.0.0" # 管理页面 (FastAPI + 单页)
|
||||
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 表为准, 页面可调)
|
||||
# =========================================================
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/
|
||||
名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/
|
||||
判分脚本聚合与对照分组 (41 例)
|
||||
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
|
||||
接口鉴权(写操作默认拒绝) (14 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||
共 560 例
|
||||
任一子集失败即整体失败 (退出码 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_batch10_units.py", "test_batch11_units.py", "test_batch12_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():
|
||||
|
|
|
|||
|
|
@ -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/adopt、POST /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()
|
||||
Loading…
Reference in New Issue