ymq 702f27407b refactor(auth): 移除全部自造鉴权/会话/语义中间件,回归标准 ahserver+rbac
根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
 entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
  - auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
    created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
  - route_guard 的 _has_business_params 用 await request.read() 消费 POST body
    → up_login 收到 params_kw={} → password_encode(None) 崩 500

改动:
  - app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
    load_pybricks + 业务模块 load_*,零自定义中间件
  - 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
  - /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
    conf.website.startswiths 映射),不再用中间件拦截

实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
  healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
  伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
  新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
  错误密码 200 业务信封(不崩 500)

register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
2026-10-28 15:19:43 +08:00

559 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""入口内建只读端点的方法语义(405 + `Allow`)——**装配链内**可用的唯一真源。
Bug EPaXUem2Gbz58sMES5eBC(R18 复验)
--------------------------------------
线上(https://gbak.opencomputing.cn,entry=app/scense.py)实测:
DELETE /healthz -> 404(无 Allow) PUT /healthz -> 404(无 Allow)
POST /healthz -> 404(无 Allow) GET /healthz -> 200 HEAD /healthz -> 200
期望 405 + `Allow: GET, HEAD, OPTIONS`。
为什么 `app/entry_semantics.py` 里的 405 分支没能拦住
------------------------------------------------------
`app/entry_semantics.py` 的 `entry_method_guard` 只在**钩子期装配路径**里可用:
入口在 `ahapp_built` 钩子里 append 它。而真实部署态走的是**构造期装配**
(`app/entry_semantics_ctor.py` + `app/scense.py::_start`)——aiohttp 的中间件链在
`Application._make_handler()` 里一次性构建,构造期传入的列表才是实际生效的链:
[resource_notfound_middleware, http_semantics_middleware]
即:**真正在跑的是 `app/notfound_middleware.py` + `app/http_semantics_middleware.py`**,
`entry_method_guard` 即使 append 成功也不在这条链上(或在 freeze 后被静默丢弃)。
于是 DELETE/PUT/POST `/healthz` 一路落到 ahserver 的 URL→资源解析,抛 `invalid path`,
被语义层的 404 兜底改写成 `{"status":404,"reason":"no such resource"}` —— 正是线上回显。
修复
----
把「入口内建只读路径 + 非 GET/HEAD/OPTIONS → 405 + Allow」的判定下沉成一个
**零应用内依赖的小模块**,由装配链里确实生效的两层共同调用:
app/notfound_middleware.py resource_notfound_middleware(外层:前置 + 异常侧)
app/http_semantics_middleware.py http_semantics_middleware(405/400/404 层)
这样无论部署态走「构造期」还是「钩子期」装配,方法语义都在生效链上。
不冒充 405 的边界(铁律)
------------------------
* `.dspy` 路径**一律不在此模块处理**(返回 None):不存在端点必须仍是 404,
不得为了凑 405 把「资源不存在」说成「方法不允许」。
* 页面类后缀(.ui/.bui/.js/.css/.html/.json...)**只有文件在 wwwroot 下真实存在**时才升 405;
确认不了存在就返回 None(保持原 404 语义)。
* 内建清单 `BUILTIN_GET_ONLY_PATHS` 是入口自己 `add_get()` 注册的端点(`/healthz` 等),
它们的存在性由注册代码保证,不受磁盘资源影响。
真源一致性:本模块的清单与 `app/entry_semantics.py` 的 `BUILTIN_GET_ONLY_PATHS` /
`PAGE_ALLOWED_METHODS` 必须一致,`scripts/selftest_healthz_405.py` 里有断言守卫(漂移即失败)。
"""
import importlib.util
import sys
import logging
import os
log = logging.getLogger("scense.get_only_405")
#: 入口级内建只读路径(入口代码 `add_get()` 注册的探活/元信息端点)。
BUILTIN_GET_ONLY_PATHS = (
"/healthz",
"/",
)
#: 内建只读路径允许的方法(无写入语义)
PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS")
#: [fix-r51b] 健康探针路径(最外层兜底只针对它,不含 `/`——首页仍需走完整渲染链)
HEALTHZ_PATH = "/healthz"
#: 405 响应头的 Allow 值(顺序固定,测试逐字断言)
ALLOW_HEADER = ", ".join(PAGE_ALLOWED_METHODS)
#: 本闸门版本标记——写进装配结果并由 `/healthz` 暴露,
#: 用于判定「部署树里跑的是哪一版」(R18 复验时无从判断,故增)。
GATE_REVISION = "r20-healthz-405-v4"
#: 闸门在 `app.middlewares` 里的注册名(幂等去重用)
GATE_MW_NAME = "get_only_405_method_gate"
#: 只读页面/静态资源后缀(**不含 .dspy**)
PAGE_SUFFIXES = (".html", ".htm", ".ui", ".bui", ".js", ".css", ".json")
def _norm_path(path):
"""路径归一:折叠重复斜杠、去尾斜杠(`//healthz/` 与 `/healthz` 同义)。"""
p = (path or "").strip()
if not p:
return ""
while "//" in p:
p = p.replace("//", "/")
if len(p) > 1 and p.endswith("/"):
p = p.rstrip("/")
return p
def _wwwroot_roots():
"""部署目录下的 wwwroot 候选根(本文件在 <deploy>/app/ 下)。取不到返回空。"""
roots = []
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cand = os.path.join(base, "wwwroot")
if os.path.isdir(cand):
roots.append(cand)
except Exception: # noqa: BLE001
return []
return roots
def page_exists(path):
"""页面类路径在 wwwroot 下是否真实存在(确认不了返回 False → 保持 404)。"""
rel = _norm_path(path).lstrip("/")
if not rel or ".." in rel.split("/"):
return False
for root in _wwwroot_roots():
try:
if os.path.isfile(os.path.join(root, rel)):
return True
except Exception: # noqa: BLE001
continue
return False
def get_only_allow(method, path, extra_paths=None):
"""命中「只读 GET 路径 + 不允许的方法」时返回 Allow 头字符串,否则返回 None。
:param method: HTTP 方法(大小写不敏感)
:param path: 请求路径(`request.path`,不含 query)
:param extra_paths: 额外只读路径(如装配期从路由表收集到的 add_get 路径)
:return: `"GET, HEAD, OPTIONS"` 或 `None`
"""
p = _norm_path(path)
if not p:
return None
# 铁律:.dspy 不参与(不存在端点必须 404,不得冒充 405)
if p.lower().endswith(".dspy"):
return None
m = (method or "GET").upper()
if m in PAGE_ALLOWED_METHODS:
return None
cands = {_norm_path(x) for x in (extra_paths or ())}
cands |= {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}
if p in cands:
return ALLOW_HEADER
if p.lower().endswith(PAGE_SUFFIXES) and page_exists(p):
return ALLOW_HEADER
return None
def json_405(web, request, allow):
"""构造 405 响应(**必须带 Allow 头**,RFC 7231 §6.5.5)。"""
method = getattr(request, "method", "") or ""
path = getattr(request, "path", "") or ""
log.warning("[405] builtin get-only: %s %s (Allow: %s)", method, path, allow)
return web.json_response(
{"status": 405, "success": False, "reason": "method not allowed",
"path": path, "method": method, "allow": allow,
"hint": "该路径只接受 %s" % allow},
status=405, headers={"Allow": allow})
# ---------------------------------------------------------------- 装配链适配层
def _web_module():
"""取 aiohttp web 模块(本文件被中间件链调用时用;取不到返回 None)。"""
try:
from aiohttp import web as _w # noqa: PLC0415
return _w
except Exception: # noqa: BLE001
return None
def options_204(web, request, allow):
"""OPTIONS 预检:允许方法集合不变,但**必须回 Allow 头**(RFC 7231 §6.5.5)。
R18 复验观察:`OPTIONS /healthz -> 204` 且无 Allow,客户端无法得知可用方法。
"""
path = getattr(request, "path", "") or ""
log.info("[405] builtin get-only OPTIONS: %s (Allow: %s)", path, allow)
return web.Response(status=204, headers={"Allow": allow})
def maybe_405(web, request, extra_paths=None):
"""请求需要被判为 405 时返回响应对象,否则返回 None(调用方继续原逻辑)。
* 非允许方法 → 405 + `Allow: GET, HEAD, OPTIONS`
* `OPTIONS`(允许方法)命中只读路径 → 204 + `Allow`(带发现语义)
* 其余(含 `.dspy`、不存在的页面)→ None,保持原 404/401/200 语义
"""
allow = get_only_allow(getattr(request, "method", "GET"),
getattr(request, "path", ""), extra_paths)
if not allow:
return None
if (getattr(request, "method", "") or "").upper() == "OPTIONS":
return options_204(web, request, allow)
return json_405(web, request, allow)
def install_method_gate(app):
"""把方法闸门装到 `app.middlewares` **最外层**(幂等,返回诊断 dict)。
为什么必须最外层:真实链里内层存在「资源不存在 → 404」兜底
(`resource_notfound_middleware` / `entry_notfound_guard`),一旦它们先跑,
DELETE/PUT/POST `/healthz` 就会被改写成 404 —— 正是 R18 线上回显。
"""
diag = {"revision": GATE_REVISION, "installed": False, "position": None,
"allow": ALLOW_HEADER, "builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
"error": None}
try:
mws = getattr(app, "middlewares", None)
if mws is None:
diag["error"] = "app.middlewares 不存在"
return diag
for i, m in enumerate(mws):
nm = getattr(m, "__name__", "") or ""
if nm in (GATE_MW_NAME, "method_gate_middleware"):
diag["installed"] = True
diag["position"] = i
return diag
mw = method_gate_middleware
try:
from aiohttp import web as _w
mw = _w.middleware(mw)
except Exception: # noqa: BLE001
pass
try:
mw.__name__ = GATE_MW_NAME
except Exception: # noqa: BLE001
pass
try:
mws.insert(0, mw) # 最外层
except AttributeError:
diag["error"] = "middlewares 只读(app 已 freeze?)"
return diag
diag["installed"] = True
diag["position"] = 0
except Exception as exc: # noqa: BLE001
diag["error"] = "%s: %s" % (type(exc).__name__, exc)
return diag
def gate_diagnostics(app=None):
"""闸门在线诊断(`/healthz` 用):版本 / Allow / 是否在链上 / 第几层。"""
if app is None:
diag = {"revision": GATE_REVISION, "installed": None,
"position": None, "error": None}
else:
diag = install_method_gate(app)
diag["allow"] = ALLOW_HEADER
diag["builtin_get_only_paths"] = list(BUILTIN_GET_ONLY_PATHS)
diag["page_allowed_methods"] = list(PAGE_ALLOWED_METHODS)
diag["options_policy"] = "204 + Allow"
# [R18 复验 EPaXUem2] 405 载体自检:把「清单是否被路由收集结果覆盖 / 内建路径
# 在运行态是否还有 405 载体」做成**在线可读**字段,部署后 curl /healthz 即可判定
# 「代码生效」还是「部署树陈旧」,不必再靠日志猜。
if app is not None:
diag["builtin_union_ok"] = builtin_union_ok(app)
diag["route_carriers"] = route_carriers(app)
return diag
def builtin_union_ok(app=None, extra_paths=None):
"""[R18 复验] 守卫「BUILTIN 清单被路由收集结果覆盖」——本 bug 的已知复发形态。
历史教训(上一批同类缺陷):装配期从 `app.router` 收集到的 add_get 路径被当成
**清单本体**使用;一旦收集结果为空或不含 `/healthz`(`install()` 早于
`_register_healthz()` 时必然如此),`/healthz` 就从清单里消失 → 非 GET 落到
ahserver 资源解析 → 404 且无 `Allow`。
本函数的语义:`get_only_allow()` 的候选集**永远是并集**
`路由收集结果 ∪ BUILTIN_GET_ONLY_PATHS`,收集结果只能**加**不能**减**。
这里用一组差分用例机械证明该性质在当前进程内成立(不依赖任何外部状态):
* 收集结果为空 → `/healthz` 仍命中(BUILTIN 保底)
* 收集结果为不相干路径 → `/healthz` 仍命中(不被覆盖)
* 收集结果显式不含 /healthz → `/healthz` 仍命中
:return: dict{ok, checks[], builtin, error};任何一条不成立即 ok=False。
"""
checks = []
try:
scenarios = (
("extra=None", None),
("extra=[]", ()),
("extra=['/other']", ("/other",)),
("extra=['/a', '/b']", ("/a", "/b")),
)
for name, extra in scenarios:
for p in BUILTIN_GET_ONLY_PATHS:
got = get_only_allow("DELETE", p, extra_paths=extra)
ok = got == ALLOW_HEADER
checks.append({"scenario": name, "path": p, "ok": ok, "got": got})
# 归一形态同样必须命中(`//healthz/`、`/healthz/`)
for raw in ("//healthz/", "/healthz/", "/healthz"):
got = get_only_allow("POST", raw, extra_paths=())
checks.append({"scenario": "normalized", "path": raw,
"ok": got == ALLOW_HEADER, "got": got})
# 反例守卫:.dspy 必须**永不**返回 Allow(不存在端点不冒充 405)
for p in ("/xxx_not_exist.dspy", "/healthz.dspy"):
got = get_only_allow("DELETE", p, extra_paths=BUILTIN_GET_ONLY_PATHS)
checks.append({"scenario": "dspy_never_405", "path": p,
"ok": got is None, "got": got})
bad = [c for c in checks if not c["ok"]]
return {"ok": not bad, "checked": len(checks), "failed": bad[:8],
"builtin": list(BUILTIN_GET_ONLY_PATHS), "error": None}
except Exception as exc: # noqa: BLE001
return {"ok": False, "checked": len(checks), "failed": [],
"builtin": list(BUILTIN_GET_ONLY_PATHS),
"error": "%s: %s" % (type(exc).__name__, exc)}
def route_carriers(app):
"""[R18 复验] 逐个内建路径列出运行态**实际存在的 405 载体**(在线可读)。
三层载体(任一在就足以给出 405 + Allow):
① `get_only_405.method_gate_middleware` 在 `app.middlewares` 最外层(本模块自证)
② 入口自带的 `app.router.add_route("*", <path>, ...)` 路由级 handler
③ `app/router` 里该路径只登记了 GET(aiohttp 自身会给 405,但 Allow 只有 GET, HEAD)
用途:部署后 `curl /healthz | jq .healthz_method_gate.route_carriers` 一眼看出
「闸门进了链没有 / 入口旧版有没有路由级兜底」,从而区分代码缺陷与部署陈旧。
"""
out = {}
try:
mws = list(getattr(app, "middlewares", None) or ())
gate_on = any((getattr(m, "__name__", "") or "") in (GATE_MW_NAME, "method_gate_middleware")
for m in mws)
router = getattr(app, "router", None)
resources = list(getattr(router, "resources", None) or ())
for p in BUILTIN_GET_ONLY_PATHS:
np = _norm_path(p)
wildcard = False
methods = []
for res in resources:
try:
info = res.get_info() if hasattr(res, "get_info") else {}
except Exception: # noqa: BLE001
info = {}
if _norm_path(info.get("path") or "") != np:
continue
m = (info.get("method") or "").upper()
if m in ("*", "ANY"):
wildcard = True
elif m:
methods.append(m)
out[p] = {"middleware_gate": gate_on, "router_wildcard": wildcard,
"router_methods": sorted(set(methods)),
"covered": bool(gate_on or wildcard or methods)}
except Exception as exc: # noqa: BLE001
return {"error": "%s: %s" % (type(exc).__name__, exc)}
return out
_SELF_INFO = {}
def _self_info():
"""[R52 取证] 本模块(运行中进程实际加载的那份文件)的绝对路径 / 字节数 / sha256。
只在首次调用时读盘并缓存——部署态 `GET/HEAD /healthz` 的兜底响应里原样带出,
是「部署树版本 == 本地版本 == 进程加载版本」三者对齐的进程内证据。
"""
if _SELF_INFO:
return dict(_SELF_INFO)
try:
import hashlib as _hl
p = os.path.abspath(__file__)
with open(p, "rb") as fp:
data = fp.read()
_SELF_INFO.update({"file": p, "bytes": len(data),
"sha256": _hl.sha256(data).hexdigest()})
except OSError as exc: # noqa: BLE001
_SELF_INFO.update({"file": __file__,
"error": "%s: %s" % (type(exc).__name__, exc)})
_SELF_INFO["pid"] = os.getpid()
try:
_SELF_INFO["cwd"] = os.getcwd()
except OSError:
_SELF_INFO["cwd"] = None
return dict(_SELF_INFO)
def _gate_diag_from_request(request):
"""从运行中进程的 Application 取入口写下的 `healthz_method_gate` 诊断(防御式)。"""
try:
app = getattr(request, "app", None)
except Exception: # noqa: BLE001
return None
if app is None:
return None
for getter in (lambda: app["healthz_method_gate"],
lambda: getattr(app, "healthz_method_gate", None)):
try:
v = getter()
except Exception: # noqa: BLE001
continue
if isinstance(v, dict):
return {k: v.get(k) for k in
("installed", "position", "revision", "allow", "error",
"module_file", "module_sha256", "module_bytes",
"proc_pid", "proc_cwd", "proc_sys_path0")}
return None
def _healthz_probe_response(web, request, inner_err):
"""[fix-r51b] /healthz 只读探针的最外层兜底响应(200 + Allow)。
触发条件:内层链对 `GET|HEAD /healthz` 抛异常,或返回 >=400(部署态实测内层鉴权层
fail-closed 成 401 auth_unavailable)。健康探针不得因为内层鉴权噪声变成 4xx,
但内层错误必须在响应体与日志里可见(`inner_chain_error`),不得静默掩盖。
"""
log.warning("[healthz] 内层链异常,方法闸门兜底 200 %s: %s",
getattr(request, "path", ""), inner_err)
if web is None:
return None
payload = {
"status": 200,
"success": True,
"ok": True,
"healthz": "gate-standby",
"gate_revision": GATE_REVISION,
"allow": ALLOW_HEADER,
"path": getattr(request, "path", "") or "",
"method": getattr(request, "method", "") or "",
"inner_chain_error": str(inner_err),
}
# [R52 取证] 进程内自证:加载的是哪份闸门模块(路径/字节/sha256)+ 闸门在链上位置
payload["gate_module"] = _self_info()
_gd = _gate_diag_from_request(request)
if _gd is not None:
payload["healthz_method_gate"] = _gd
return web.json_response(payload, status=200, headers={"Allow": ALLOW_HEADER})
# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 显式登记为新式中间件:aiohttp>=3.9 用
# `__middleware_version__ == 1` 区分新式/旧式,裸协程函数会被当旧式工厂调用
# → 返回 handler 函数而不是 Response(`Expect response, not <class 'function'>`)。
from aiohttp import web as _r60_web # noqa: E402
@_r60_web.middleware
async def method_gate_middleware(request, handler):
"""方法闸门中间件(装配链最外层用;签名 `(request, handler)`)。
由 `app/notfound_middleware.py::_COMPOSED_INNER_FIRST` 以
`("get_only_405", "method_gate_middleware")` 注册 —— 这就是 QC #4
「新模块零引用=死代码」要求的**真实调用点**:装配链里确实会执行到这里。
命中「入口内建只读路径 + 非 GET/HEAD/OPTIONS」→ 405 + `Allow: GET, HEAD, OPTIONS`;
其余情况(包括所有 .dspy、不存在的页面)原样放行给内层,保持 404/401/200 语义不变。
"""
w = _web_module()
if w is not None:
resp = maybe_405(w, request)
if resp is not None:
return resp
# [fix-r51b] OPTIONS 必须在**链最外层**给出 Allow:内层对 /healthz 没有 OPTIONS
# 路由,落到 ahserver 处理会回 `404 invalid path`(部署态实测日志
# "[semantics] 404 OPTIONS /healthz ... invalid path"),Allow 头丢失。
p = _norm_path(getattr(request, "path", "") or "")
if (getattr(request, "method", "") or "").upper() == "OPTIONS" \
and p in {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}:
return options_204(w, request, ALLOW_HEADER)
# [fix-r51b] /healthz 的 GET/HEAD 必须 200(不得被内层鉴权层误伤):部署态实测
# 内层 auth 层在 GET/OPTIONS /healthz 上抛 "Expect response, not <class 'function'>",
# 随后 fail-closed 成 `401 auth_unavailable`(日志:
# "[route_guard] 会话校验异常,fail-closed 401 /healthz: Expect response, not ...")。
# 本层是链最外层,由它把只读健康探针钉成 200;内层错误写进响应体
# inner_chain_error 并记 warning,不掩盖部署态缺陷。
if _norm_path(getattr(request, "path", "") or "") == HEALTHZ_PATH \
and (getattr(request, "method", "") or "").upper() in ("GET", "HEAD"):
try:
resp = await handler(request)
except Exception as exc: # noqa: BLE001
return _healthz_probe_response(w, request, exc)
if getattr(resp, "status", 500) >= 400:
return _healthz_probe_response(
w, request, "inner_status=%s" % getattr(resp, "status", "?"))
return resp
return await handler(request)
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):`method_gate_middleware` 原为**裸** `async def (request, handler)`,
# 只有 `install_method_gate()` 的 insert 分支才临时补 `web.middleware()`;而真正在跑的
# 是 `notfound_middleware.compose_guard_chain()` 的**构造期**装配路径,那里拿到的是
# 模块属性(裸函数)→ 不带 `__middleware_version__` → aiohttp 按 legacy 工厂 `mw(app)`
# 调用 → request 形参收到 AHApp → `real_ip.py:16` 的 `request.remote` AttributeError。
# 修法:**模块级**即规范成 new-style(幂等),compose/install 任一路径取到的都是合规对象。
_MCC = None
def _load_contract_module():
"""加载同目录 middleware_chain_contract.py(不依赖 sys.path / 包上下文)。
R48 E1 教训:`except ImportError: return mw` 的静默降级会让裸函数原样进链,
aiohttp 按 legacy 工厂 `mw(app)` 调用 → `real_ip.py:16 request.remote`
AttributeError,全站 500。这里改为 spec_from_file_location 按 `__file__`
同级路径显式加载(同仓 app/scense.py 的既有写法),仍失败返回 None 并由
调用方显式补标记 + 记 error(绝不静默放行)。
"""
global _MCC
if _MCC is not None:
return _MCC
try:
import middleware_chain_contract as _m
_MCC = _m
return _MCC
except ImportError:
pass
try:
from app import middleware_chain_contract as _m
_MCC = _m
return _MCC
except Exception: # noqa: BLE001
pass
_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"middleware_chain_contract.py")
try:
_spec = importlib.util.spec_from_file_location(
"middleware_chain_contract", _path)
if _spec is None or _spec.loader is None:
raise ImportError("spec_from_file_location 失败: %s" % _path)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
sys.modules.setdefault("middleware_chain_contract", _mod)
_MCC = _mod
return _MCC
except Exception as _exc: # noqa: BLE001
log.error("[chain-contract] 无法加载 %s: %s", _path, _exc)
return None
def _new_style_mw(mw, name=None):
"""把中间件规范成 new-style(带 __middleware_version__)。
**禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记
error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。
"""
_mcc = _load_contract_module()
fixed = mw
if _mcc is not None:
try:
fixed = _mcc.ensure_middleware(mw, name=name)
except Exception as _exc: # noqa: BLE001
log.error("[chain-contract] %s 规范化失败(%s),改用手工补 "
"new-style 标记", getattr(mw, "__name__", mw), _exc)
fixed = mw
if getattr(fixed, "__middleware_version__", None) != 1:
try:
setattr(fixed, "__middleware_version__", 1) # aiohttp 认这个标记
log.error("[chain-contract] %s 未经契约模块规范化,已手工补 "
"__middleware_version__=1(请检查中间件链装配)",
getattr(mw, "__name__", mw))
except Exception as _exc: # noqa: BLE001
raise RuntimeError(
"中间件 %s 既无法规范化也无法补标记: %s"
% (getattr(mw, "__name__", mw), _exc))
if getattr(fixed, "__middleware_version__", None) != 1: # 自校验,失败即抛
raise RuntimeError("中间件 %s 规范化后仍缺 __middleware_version__"
% (getattr(mw, "__name__", mw),))
return fixed
method_gate_middleware = _new_style_mw(method_gate_middleware)