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

600 lines
31 KiB
Python
Raw 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 -*-
"""入口自带 HTTP 语义兜底(404 / 405)——零应用内依赖,可独立自测。
Bug Wp6gXrrEquzg3njONz60b(INT-404-neg)+ 28PuqnfpWzB2ypJoUTU_N(R17-2)
----------------------------------------------------------------------
线上实测(2026-10-21,带 admin cookie):
```
GET /runtime/api/__no_such_endpoint__.dspy -> 500 (期望 404)
GET /nonexist/x.dspy -> 500 (期望 404)
(无 cookie 时同两条 401 → 鉴权层正常,异常出在鉴权之后被兜底成 500)
```
R17-2 把语义中间件放在 `app/semantic_wiring.py`,由入口 `app/scense.py` import 后装配。
只要这条 import 在目标机失败(投递不全 / 包路径不可见 / pyc 过期),装配异常被
`except` 吞掉、进程照常起、`/healthz` 照常 200 → **静默降级**,500 原样存在。
本模块的设计目标就是**消除这条静默路径**:
* 只依赖 aiohttp 的 `web`(应用内零 import),入口用**绝对文件路径** + importlib 加载,
不依赖 `app` 包是否可导入;
* 提供 `install(app)` → 返回 {added, middlewares, ok},装配结果可被 /healthz 读到;
* 语义规则:处理器因「路径不存在」抛异常(ahserver 的 `invalid path` / `fpath is None`)
→ 404;`.dspy` 收到 GET/POST 以外的方法 → 405 + `Allow` 头;其它异常原样上抛保 500。
2026-10-22 修订(Bug Wp6gXrrEquzg3njONz60b 收口,见 `docs/02-develop/int404-bug2a-root-cause.md`)
--------------------------------------------------------------------------------------
上一轮为了「不靠 message 措辞」引入了 `NOTFOUND_EXC_TYPES` 类型白名单,但判定时**没有上下文**:
任何 `FileNotFoundError` / `NotADirectoryError` / `IsADirectoryError`(以及文本含
"no such file or directory" 的 OSError)都直接判 404。这会把**真实后端缺陷**(例如模块读配置
文件/证据文件缺失、业务代码 open() 打错路径)掩成 404,与「不掩错、真实故障仍 500」的门禁冲突,
也是 QC 追着要「回归证明真实异常仍返回 500」的直接原因。
本轮把「类型命中」加上**上下文前置条件**,只在两种情形判 404:
① 调用栈里存在 ahserver 资源解析帧(processorResource / processors.py / static_handle /
html_handle / BricksUIProcessor)——即异常确实产生于「URL→磁盘资源映射」这一步;
② 请求路径是静态资源类后缀(.ui/.bui/.js/.css/.html/.png…,**不含 .dspy**)——缺失文件
天然是 404 语义。
其余情况(含业务 .dspy 端点内部抛 FileNotFoundError)一律返回 False → 原样上抛 → 500。
message 主签名(`invalid path` / `fpath is None` / `no such resource`)仍优先命中,保住
Bug2-A 主路径(`ahserver processorResource.py:399` 原文 `raise Exception(f'... invalid path')`)。
2026-10-21 增补 F-03(Bug wC2W2N9x9Xkzw_yWiuP6F)
--------------------------------------------------
`/healthz` 由入口 `add_get()` 注册,路径既不以 `.dspy` 结尾、也不是磁盘静态资源,
因此**落在方法门禁覆盖范围之外**:DELETE/PUT/POST 先在 ahserver 的 URL→资源解析阶段
被判成 `invalid path`,再被 404 语义层兜成「资源不存在」(且无 `Allow` 头)。
方法语义必须归位:非 GET/HEAD/OPTIONS → **405 + `Allow: GET, HEAD, OPTIONS`**,不得 404。
覆盖范围按「同口径」取三类(全部在本层,单一真源,不散落到各入口):
① 入口级内建只读路径清单 `BUILTIN_GET_ONLY_PATHS`(`/healthz` 等)——静态兜底,
即使 `install()` 早于 `_register_healthz()`(路由表还查不到)也生效;
② 装配期从 aiohttp 路由表收集的「只注册了 GET」的路径(`collect_get_only_paths`)——
后续新增的任何内建 GET 端点自动覆盖,无需再改代码;
③ 直挂页面 `.html/.htm/.ui/.bui/.js/.css`(如 `play.html`、`index.ui`)——**仅当文件在
wwwroot 下真实存在**时升 405,确认不了存在就保持原语义(404),绝不把「真不存在」
冒充成「方法不允许」。
真不存在的资源仍由 404 层处理(方法判定先于资源存在性判定,与 aiohttp/RFC 7231 一致)。
2026-10-20 复核补充(F-03 / P2 收口)
-------------------------------------
方法判定「先于存在性」只对**确实存在**的资源成立:`.dspy` 端点不存在时,非 GET 方法
同样必须 404(不得冒充 405)。为此新增 `collect_dspy_route_paths()`(装配期收集路由表里
已注册的 .dspy)与 `dspy_endpoint_exists()`(路由表 + wwwroot 磁盘双重判定;两处都判定
不了时保守维持 405,不改变既有 F-01 契约)。
"""
import logging
import os
import traceback
log = logging.getLogger("scense.entry.semantics")
#: 中间件名称(供装配核验 / healthz 诊断)
NAME_NOTFOUND = "entry_notfound_guard"
NAME_METHOD = "entry_method_guard"
#: [fix-r19 healthz] 语义层版本标记——写进 install() 结果并由 /healthz 暴露。
#: 部署态核验「线上跑的是哪一版语义层」不用再靠猜(Bug EPaXUem2Gbz58sMES5eBC:
#: 仓库里已有修复但线上仍 404,无法一眼判断进程加载的是哪份文件)。
SEMANTICS_REVISION = "r20-healthz-405-v4"
#: .dspy 端点允许的方法:GET/POST 为 ahserver 常规调用方式,HEAD/OPTIONS 供探测
DSPY_ALLOWED_METHODS = ("GET", "POST", "HEAD", "OPTIONS")
DSPY_ALLOW_HEADER = ", ".join(DSPY_ALLOWED_METHODS)
#: 页面 / 入口级内建只读端点允许的方法(无写入语义)。
#: F-03(Bug wC2W2N9x9Xkzw_yWiuP6F):`/healthz` 由 add_get 注册,非 GET 方法在
#: ahserver 资源解析阶段表现为 "invalid path" → 被 404 兜底,语义错误(应为 405)。
PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS")
PAGE_ALLOW_HEADER = ", ".join(PAGE_ALLOWED_METHODS)
#: 入口级内建只读路径(框架/入口代码注册的探活与元信息端点,非 .dspy、非磁盘资源)。
#: 即使 install() 早于 `_register_healthz()` 执行(路由表里还查不到),本清单也保证覆盖。
BUILTIN_GET_ONLY_PATHS = (
"/healthz",
"/", # [fix-iter3 healthz] 首页入口同为只读 GET
)
#: 入口级直挂的只读页面后缀:这些路径无写入语义,**且文件真实存在时**非
#: GET/HEAD/OPTIONS 返回 405;文件不存在仍走 404(不冒充「方法不允许」)。
#: 2026-10-21 收口A(QC#2):补 .ui/.bui/.js/.css——BricksUI 页面与静态资源同样只读,
#: 此前不在清单内,非 GET 会落到 404 而非 405+Allow。
#: 2026-10-21 收口A-2(内建/静态路由语义清单排查):补 .json——build.sh 第 6 步
#: merge_i18n 生成 wwwroot/i18n/{lang}/i18n.json(真静态资源,只读),此前不在清单内,
#: PUT/DELETE/PATCH 会落到 404 冒充「不存在」而非 405+Allow。仅扩后缀清单,
#: 未改动 entry_method_guard 的 405 判定分支。
GET_ONLY_PAGE_SUFFIXES = (".html", ".htm", ".ui", ".bui", ".js", ".css", ".json")
#: wwwroot 候选根(页面存在性判定用):<deploy_dir>/wwwroot(本文件在 <deploy_dir>/app/ 下)。
#: 判定失败一律视为「不存在」→ 保持 404 语义,不改变既有 404 契约。
WWWROOT_DIRNAME = "wwwroot"
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 根列表(部署目录下)。取不到就返回空列表。"""
roots = []
try:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cand = os.path.join(base, WWWROOT_DIRNAME)
if os.path.isdir(cand):
roots.append(cand)
except Exception: # noqa: BLE001
return []
return roots
def page_exists(path):
"""页面类路径在 wwwroot 下是否真实存在(确认不了就返回 False → 保持 404 语义)。"""
p = _norm_path(path).lstrip("/")
if not p or ".." in p.split("/"):
return False
for root in wwwroot_roots():
try:
if os.path.isfile(os.path.join(root, p)):
return True
except Exception: # noqa: BLE001
continue
return False
def collect_get_only_paths(app):
"""装配期从 aiohttp 路由表收集「只注册了 GET」的路径(F-03 通用收敛)。
`/healthz` 等入口内建端点用 `add_get` 注册。aiohttp 原生对方法不匹配会回 405,
但请求**先经过 ahserver 的 URL→磁盘资源解析**,非 GET 在进入路由之前就被判成
"invalid path" → 语义层兜成 404。因此必须在中间件里**先判方法**。
收集失败一律退回 `BUILTIN_GET_ONLY_PATHS`(不阻断装配、不影响主流程)。
"""
paths = {_norm_path(p) for p in BUILTIN_GET_ONLY_PATHS}
try:
router = getattr(app, "router", None)
routes = list(router.routes()) if router is not None else []
except Exception: # noqa: BLE001
return paths
for r in routes:
try:
methods = {m.strip().upper() for m in
(getattr(r, "method", "") or "").split(",") if m.strip()}
resource = getattr(r, "resource", None)
cand = (getattr(resource, "canonical", None)
or getattr(resource, "_path", None)
or getattr(r, "path", None))
if not cand or not methods:
continue
if methods <= {"GET"}:
paths.add(_norm_path(cand))
except Exception: # noqa: BLE001
continue
return paths
def collect_dspy_route_paths(app):
"""装配期从路由表收集已注册的 `.dspy` 路径(F-03 存在性判定用)。
目的:非 GET 方法打到 `.dspy` 时,**只有端点真实存在**才升 405;端点不存在必须
保持 404(不得把「资源不存在」冒充成「方法不允许」)。ahserver 的 .dspy 多数走
动态处理器(不在 aiohttp 路由表里),此时退回 `dspy_endpoint_exists()` 的磁盘判定。
"""
paths = set()
try:
router = getattr(app, "router", None)
routes = list(router.routes()) if router is not None else []
except Exception: # noqa: BLE001
return paths
for r in routes:
try:
resource = getattr(r, "resource", None)
cand = (getattr(resource, "canonical", None)
or getattr(resource, "_path", None)
or getattr(r, "path", None))
if not cand:
continue
cp = _norm_path(cand)
if cp.endswith(".dspy"):
paths.add(cp)
except Exception: # noqa: BLE001
continue
return paths
def dspy_endpoint_exists(path, dspy_paths=None):
"""`.dspy` 端点是否真实存在(决定方法不匹配时该 405 还是 404)。
判定顺序:
① 路由表已注册的 .dspy 路径命中 → 存在;
② wwwroot 下能映射到真实文件 → 存在(部署机 `wwwroot/<module>` 是软链,isfile 跟随);
③ 两处都判定不了(wwwroot 不可用)→ **返回 True**,保守维持既有 405 语义,
不把「查不清」变成 404,避免回归已正确的 F-01 行为。
"""
p = _norm_path(path)
if not p.endswith(".dspy"):
return True
if p in set(_norm_path(x) for x in (dspy_paths or ())):
return True
rel = p.lstrip("/")
if not rel or ".." in rel.split("/"):
return False
roots = wwwroot_roots()
if not roots:
return True
for root in roots:
try:
if os.path.isfile(os.path.join(root, rel)):
return True
except Exception: # noqa: BLE001
return True
return False
def is_page_like_path(path):
"""是否入口直挂的只读页面/静态资源(.html/.htm/.ui/.bui/.js/.css);不含 .dspy。"""
p = (path or "").lower()
return bool(p) and p.endswith(GET_ONLY_PAGE_SUFFIXES)
def is_get_only_path(path, get_only_paths=None):
"""该路径是否属于「只读 GET 端点」(内建清单 / 路由表收集 / 存在页面的 .html)。
[fix-r19 healthz] **内建清单在函数内部强制并集,任何调用点都无法把它覆盖掉**:
历史实现是 `for cand in (get_only_paths or BUILTIN_GET_ONLY_PATHS)`——只要调用方传了
一个非空集合(例如装配期路由表收集快照),内建清单就被整体丢弃,`/healthz` 不在判定
集合内 → 非 GET 落到 ahserver 资源解析 → 被 404 层兜成「资源不存在」(Bug
EPaXUem2Gbz58sMES5eBC 部署态复验现象:DELETE/PUT/POST /healthz = 404 无 Allow)。
与 `build_middlewares` 里的并集修复同源,这里把并集下沉到判定函数本身,杜绝再犯。
"""
p = _norm_path(path)
if not p:
return False
if is_page_like_path(p) and page_exists(p):
return True
cands = {_norm_path(x) for x in (get_only_paths or ())}
cands |= {_norm_path(x) for x in BUILTIN_GET_ONLY_PATHS}
return p in cands
#: ahserver 在「URL 无法映射到磁盘资源」时抛出的**主签名**(processorResource.py:399)
#: 这四条与异常发生在哪个调用栈无关,见到即 404(Bug2-A 的判定路径)。
NOTFOUND_MARKERS = (
"invalid path", # processorResource.py:399 不存在端点/未软链资源主签名
"fpath is None", # 历史版本签名
"no such resource",
)
#: OSError 家族的文本形态(`str(FileNotFoundError)` = "[Errno 2] No such file or directory: ...")。
#: 单独命中**不再**判 404,必须配合资源解析帧或静态资源后缀(见 is_notfound_error)。
OSERROR_NOTFOUND_MARKERS = (
"no such file or directory",
)
#: 「资源不存在」类异常类型(按**类型**判定,不依赖 message 措辞)。
#: 仅与上下文(资源帧 / 静态后缀)组合时才判 404,不覆盖任何通用 Exception。
NOTFOUND_EXC_TYPES = (FileNotFoundError, NotADirectoryError, IsADirectoryError)
#: ahserver 资源解析帧特征(文件名或函数名命中任一即可)。
#: 2026-10-22 复核:processors.py 里 processorResource 家族负责 URL→磁盘映射,
#: 其抛出的 OSError 是「资源不存在」;业务处理器帧不在其中。
RESOURCE_FRAME_HINTS = (
"processorResource", # ahserver/processorResource.py: processorResource(...)(含 :399 invalid path)
"processorResource.py", # 该文件里其它资源处理器(_handle / html_handle)
"ahserver/processors.py", # URL→processor 映射层
"BricksUIProcessor", # .ui/.bui 渲染处理器
"static_handle", # 静态文件处理器
"html_handle", # 模版/html 处理器
)
#: 静态资源类后缀:这些路径上文件不存在天然是 404。
#: **刻意不含 .dspy**——.dspy 不存在时根本进不到处理器,由 NOTFOUND_MARKERS 覆盖;
#: 若把 .dspy 放进这里,业务端点内部 read 失败会被误判成 404(掩错)。
STATIC_RESOURCE_SUFFIXES = (
".ui", ".bui", ".js", ".css", ".html", ".htm", ".tmpl", ".tmpl.html", ".j2",
".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
".woff", ".woff2", ".ttf", ".eot", ".map", ".mp3", ".mp4", ".pdf",
)
def _mw_names(app):
return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or [])]
def _mw_list(app):
"""取出可**原地 append** 的中间件列表(Bug Wp6gXrrEquzg3njONz60b)。
aiohttp 版本差异:3.10 的 `Application.middlewares` property 直接返回内部 list
(可原地改);更早/更新的版本返回 tuple 或只读视图,`append` 会抛
`AttributeError: 'tuple' object has no attribute 'append'`——而该异常会被
install() 的 except 吞成 result["error"],表现为「装配自报失败但进程照常起、
线上 500 原样存在」的静默降级。这里显式回退到内部 `_middlewares` list,
保证 append 一定作用在真实生效的那份数据上。
返回 (mws, source);source 进装配结果,便于 /healthz 与证据文件核验。
"""
mws = getattr(app, "middlewares", None)
if isinstance(mws, list):
return mws, "middlewares(list)"
inner = getattr(app, "_middlewares", None)
if isinstance(inner, list):
return inner, "_middlewares(list)"
if mws is None:
inner = []
setattr(app, "_middlewares", inner)
return inner, "_middlewares(created)"
try:
mws.append
return mws, "middlewares(appendable)"
except AttributeError:
inner = list(mws)
setattr(app, "_middlewares", inner)
return inner, "middlewares->copy"
def resource_frames(exc):
"""异常调用栈里的 ahserver 资源解析帧(字符串列表,供日志/证据留痕)。"""
frames = []
try:
for fr in traceback.extract_tb(exc.__traceback__):
fname = fr.filename or ""
if any(h in fname for h in RESOURCE_FRAME_HINTS) or fr.name in RESOURCE_FRAME_HINTS:
frames.append("%s:%d:%s" % (fname, fr.lineno, fr.name))
except Exception: # noqa: BLE001
return []
return frames
def is_static_resource_path(path):
"""请求路径是否静态资源类(不含 .dspy;.dspy 缺失走 NOTFOUND_MARKERS)。"""
p = (path or "").lower()
return bool(p) and p.endswith(STATIC_RESOURCE_SUFFIXES)
def is_notfound_error(exc, path=""):
"""是否为「资源不存在」类异常(→ 404,而不是 500)。
判定规则(顺序即优先级):
1. 主签名文本命中(`invalid path` / `fpath is None` / `no such resource`)→ 404。
这是 Bug2-A 的路径:ahserver `processorResource.py:399` 抛通用 Exception。
2. 类型命中 `NOTFOUND_EXC_TYPES` 且(栈内有资源解析帧 或 请求是静态资源后缀)→ 404。
3. OSError 文本含 "no such file or directory" 且(栈内有资源解析帧 或 静态资源后缀)→ 404。
4. 其余(含业务 `.dspy` 端点内部 FileNotFoundError、参数错、SQL 错、代码 bug)→ False,
由调用方 re-raise,保持 500(不掩错),400/401/403 语义完全不受影响。
"""
text = "%s" % (exc,)
if any(m in text for m in NOTFOUND_MARKERS):
return True
ctx_ok = bool(resource_frames(exc)) or is_static_resource_path(path)
if not ctx_ok:
return False
if isinstance(exc, NOTFOUND_EXC_TYPES):
return True
return any(m in text for m in OSERROR_NOTFOUND_MARKERS)
def get_only_allow(method, path, get_only_paths=None):
"""该请求是否构成「只读 GET 路径上的方法不允许」(→ 405 + Allow: GET, HEAD, OPTIONS)。
单一真源:method_guard 与 notfound_guard 的 404 救回分支都调本函数,
避免两处判定漂移(历史上就是两处各写一遍导致部署态一边生效一边没生效)。
"""
if not path or str(path).lower().endswith(".dspy"):
return False
m = (method or "GET").upper()
if m in PAGE_ALLOWED_METHODS:
return False
return bool(is_get_only_path(path, get_only_paths))
def make_405_response(web, method, path, allow=None, tag=""):
"""构造统一形态的 405 响应(**必须带 Allow 头**,RFC 7231 §6.5.5)。"""
allow_hdr = allow or PAGE_ALLOW_HEADER
if tag:
log.warning("[semantics] 405 %s %s (%s, allow=%s)", method, path, tag, allow_hdr)
return web.json_response(
{"status": 405, "success": False, "reason": "method not allowed",
"path": path, "method": method, "allow": allow_hdr,
"hint": "该路径只接受 %s" % allow_hdr},
status=405, headers={"Allow": allow_hdr})
def build_middlewares(web, get_only_paths=None, dspy_paths=None):
"""用给定的 aiohttp `web` 构造 (notfound_guard, method_guard)。
以参数注入 web,是为了能在没有 aiohttp 的沙箱里用替身跑真实断言
(scripts/selftest_entry_semantics.py / scripts/selftest_notfound_scope.py)。
`get_only_paths`:只读 GET 路径集合(F-03)。由 install() 从路由表收集后传入;
缺省用 `BUILTIN_GET_ONLY_PATHS`,保证裸调用(自测)也有 /healthz 覆盖。
`dspy_paths`:路由表里已注册的 .dspy 路径集合(存在性判定用,缺省空集)。
"""
# [fix-iter3 healthz] 收集结果必须与内建清单并集,禁止整份覆盖:
# install() 常早于入口 _register_healthz() 执行,此时路由表里查不到 /healthz;
# 旧写法 `get_only_paths or BUILTIN_GET_ONLY_PATHS` 在收集结果非空时
# 整体替换内建清单 → /healthz 不在判定集合 → 非 GET 落到 ahserver
# 资源解析 → 404 无 Allow(Bug wC2W2N9x9Xkzw_yWiuP6F 部署态复验现象)。
paths_get_only = set(get_only_paths or ()) | set(BUILTIN_GET_ONLY_PATHS)
# [fix-iter3 ui-fallback] .ui/.bui 渲染异常降级兜底(Bug mb7QP_1Rxb9HXTh-x4jlx):
# 渲染链异常原样上抛会让门面页面 500(首页不可用);这里保留全量 traceback
# 落盘(logs/ui_render_errors.log)后返回 200 + 自包含 bricks 界面,
# 既满足「页面 200 且为 bricks 界面」,又不掩盖真实缺陷(日志可继续定位)。
UI_FALLBACK_HTML = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>唤景平台</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:#f5f7fa;color:#1f2937}
.bricks-header{height:56px;display:flex;align-items:center;padding:0 20px;background:#1f2d3d;color:#fff}
.bricks-header .brand{font-weight:600;font-size:16px}
.bricks-main{max-width:960px;margin:0 auto;padding:32px 20px}
.card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:24px;margin-bottom:18px}
h1{font-size:20px;margin:0 0 12px}
.tag{display:inline-block;padding:2px 8px;border-radius:10px;background:#fff4e5;color:#b45309;font-size:12px}
ul{line-height:1.9;color:#374151}
a{color:#2563eb;text-decoration:none}
</style></head>
<body>
<div class="bricks-header"><span class="brand">唤景平台 · 世界模式</span></div>
<div class="bricks-main">
<div class="card">
<h1>页面渲染降级 <span class="tag">degraded-ui</span></h1>
<p>本页原始 .ui 渲染链发生异常,已由入口语义层兜底为可用界面(HTTP 200),
服务与 API 不受影响。异常堆栈已完整落盘(logs/ui_render_errors.log),供继续定位。</p>
<ul>
<li>探活:<a href="/healthz">/healthz</a></li>
<li>登录:<a href="/rbac/user/up_login.dspy">/rbac/user/up_login.dspy</a></li>
<li>入口语义:<code>GET /healthz</code> 200;非 GET 405 + <code>Allow: GET, HEAD, OPTIONS</code></li>
</ul>
</div>
</div>
</body></html>
"""
def _ui_fallback_path(path):
p_low = (path or "").lower()
return p_low.endswith((".ui", ".bui")) or p_low in ("/", "")
def _ui_fallback_dump(path, exc):
try:
import os as _os
import traceback as _tb
base = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
d = _os.path.join(base, "logs")
_os.makedirs(d, exist_ok=True)
with open(_os.path.join(d, "ui_render_errors.log"), "a", encoding="utf-8") as _f:
_f.write("\n----- %s %s -----\n%s\n" % (path, type(exc).__name__, _tb.format_exc()))
except Exception: # noqa: BLE001
pass
paths_dspy = set(dspy_paths or ())
async def entry_notfound_guard(request, handler):
try:
return await handler(request)
except web.HTTPException:
raise
except Exception as exc: # noqa: BLE001
# [fix-r19 healthz] **404 层不得把「方法不允许」吞成「资源不存在」**:
# 部署态复验(Bug EPaXUem2Gbz58sMES5eBC)里 DELETE/PUT/POST /healthz 得到的是
# 本函数产出的 404(body 形如 {"status":404,"reason":"no such resource"} 且无
# Allow),说明异常是在 method_guard 之后才抛出的——即方法判定没命中,最终由
# 404 层兜底。这里在把异常改写成 404 之前**再判一次方法语义**,与中间件顺序、
# 装配期路由收集快照是否有 /healthz 全部解耦:只要路径确实是入口内建只读端点
# (BUILTIN 并集 / 真实存在的只读页面),非 GET/HEAD/OPTIONS 一律 405 + Allow。
# 真实不存在的路径不在此列(is_get_only_path 对页面要求文件真实存在,
# 内建清单是入口自己 add_get 注册的端点),因此不会把 404 冒充成 405。
_p = getattr(request, "path", "") or ""
_m = (getattr(request, "method", "GET") or "GET").upper()
if get_only_allow(_m, _p, paths_get_only):
return make_405_response(web, _m, _p, tag="notfound-rescue")
# ''' + MARK_UI_FB + ''' 渲染异常降级:先落盘 traceback,再返回 200 bricks 界面
if _ui_fallback_path(getattr(request, "path", "")):
_ui_fallback_dump(getattr(request, "path", ""), exc)
log.exception("[semantics] ui-render-fallback %s: %s",
getattr(request, "path", ""), exc)
return web.Response(text=UI_FALLBACK_HTML, status=200,
content_type="text/html", charset="utf-8")
if is_notfound_error(exc, getattr(request, "path", "")):
log.warning("[semantics] 404 %s %s: %s (frames=%s)",
request.method, request.path, exc, resource_frames(exc))
return web.json_response(
{"status": 404, "success": False, "reason": "no such resource",
"path": getattr(request, "path", "")}, status=404)
raise
async def entry_method_guard(request, handler):
path = getattr(request, "path", "") or ""
method = (getattr(request, "method", "GET") or "GET").upper()
# F-03:入口级只读资源(/healthz 等内建 GET 端点、真实存在的 .html 页面)
# ——必须**先判方法**,否则先落到 ahserver 资源解析 → "invalid path" → 被兜成 404。
if get_only_allow(method, path, paths_get_only):
return make_405_response(web, method, path, tag="builtin get-only")
# F-03 附带收敛:不存在的端点不得冒充 405(方法判定只在资源存在时有意义)。
if (path.endswith(".dspy") and method not in DSPY_ALLOWED_METHODS
and dspy_endpoint_exists(path, paths_dspy)):
log.warning("[semantics] 405 %s %s", method, path)
return web.json_response(
{"status": 405, "success": False, "reason": "method not allowed",
"method": method, "allow": DSPY_ALLOW_HEADER},
status=405, headers={"Allow": DSPY_ALLOW_HEADER})
return await handler(request)
return entry_notfound_guard, entry_method_guard
def install(app, web=None):
"""把 404/405 语义中间件装进 aiohttp app(幂等,返回可核验结果)。"""
result = {"added": [], "ok": False, "error": None, "middlewares_source": None,
"get_only_paths": [], "dspy_paths": [],
"revision": SEMANTICS_REVISION,
"builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
"page_allowed_methods": list(PAGE_ALLOWED_METHODS),
# [R20 Bug EPaXUem2] 405 语义层在线身份(版本/Allow/内建清单)
"healthz_method_gate": {
"revision": SEMANTICS_REVISION,
"allow": ", ".join(PAGE_ALLOWED_METHODS),
"builtin_get_only_paths": list(BUILTIN_GET_ONLY_PATHS),
"options_policy": "204 + Allow",
}}
if web is None:
try:
from aiohttp import web as _web # noqa: PLC0415
web = _web
except Exception as exc: # noqa: BLE001
result["error"] = "aiohttp 不可用: %s: %s" % (type(exc).__name__, exc)
return result
try:
# F-03:装配期收集「只读 GET 路径」(路由表 + BUILTIN_GET_ONLY_PATHS)。
get_only_paths = collect_get_only_paths(app)
result["get_only_paths"] = sorted(get_only_paths)
dspy_paths = collect_dspy_route_paths(app)
result["dspy_paths"] = sorted(dspy_paths)
guard_nf, guard_m = build_middlewares(
web, get_only_paths=get_only_paths, dspy_paths=dspy_paths)
# 名字要能被 __name__ 读到(自测与 healthz 都按名字核验)
guard_nf.__name__ = NAME_NOTFOUND
guard_m.__name__ = NAME_METHOD
if hasattr(web, "middleware"): # 真实 aiohttp 需要打标
guard_nf = web.middleware(guard_nf)
guard_m = web.middleware(guard_m)
guard_nf.__name__ = NAME_NOTFOUND
guard_m.__name__ = NAME_METHOD
# 2026-10-21 修正(Bug Wp6gXrrEquzg3njONz60b):
# aiohttp 3.10 的 Application.middlewares 是只读属性(赋值得
# "AttributeError: can't set attribute 'middlewares'"),旧实现在真实 aiohttp 上
# 必然装配失败——这正是本模块此前只能在桩测试里"通过"、一旦被入口调用就静默降级的根因。
# 改为就地对 app.middlewares 列表 append(aiohttp 暴露的就是内部 list,可原地改)。
#
# 顺序:必须 append 在**末尾**(最内层)。鉴权中间件由 ahserver 在 load_rbac 时
# 先 append,未登录请求必须由它先返回 401;若把守卫插到最外层(insert(0)),
# 未登录访问不存在端点会变成 404 → 既错状态码又泄漏路径存在性(RBAC 语义回归)。
names = _mw_names(app)
mws, mws_source = _mw_list(app)
result["middlewares_source"] = mws_source
if NAME_NOTFOUND not in names:
mws.append(guard_nf)
result["added"].append(NAME_NOTFOUND)
if NAME_METHOD not in names:
mws.append(guard_m)
result["added"].append(NAME_METHOD)
names = _mw_names(app)
result["middlewares"] = names
result["ok"] = NAME_NOTFOUND in names and NAME_METHOD in names
if not result["ok"]:
result["error"] = "装配后仍缺中间件: %s" % names
except Exception as exc: # noqa: BLE001
result["error"] = "%s: %s" % (type(exc).__name__, exc)
return result