#!/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 候选根(页面存在性判定用):/wwwroot(本文件在 /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/` 是软链,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 = """ 唤景平台
唤景平台 · 世界模式

页面渲染降级 degraded-ui

本页原始 .ui 渲染链发生异常,已由入口语义层兜底为可用界面(HTTP 200), 服务与 API 不受影响。异常堆栈已完整落盘(logs/ui_render_errors.log),供继续定位。

""" 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