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 中间件提供。
This commit is contained in:
ymq 2026-10-28 15:19:43 +08:00
parent aaba05a100
commit 702f27407b
26 changed files with 8404 additions and 75 deletions

View File

@ -0,0 +1,213 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""鉴权豁免清单**单一真源**(Bug uifdBIMdmixZcmJEdV33O / T4)。
问题
----
修复前,「哪些端点免鉴权」这件事在 5 个文件里各存了一份:
app/auth_route_gate.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
app/route_guard_middleware.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
app/auth_gate_strict.py PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES
app/entry_guard_r19.py 自己没有清单,委托 auth_gate_strict
app/notfound_middleware.py **完全没有清单**(内联兜底闸门任何 .dspy 都 401)
后果(R57b / R59 实测):登录端点 `/rbac/user/up_login.dspy` 在主线闸门上被豁免,
但在「语义组合链装配失败 → 内联兜底闸门」这条路径上**没有任何豁免**,一律返回
`401 {"error": "auth_unavailable"}`;而更早的版本还会把闸门自身异常统一降级成
`auth_unavailable`(R60 已改为 500 auth_gate_internal_error,但豁免缺失仍在)。
于是「登录端点自己 401」被固化,cookie jar 恒空,全部带登录态复测无法执行。
本模块的做法
------------
* **清单只有这一份**:`AUTH_EXEMPT_DSPY_PATHS`(精确路径,禁通配)+
`AUTH_EXEMPT_PREFIXES`(按**路径段边界**匹配的前缀,禁通配)。
* 所有闸门只调用 `is_exempt_path()`,不再各自维护副本。
* 豁免判定必须是闸门里**最靠前**的判定:在「资源存在性」「会话校验」之前。
这样无论 wwwroot 软链是否挂上、会话组件是否可用、闸门内部是否抛异常,
登录端点都不会被判成 404 / 401(真不存在时由框架自己给 404,语义更准)。
豁免判据(逐条写明,评审可核对)
--------------------------------
1. 路径归一化(去 query/fragment、URL 解码、`\`→`/`、折叠重复斜杠、去尾斜杠、补前导斜杠)后
**整串精确等于** `AUTH_EXEMPT_DSPY_PATHS` 中的某一项 → 豁免。
**不做前缀/通配/子串匹配**:`/rbac/*` 这种写法会连带放行 `/rbac/user/*` 全部端点
(含用户管理写接口),属于越权放行,明令禁止(QC 硬门禁 #6)。
2. 命中 `AUTH_EXEMPT_PREFIXES` 之一,且**按路径段边界**命中(`/static/` 命中 `/static/a`、
`/static` 本身,但**不**命中 `/staticx`)→ 豁免。这些是静态资产与健康探针,无业务数据。
3. 其余一律不豁免(默认拒绝,default-deny)。
零硬编码:本文件不含 host / port / 域名 / 凭据;清单是代码常量,逐条精确。
"""
import os
import logging
log = logging.getLogger("yuanjing.auth_exempt")
# --------------------------------------------------------------------------- #
# 豁免清单(唯一真源,逐条精确,禁止通配)
# --------------------------------------------------------------------------- #
#: 免鉴权的动态端点(**精确路径**,小写比较)。
#: 依据:登录/登出/注册/会话自检这几件事发生在「还没有会话」的时刻,
#: 若要求会话则构成"先登录才能登录"的自指死循环。
AUTH_EXEMPT_DSPY_PATHS = frozenset((
"/rbac/user/up_login.dspy", # 主登录端点(前端表单 / API 均打这里)
"/rbac/user/login.dspy", # 兼容旧前端占位
"/rbac/user/up_logout.dspy", # 登出(销毁会话,本身不需要有效会话)
"/rbac/user/logout.dspy", # 兼容别名
"/rbac/user/register.dspy", # 注册
"/rbac/user/check_login.dspy", # 会话自检(返回是否已登录)
"/rbac/user/get_login_user.dspy", # 当前登录用户(未登录返回空,不 401)
))
#: 免鉴权前缀(静态资产 / 健康探针;按**路径段边界**匹配)。
AUTH_EXEMPT_PREFIXES = (
"/healthz",
"/health",
"/robots.txt",
"/favicon.ico",
"/i18n/",
"/static/",
"/files/",
)
#: 【R62 / Bug 匿名读全站 P0】受保护 API 前缀(**显式列举,禁通配**)。
#: 需求口径:修复必须覆盖 /scense、/runtime、/scene、/world、/entity 五个前缀下的
#: 全部 api 端点,而不是只补被测的 3 条。判据见 `is_protected_api_path()`:
#: 命中这五个前缀且「路径含 /api/ 段」或「以 .dspy 结尾」→ 必须带可验证会话。
#: 注意:这是**受保护**清单(扩大闸门覆盖面),不是豁免清单;豁免仍然只有上面
#: 那 7 条精确登录类端点 + 7 条静态/健康探针前缀,未因本次修复新增任何免鉴权项。
PROTECTED_API_PREFIXES = (
"/scense",
"/runtime",
"/scene",
"/world",
"/entity",
)
#: 判定口径标识(写进 /healthz 诊断与自测输出,便于部署态核对生效版本)。
EXEMPT_REVISION = "r62-api-prefix-coverage-v1"
# --------------------------------------------------------------------------- #
# 路径归一化与匹配
# --------------------------------------------------------------------------- #
def normalize_path(path):
r"""归一化请求路径,封堵写法绕过。
[fix rvtGi1mlUsTwQrXnybZj6 / R61] 本文档串含反斜杠样例 `\up_login.dspy`,
原为非 raw 三引号 → `\u` 被当作 unicode 转义前缀(`\up` 非法)→ 整个模块
**SyntaxError 无法 import**,进而 `route_guard_middleware` 的 auth_required 层
装载失败、鉴权层整层缺席(实测 SyntaxError: truncated \uXXXX escape, line 90)。
改为 raw docstring 后模块可正常导入;语义不变。
`/rbac/user/up_login.dspy/`、`//rbac/user/up_login.dspy`、
`/rbac/user/%75p_login.dspy`、`/rbac/user\up_login.dspy`、
`/rbac/user/up_login.dspy?x=1` 必须与规范路径得到**同一判定**。
"""
p = str(path or "")
try:
from urllib.parse import unquote
p = unquote(p)
except Exception: # noqa: BLE001
pass
p = p.split("?", 1)[0].split("#", 1)[0]
p = p.replace("\\", "/")
p = "/" + "/".join([seg for seg in p.split("/") if seg])
if p != "/":
p = p.rstrip("/")
return p
def _prefix_hit(low_path, prefix):
"""按**路径段边界**判断前缀命中(禁止 `/static` 命中 `/staticx`)。"""
pre = str(prefix or "").lower()
if not pre:
return False
if pre.endswith("/"):
base = pre.rstrip("/")
return low_path == base or low_path.startswith(pre)
return low_path == pre or low_path.startswith(pre + "/")
def is_exempt_path(path):
"""该请求路径是否免鉴权(唯一判定入口,所有闸门只调这一个函数)。"""
try:
p = normalize_path(path)
except Exception: # noqa: BLE001
# 归一化都失败 → 不放行(fail-closed),但记日志便于定位。
log.error("[auth_exempt] 路径归一化失败,按不豁免处理: %r", path)
return False
low = p.lower()
if low in AUTH_EXEMPT_DSPY_PATHS:
return True
for prefix in AUTH_EXEMPT_PREFIXES:
if _prefix_hit(low, prefix):
return True
return False
def exempt_reason(path):
"""返回豁免依据字符串(未豁免返回空串),供自测/诊断输出。"""
p = normalize_path(path)
low = p.lower()
if low in AUTH_EXEMPT_DSPY_PATHS:
return "dspy_exact:" + low
for prefix in AUTH_EXEMPT_PREFIXES:
if _prefix_hit(low, prefix):
return "prefix:" + prefix
return ""
def assert_no_wildcard(paths=None):
"""硬门禁:豁免清单不得含通配符(`*` / `%`)。命中即抛 ValueError。"""
items = AUTH_EXEMPT_DSPY_PATHS if paths is None else paths
bad = [p for p in (items or ()) if ("*" in str(p) or "%" in str(p))]
if bad:
raise ValueError("鉴权豁免清单禁止通配符: %s" % (bad,))
return True
def is_protected_api_path(path):
"""该路径是否属于「必须鉴权的业务 API 覆盖面」(R62 新增判据)。
判据(默认拒绝,与豁免清单解耦):
1. 归一化 + 小写;
2. 必须**按路径段边界**命中 `PROTECTED_API_PREFIXES` 之一(`/scene` 不命中 `/scense`,
也不命中 `/scenex`);
3. 且该路径含 `/api/` 段,或以 `.dspy` 结尾(动态端点)。
命中后再由调用方判断是否豁免(`is_exempt_path()`)——豁免优先级更高,
但豁免清单里没有、也不允许有这五个前缀下的任何业务端点。
"""
try:
low = normalize_path(path).lower()
except Exception: # noqa: BLE001
return True # 判不了 → 按受保护处理
if not any(_prefix_hit(low, "/" + str(pfx).strip("/")) for pfx in PROTECTED_API_PREFIXES):
return False
return ("/api/" in low or low.endswith(".dspy")
or ("/api" in low and low.endswith(".api")))
def manifest():
"""豁免清单快照(写进 /healthz 诊断字段与自测输出)。"""
return {
"revision": EXEMPT_REVISION,
"dspy_paths": sorted(AUTH_EXEMPT_DSPY_PATHS),
"prefixes": list(AUTH_EXEMPT_PREFIXES),
"protected_api_prefixes": list(PROTECTED_API_PREFIXES),
"wildcard_free": assert_no_wildcard(),
}
if __name__ == "__main__": # pragma: no cover
import json as _json
log.setLevel(logging.INFO)
print(_json.dumps(manifest(), ensure_ascii=False, indent=2))
for _p in ("/rbac/user/up_login.dspy", "/rbac/user/up_login.dspy/",
"/rbac/user/%75p_login.dspy", "/rbac/user/up_login.dspy?a=1",
"/rbac/user/list_users.dspy", "/healthz", "/staticx/a.js"):
print("%-42s exempt=%-5s reason=%s" % (_p, is_exempt_path(_p), exempt_reason(_p) or "-"))

View File

@ -0,0 +1,134 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""向后兼容别名面:`from app import auth_gate` 的外部调用方不应 ImportError。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件此前把豁免表达成
`is_exempt_path(p) = not is_protected(p)` —— 这是**第二份判定入口**:
`is_protected()` 是「按资源存在性 + 扩展名」推导的受保护集合,与豁免清单
(`app/auth_exempt.py`)的判据不同源。两份口径一旦分叉(例如某路径磁盘不可见
但语义上必须免鉴权),调用方拿到的豁免结论就与主线闸门不一致,
正是 Bug uifdBIMdmixZcmJEdV33O(登录端点自己 401)的原始形态。
现在的约定(唯一真源 = `app/auth_exempt.py`):
* `is_exempt_path()` / `is_anonymous_path()` 都**纯转发** `auth_exempt.is_exempt_path()`,
不再用 `not is_protected(...)` 二次表达豁免;
* `normalize_request_path()` / `resource_exists()` / `is_protected()` 仍委托
`app/auth_route_gate.py`(资源面语义,与豁免判据无关,保留原样);
* 本文件不维护任何名单字面量,也不新增策略。
pbls 与 yuanjing 顶层包名同为 app,跨应用自测脚本
apps/pbls/scripts/selftest_auth_gate_r39.py 引用 normalize_request_path /
is_anonymous_path / is_exempt_path / resource_exists 四个符号,故保留全部四个。
"""
import logging
log = logging.getLogger("scense.auth_gate")
def _route_gate():
"""取资源面实现模块 app/auth_route_gate.py(保持历史委托关系)。"""
try:
from . import auth_route_gate as _m
return _m
except Exception: # noqa: BLE001
try:
import auth_route_gate as _m
return _m
except Exception: # noqa: BLE001
return None
def _auth_exempt():
"""取鉴权豁免**单一真源** app/auth_exempt.py(部署态 sys.path 可能不含应用根)。"""
try:
from . import auth_exempt as _m
return _m
except Exception: # noqa: BLE001
try:
from app import auth_exempt as _m # type: ignore
return _m
except Exception: # noqa: BLE001
pass
try:
import auth_exempt as _m
return _m
except Exception: # noqa: BLE001
pass
try:
import importlib.util
import os
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "auth_exempt.py")
spec = importlib.util.spec_from_file_location("yj_auth_exempt_gate", p)
_m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(_m)
return _m
except Exception as exc: # noqa: BLE001
log.error("[auth_gate] 豁免清单单一真源加载失败: %s", exc)
return None
def normalize_request_path(path):
m = _route_gate()
if m is None:
return path
return m.normalize_request_path(path)
def resource_exists(url_path):
m = _route_gate()
if m is None:
return False
return bool(m.resource_exists(url_path))
def is_protected(path):
"""资源面受保护判定(**不是**豁免判据;豁免一律走 is_exempt_path)。"""
m = _route_gate()
if m is None:
return True
try:
return bool(m.is_protected(path))
except Exception as exc: # noqa: BLE001
log.warning("[auth_gate] is_protected 失败,按受保护处理: %s", exc)
return True
def is_exempt_path(path):
"""是否免鉴权——**纯转发**单一真源 `auth_exempt.is_exempt_path()`。
禁止在此用 `not is_protected(...)` 表达豁免(那会形成第二份判定入口,
Bug uifdBIMdmixZcmJEdV33O 的原始形态)。单一真源不可用时 fail-closed(False)。
"""
m = _auth_exempt()
if m is None:
return False
try:
return bool(m.is_exempt_path(path))
except Exception as exc: # noqa: BLE001
log.error("[auth_gate] 豁免判定异常(按不豁免处理): %s", exc)
return False
def is_anonymous_path(path):
"""历史符号:匿名可到达 == 免鉴权。与 `is_exempt_path()` 同源同义(纯转发)。"""
return is_exempt_path(path)
def manifest():
"""豁免清单快照——同样纯转发单一真源,便于调用方做一致性断言。"""
m = _auth_exempt()
if m is None:
return {"revision": "unavailable", "dspy_paths": [], "prefixes": [],
"wildcard_free": False}
return m.manifest()
def main(): # pragma: no cover
import json as _json
print(_json.dumps(manifest(), ensure_ascii=False, indent=2))
if __name__ == "__main__": # pragma: no cover
main()

View File

@ -0,0 +1,410 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""yuanjing 应用级「最后一层」会话鉴权闸门(Bug vRZrx0Ff9tz / R18 复测,P1 匿名读越权)。
为什么还需要这一层(根因链,实测于部署态 2026-10-25)
------------------------------------------------------
`app/route_guard_middleware.py::auth_required_middleware` 的判定逻辑本身是对的
(非公开 `.dspy` + 无会话 → 401),但部署态实测:
NOCOOKIE GET /scense/api/list_scense.dspy -> 200 {"ok": true, "total": 0, "rows": []}
NOCOOKIE GET /scense/api/session_create.dspy -> 401 # 同层对照
NOCOOKIE GET /no/such/path.dspy -> 401 # 框架层回 401
即「门禁按端点覆盖不全」而非整链缺失。语义中间件链的装配点是
`app/notfound_middleware.py::compose_guard_chain()`,它**按模块名**导入
`route_guard_middleware` 等中间件;部署态 `python app/scense.py` 的 sys.path 不含应用根时
导入失败 → `compose_guard_chain` 抛 RuntimeError → 外层降级为「只做 404 异常映射」的
handler-only 链 → **鉴权层整层缺席**,业务处理器直接执行 → 200。
(同一降级路径也是 2026-10-25 上一轮 business_status 层缺席的同源故障。)
本模块是**自持、零 app 包依赖**的兜底闸门:
* 只依赖 stdlib + aiohttp + cryptography(Fernet),不 import `app.*`;
* 默认 deny:除显式公开路径(登录/登出/健康探针)与静态资源外,**任何 `.dspy` 都必须
带可验证的会话密文**,否则 401 并以统一错误信封返回(无业务字段);
* 自检/解密异常一律 fail-closed(401),绝不 fail-open;
* 由 `app/scense.py::_load_semantic_middlewares()` **按绝对文件路径**加载并注册,
与 `compose_guard_chain` 的装配成败解耦 —— 组合链修好后本层成为幂等冗余保险丝。
与 `route_guard_middleware` 的关系
----------------------------------
职责重叠是有意的(纵深防御)。责任重叠是有意的(纵深防御)。**公开路径集合不再由本文件持有**:
鉴权豁免清单的单一真源是 `app/auth_exempt.py`,本文件 `is_public_path()` 委托其
`is_exempt_path()`;本地 `PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 已降级为只读镜像,
仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等(防多清单漂移)。
只读镜像(仅供 `scripts/selftest_auth_gate_list_scense.py` 交叉断言相等)。
其余事实常量(密钥派生尾串、cookie 精确名、会话有效期)仍由该自测交叉断言,
改一处不改另一处会直接让自测 FAIL —— 以此防止实现漂移。
"""
import base64
import importlib.util
import json
import logging
import os
import time
import urllib.parse
from aiohttp import web
log = logging.getLogger("yuanjing.auth_gate_strict")
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)──
# 修复前本文件自持一份 PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES 副本,与
# auth_route_gate / route_guard_middleware / notfound_middleware 的另外几份副本各自演化
# (R57b / R59:主线豁免、兜底路径不豁免 → 登录端点仍 401 auth_unavailable)。
# [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 现在本文件**不再持有第二份清单**:
# 判定一律委托 `auth_exempt.is_exempt_path()`,下面两个名字只是它的**只读镜像**,
# 仅用于自测交叉断言与诊断输出(自测 `scripts/selftest_auth_gate_list_scense.py`
# 断言三者相等,防止再次各自演化)。
def _load_auth_exempt():
"""加载豁免清单单一真源(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
spec = importlib.util.spec_from_file_location(
"yj_auth_exempt_strict",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "auth_exempt.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
try:
EXEMPT = _load_auth_exempt()
except Exception as _load_exc: # noqa: BLE001
# 清单都加载不了 → 不做任何豁免(fail-closed),但必须显式告警,不静默。
log.error("[auth_strict] 豁免清单单一真源加载失败(所有 .dspy 均按需会话处理): %s",
_load_exc)
EXEMPT = None
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 下面两个 PUBLIC_* 名字仅是**派生镜像**
(自测交叉断言 + 诊断输出),判定一律走 `EXEMPT.is_exempt_path()`。
"""
if mod is None:
return {"revision": "unavailable", "dspy_paths": frozenset(),
"prefixes": (), "wildcard_free": False}
try:
mf = mod.manifest()
except Exception: # noqa: BLE001
return {
"revision": getattr(mod, "EXEMPT_REVISION", "unavailable"),
"dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()),
"prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()),
"wildcard_free": False,
}
return {
"revision": str(mf.get("revision") or "unavailable"),
"dspy_paths": frozenset(mf.get("dspy_paths") or ()),
"prefixes": tuple(mf.get("prefixes") or ()),
"wildcard_free": bool(mf.get("wildcard_free", False)),
}
EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT)
#: **交叉断言镜像**(非清单本体,由 manifest() 派生):仅为自测比对与诊断输出保留。
PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"]
#: **交叉断言镜像**(非清单本体,由 manifest() 派生):同上。
PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"]
#: 受闸门约束的后缀。`.dspy` 是唯一需要会话的动态端点类型;
#: `.ui/.js/.css/.html/.json` 属页面与静态资产,由框架会话层处理(避免锁死登录页)。
GATED_SUFFIX = ".dspy"
#: 会话 cookie 精确名(与 ahserver AuthAPI.setupAuth / route_guard 同源)。
SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION"
SESSION_COOKIE_MAX_AGE = 24 * 60 * 60
#: 复刻 ahserver AuthAPI.setupAuth 的 Fernet 密钥派生(port 前缀 + 固定尾串,共 32 字节)。
SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345"
#: 闸门总开关(仅排障用;生产禁开)。与 route_guard 同名环境变量。
GATE_ENV = "YJ_AUTH_GATE"
#: 应用根(本文件 → app/ → 应用根)。
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def gate_enabled():
"""总开关:默认开;`YJ_AUTH_GATE=off|0|false|no` 才关(排障专用)。"""
return (os.environ.get(GATE_ENV) or "on").strip().lower() not in (
"off", "0", "false", "no")
def normalize_path(path):
"""归一化请求路径,封堵写法绕过。
`/scense/api/list_scense.dspy/`、`/SCENSE/API/LIST_SCENSE.DSPY`、
`//scense/api/list_scense.dspy`、`/scense/api/%6cist_scense.dspy` 都必须
与 `/scense/api/list_scense.dspy` 得到同一判定,不能因为写法不同而绕过闸门。
"""
p = str(path or "")
p = urllib.parse.unquote(p)
p = p.split("?", 1)[0].split("#", 1)[0]
p = "/" + "/".join([seg for seg in p.split("/") if seg])
if p != "/":
p = p.rstrip("/")
return p.lower() if p.lower().endswith(GATED_SUFFIX) or p.startswith("/rbac/") else p
def is_public_path(path):
"""显式公开/豁免路径判定 —— **委托单一真源** `auth_exempt.is_exempt_path()`。
[fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#1] 本函数**不再**用本地常量判定:
历史实现在这里持有第二份清单,与 route_guard / auth_route_gate /
notfound 兜底闸门的副本各自演化(R57b / R59 多清单漂移同源缺陷)。
现在只有一份清单(auth_exempt),本层只是调用方。
:param path: 请求路径(可含 query / 尾斜杠 / 编码,内部归一化)
:return: bool —— 豁免 True;清单不可用或判定异常一律 False(fail-closed)
"""
if EXEMPT is None:
return False
try:
return bool(EXEMPT.is_exempt_path(normalize_path(path)))
except Exception as exc: # noqa: BLE001
log.error("[auth_strict] 豁免判定异常(按不豁免处理): %s", exc)
return False
def public_reason(path):
"""豁免依据(未豁免返回空串)—— 委托单一真源,供自测/诊断输出。"""
if EXEMPT is None:
return ""
try:
return EXEMPT.exempt_reason(normalize_path(path)) or ""
except Exception: # noqa: BLE001
return ""
def is_gated_path(path):
"""该路径是否需要会话凭据。
[R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为
**后缀 ∪ 业务 API 前缀**,判定顺序(顺序即语义):
1. 显式公开路径(登录/登出/健康探针,单一真源 `auth_exempt`)→ 不需会话;
2. `.dspy` 动态端点 → 需要会话(历史行为,保持);
3. 命中 `auth_exempt.PROTECTED_API_PREFIXES`(/scense /runtime /scene /world
/entity)且路径含 `/api/` 段或以 `.dspy` 结尾 → **同样需要会话**。
第 3 条是本次修复的关键:旧实现**只看后缀**(`if not endswith('.dspy'): return
False`),任何「无 `.dspy` 后缀的业务 API」(反向代理/重写规则下的
`/runtime/api/build_entity_tree`、`/scense/api/...` 变体写法)都会被判成
「非 `.dspy` → 放行」,于是匿名 200 返回业务数据。按需求 2,覆盖面必须按
**前缀**枚举而非按后缀,且不得只补被测的 3 条。
"""
p = normalize_path(path)
if is_public_path(p):
return False
if p.lower().endswith(GATED_SUFFIX):
return True
# 前缀覆盖面:判定委托单一真源,禁在本文件复制清单
try:
if EXEMPT is not None and EXEMPT.is_protected_api_path(p):
return True
except Exception as exc: # noqa: BLE001
# 判不了 → 按受保护处理(fail-closed,绝不放行业务数据)
log.error("[auth_strict] 业务 API 前缀判定异常(按受保护处理): %s", exc)
return True
return False
def _app_conf():
"""读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。"""
try:
with open(os.path.join(APP_ROOT, "conf", "config.json"), encoding="utf-8") as fp:
return json.load(fp) or {}
except Exception: # noqa: BLE001
return {}
def session_cookie_names():
"""允许承载会话的 cookie **精确名**(默认名 + conf 的 cookie_name)。"""
names = [SESSION_COOKIE_NAME_DEFAULT]
try:
cfg = _app_conf()
name = ((((cfg.get("website") or {}).get("session") or {})
.get("cookie_name")) or "").strip()
if name and name not in names:
names.append(name)
except Exception: # noqa: BLE001
pass
return tuple(names)
def session_key():
"""Fernet 密钥:port 前 32 字节 + 固定尾串补齐(复刻 ahserver 派生)。"""
port = str(((_app_conf().get("website") or {}).get("port")) or "")
b = port.encode("utf-8")[:32]
cnt = max(0, 32 - len(b))
return base64.urlsafe_b64encode(b + SESSION_SECRET_TAIL[:cnt])
def verify_cookie(raw_cookie):
"""校验会话密文 → `(ok, reason)`。
reason ∈ ``no_session`` / ``forged_session`` / ``expired_session`` /
``session_decode_unavailable``(cryptography 不可用 → fail-closed)。
"""
if not raw_cookie or not str(raw_cookie).strip():
return False, "no_session"
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # noqa: BLE001
return False, "session_decode_unavailable"
try:
fernet = Fernet(session_key())
token = str(raw_cookie).strip().encode("utf-8")
try:
data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8"))
return (True, "ok") if isinstance(data, dict) else (False, "forged_session")
except InvalidToken:
data = json.loads(fernet.decrypt(token).decode("utf-8"))
if not isinstance(data, dict):
return False, "forged_session"
created = int(data.get("created") or 0)
if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE:
return False, "expired_session"
return False, "forged_session"
except Exception: # noqa: BLE001
return False, "forged_session"
def _has_session(request):
"""请求是否携带可验证的会话 → `(ok, reason)`。"""
cookies = getattr(request, "cookies", None) or {}
for name in session_cookie_names():
val = cookies.get(name)
if val:
return verify_cookie(val)
return False, "no_session"
def _deny(request, reason):
"""统一 401 错误信封(**不含任何业务字段**)。
hint 自指修正(R53 / QC#4):
* 登录端点自身(`/rbac/user/up_login.dspy` 等公开路径)被拒 → **不是未登录**,
而是服务端鉴权闸门/会话组件异常(fail-closed 掩盖了真实故障)。此时 hint
必须指向「闸门异常」而非「请先 POST 登录」,否则形成"先登录才能登录"的自指死循环。
* 其余受保护端点 → 保持"请先登录"的指引。
"""
path = normalize_path(getattr(request, "path", "") or "")
log.warning("[auth_strict] 拒绝匿名/无效会话请求: %s %s reason=%s",
getattr(request, "method", "-"), path, reason)
if is_public_path(path):
hint = ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed),"
"不是未登录。请查看服务端日志 [auth_strict] 与 /healthz 的 gate/chain 诊断字段")
else:
hint = "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie"
resp = web.json_response(
{
"ok": False,
"status": 401,
"error": reason,
"path": path,
"hint": hint,
},
status=401,
)
resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"'
return resp
def _is_response_contract_error(exc):
"""异常是否属于「中间件把非 Response(handler 函数本体)交回上游」这一类契约错误。"""
txt = "%s: %s" % (type(exc).__name__, exc)
for key in ("Expect response", "should return a response", "not <class 'function'>",
"not a Response", "ResponseContractError", "__mw_signature_adapter__"):
if key in txt:
return True
return False
def _internal_error(request, exc):
"""闸门内部异常 → **500**(不是 401)。带 traceback 与层名,供运维直接定位。
:param request: aiohttp Request
:param exc: 捕获到的异常
:return: aiohttp web.Response(status=500, error=auth_gate_internal_error)
"""
path = "-"
try:
path = normalize_path(getattr(request, "path", "") or "") or "-"
except Exception: # noqa: BLE001
pass
contract = _is_response_contract_error(exc)
log.error("[auth_strict] 闸门内部异常(非会话缺失)→ 500 %s %s contract_error=%s: %s: %s",
getattr(request, "method", "-"), path, contract,
type(exc).__name__, exc, exc_info=True)
detail = "%s: %s" % (type(exc).__name__, exc)
if contract:
hint = ("中间件链响应契约错误:某层把未 await 的 handler 当作 Response 返回。"
"查日志 [mw-signature]/[auth_strict] 里的 layer= 字段定位该层")
else:
hint = ("服务端鉴权闸门内部异常(既不是未登录,也不是会话缺失)。"
"查服务端日志 [auth_strict] 的 traceback")
resp = web.json_response(
{
"ok": False,
"status": 500,
"error": "auth_gate_internal_error",
"path": path,
"detail": detail,
"contract_error": contract,
"hint": hint,
},
status=500,
)
try:
resp.headers["X-Auth-Gate-Error"] = "internal"
except Exception: # noqa: BLE001
pass
return resp
@web.middleware
async def auth_gate_strict_middleware(request, handler):
"""最后一层会话闸门:非公开 `.dspy` 无有效会话 → 401(fail-closed)。
判定顺序(顺序即语义):
1. 总开关关闭(排障)→ 放行;
2. 非 `.dspy`(页面/静态)→ 放行;
3. 显式公开路径(登录/登出/健康探针)→ 放行;
4. 其余 `.dspy` → 校验会话密文,失败 401,成功放行给内层链/业务处理器。
"""
# [fix Zhpd9MXyeGtznDMqini6X / R49] 与 route_guard 同源修法:
# 只把「闸门自身判定/会话校验」的异常 fail-closed 成 401,
# `await handler(request)`(下游业务处理器)必须在 try 之外,
# 否则下游 500 / aiohttp `Expect response, not <class 'function'>`
# 会被改写成 401 auth_unavailable,把真实故障整层遮蔽(R49 矩阵
# 229×401、42 条 500 无法判定的机械原因)。
try:
if not gate_enabled():
return await handler(request)
path = normalize_path(getattr(request, "path", "") or "")
if not is_gated_path(path):
return await handler(request)
ok, reason = _has_session(request)
except Exception as exc: # noqa: BLE001
# fail-closed 仍成立(绝不放行业务数据),但**错误码不再冒充鉴权失败**。
# 历史(已废除):R50 曾把公开路径的闸门异常 `return await handler(request)`
# 放行,其余一律 401 auth_unavailable —— 两者都在掩盖真实故障,R60 起废除。
# [fix rvtGi1mlUsTwQrXnybZj6 / R60] 语义分离:**闸门内部异常 ≠ 会话缺失**。
# 旧实现把任何异常(典型:链装配错误 `Expect response, not <class 'function'>`)
# 一律降级成 401 auth_unavailable,于是「登录端点自己 401」被固化,
# 真实故障(某层把未 await 的 handler 当 Response 返回)被 401 完全遮蔽 ——
# R58 实测 logs/scense.log:1735-1737 就是这条遮蔽路径。
# 现在:会话缺失/无效 → 401(见下方 ok 分支);闸门自身或内层链异常 →
# 500 + error=auth_gate_internal_error + 完整 traceback,绝不改写成鉴权失败。
return _internal_error(request, exc)
if ok:
return await handler(request)
return _deny(request, reason)

View File

@ -0,0 +1,566 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""统一鉴权门禁(存在性 → 鉴权 两段式;404 / 401 / 403 三态互不覆盖)。
覆盖缺陷族(T2 鉴权门禁族)
---------------------------
* LonNYvRxXk9EttSexkwkV:未登录 GET /scense/api/list_scense.dspy 返 200(应 401)
* LIyicannYDhJiXXkjTQ0N:不存在端点返 401 而非 404(鉴权先于路由)
* bj-n9DhoDKGz_lMHiUmbq:匿名 GET /scene/api/get_search_status.dspy 返 200+rows
* N4teiYPv2VUo2OWkoR8Fw:匿名 POST /scene/api/create_scene.dspy 进入业务分支返 200
* QuOjNlynfq3i8P7zdU2U4:匿名读 3 端点均 200 返业务数据
设计要点(为什么另起一个「统一门禁」而不是继续在旧 gate 上打补丁)
------------------------------------------------------------------
旧实现(route_guard_middleware.auth_required_middleware)依赖
`PROTECTED_DSPY_PATHS` 逐条点名 + 行为分支判定,结果是「点名的进闸、没点名的漏出去」,
于是 list_scense / get_search_status / create_scene 各自以 200 漏过,同一站内
"不存在路径 401、真实业务端点 200" 的语义自相矛盾。
本模块改为**默认拒绝(default-deny)**:
*.dspy ⇒ 除非在 PUBLIC_DSPY_PATHS(逐条精确、无通配)中,
否则一律「必须有有效会话」。
新增端点无需登记即自动纳管(get_search_* / list_* / create_* 全部覆盖)。
判定顺序(硬约束,两者不得互相覆盖):
① normalize 路径
② 后缀不是 .dspy → 放行(静态资源 / .ui 页面 / /healthz 交给既有组件)
③ 命中公开白名单(auth_exempt.is_exempt_path,精确/段边界,无通配)
→ 放行(**先于**存在性与鉴权:登录链路不得自锁)
④ 资源不存在 → 404 (先于鉴权:路径写错不应伪装成"未登录")
⑤ 无有效会话 / 会话无效 → 401 + WWW-Authenticate
⑥ 有有效会话 → 放行(后续 RBAC 权限不足由 ahserver 判 403)
403 的职责边界:本门禁只判「有没有身份」,不判「这个身份有没有该端点权限」——
后者是 RBAC(permission / rolepermission)的事,返回 403 由框架层给出。
两者分工在矩阵表里体现为 `登录态=有, 无权限 → 403(框架)`。
安装位置
--------
`install(app)` 把中间件插到 `app.middlewares` **首位(最外层)**:
aiohttp 的中间件列表首位 = 最外层,必须先于 ahserver 的鉴权层执行,
否则框架自身的 401(对不存在路径也 401)会抢在我们的 404 之前返回 -
这正是 LIyicannYDhJiXXkjTQ0N 的根因(「鉴权前置于路由判定」)。
零硬编码:白名单路径、后缀、cookie 名全部是本文件常量或从 conf/config.json 读取;
不写任何 host/port/域名。
"""
import sys
import json
import os
import importlib.util
import logging
# R52 QC#4:本文件多处 `log.error(...)` 落在 _load_contract_module / _new_style 的
# **兜底路径**上(设计意图是「宁可抛错也不静默放行」)。此前未定义 `log`、也未
# import logging,一旦真的走到兜底分支会先抛 `NameError: name 'log' is not defined`,
# 把原始异常覆盖成模块级 import 失败 → 应用启动即崩。与 get_only_405.py 对称定义。
log = logging.getLogger("scense.auth_route_gate")
# --------------------------------------------------------------------------- #
# 常量(全部逐条精确,**禁止通配符**:QC 硬门禁 #6)
# --------------------------------------------------------------------------- #
APP_DIR = os.path.dirname(os.path.abspath(__file__))
#: 关闸开关(off/0/false/no 关闭;默认开启)
GATE_ENV = "YUANJING_AUTH_ROUTE_GATE"
#: 只对 .dspy 端点做门禁(.ui 页面由会话层处理,避免把登录页自身锁死)
PROTECTED_SUFFIXES = (".dspy",)
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)──
# 修复前本文件自带一份 PUBLIC_DSPY_PATHS 副本,与 route_guard_middleware /
# auth_gate_strict / notfound_middleware 内联兜底闸门的另外几份副本**各自演化**,
# 结果「主线豁免、兜底路径不豁免」→ 登录端点在某些链路上仍 401。现在统一委托。
def _load_auth_exempt():
"""加载豁免清单模块(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
spec = importlib.util.spec_from_file_location(
"yj_auth_exempt", os.path.join(APP_DIR, "auth_exempt.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
EXEMPT = _load_auth_exempt()
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS /
PUBLIC_PATH_PREFIXES 只是**派生镜像**,用于自测交叉断言与诊断输出;
判定一律走 `EXEMPT.is_exempt_path()`。真源不可用/异常 → 空镜像 + fail-closed。
"""
if mod is None:
return {"revision": "unavailable", "dspy_paths": frozenset(),
"prefixes": (), "wildcard_free": False}
try:
mf = mod.manifest()
except Exception: # noqa: BLE001
# manifest 不可用时退回真源的属性(仍是同一份数据,不是本地字面量)
return {
"revision": getattr(mod, "EXEMPT_REVISION", "unavailable"),
"dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()),
"prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()),
"wildcard_free": bool(getattr(mod, "EXEMPT_REVISION", "")),
}
return {
"revision": str(mf.get("revision") or "unavailable"),
"dspy_paths": frozenset(mf.get("dspy_paths") or ()),
"prefixes": tuple(mf.get("prefixes") or ()),
"wildcard_free": bool(mf.get("wildcard_free", False)),
}
EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT)
#: 免鉴权端点(逐条精确路径;登录/注册/探针)—— **由 manifest() 派生**,只读镜像。
PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"]
#: 免鉴权前缀(静态资产 / 健康探针,按路径段边界匹配,无通配)—— **由 manifest() 派生**。
PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"]
#: 豁免口径版本(写进诊断与自测输出;同样来自 manifest())
EXEMPT_REVISION = EXEMPT_MANIFEST["revision"]
#: 中间件函数名(供装配后置核验使用)
MIDDLEWARE_NAME = "auth_route_gate_middleware"
#: 会话凭据来源(与 ahserver/rbac 对齐;具体 cookie 名从 conf 读)
DEFAULT_SESSION_COOKIE = "AIOSESSION"
# --------------------------------------------------------------------------- #
# 路径工具
# --------------------------------------------------------------------------- #
def _app_root():
"""应用根目录(= app/ 的上级)。"""
return os.path.dirname(APP_DIR)
def normalize_request_path(path):
"""归一路径:去 query/fragment、URL 解码、折叠重复斜杠、去尾斜杠。
不做大小写折叠(文件系统可能大小写敏感),小写化只用于白名单匹配。
"""
try:
from urllib.parse import unquote
except Exception: # pragma: no cover
def unquote(x):
return x
p = str(path or "")
for sep in ("?", "#"):
idx = p.find(sep)
if idx >= 0:
p = p[:idx]
try:
p = unquote(p)
except Exception: # noqa: BLE001
pass
p = p.replace("\\", "/")
while "//" in p:
p = p.replace("//", "/")
if len(p) > 1 and p.endswith("/"):
p = p.rstrip("/")
if not p.startswith("/"):
p = "/" + p
return p
def _candidate_paths(url_path):
"""把 URL 映射为候选文件路径(应用 wwwroot + 外部扫描根,任一存在即视为存在)。"""
p = normalize_request_path(url_path)
cands = []
for root, prefix in _scan_roots():
if prefix:
if not (p.lower() == prefix.lower() or p.lower().startswith(prefix.lower() + "/")):
continue
rel = p[len(prefix):].lstrip("/")
else:
rel = p.lstrip("/")
if not rel:
continue
cands.append(os.path.join(root, rel))
cands.append(os.path.join(root, rel, "index.dspy"))
cands.append(os.path.join(root, rel, "index.ui"))
return cands
def resource_exists(url_path):
"""资源在 wwwroot 下是否存在(软链已跟随即存在)。"""
for c in _candidate_paths(url_path):
try:
if os.path.isfile(c):
return True
except OSError:
continue
return False
def is_public_endpoint(url_path):
"""是否免鉴权(委托单一真源 auth_exempt.is_exempt_path,精确比对无通配)。"""
try:
return bool(EXEMPT.is_exempt_path(url_path))
except Exception: # noqa: BLE001
# 豁免判定自身故障 → fail-closed(不放行),但记日志,绝不静默。
log.error("[auth_route_gate] 豁免判定异常,按不豁免处理: %r", url_path)
return False
def is_protected(url_path):
"""该路径是否受本门禁保护(= 后缀在 PROTECTED_SUFFIXES 内且非公开)。"""
p = normalize_request_path(url_path)
if not p.lower().endswith(PROTECTED_SUFFIXES):
return False
return not is_public_endpoint(p)
# --------------------------------------------------------------------------- #
# 会话校验(复用 route_guard_middleware 的会话解密,不重复实现密码学)
# --------------------------------------------------------------------------- #
_ROUTE_GUARD = None
def _route_guard_module():
"""按文件路径加载 route_guard_middleware(不依赖 app 包可导入)。"""
global _ROUTE_GUARD
if _ROUTE_GUARD is not None:
return _ROUTE_GUARD
try:
from app import route_guard_middleware as rg # noqa: F401
_ROUTE_GUARD = rg
return rg
except Exception: # noqa: BLE001
pass
path = os.path.join(APP_DIR, "route_guard_middleware.py")
try:
spec = importlib.util.spec_from_file_location("_yj_route_guard", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_ROUTE_GUARD = mod
return mod
except Exception: # noqa: BLE001
_ROUTE_GUARD = False
return False
async def _verify_session_impl(request):
"""返回 (ok: bool, reason: str)。
优先委托 route_guard_middleware.verify_session(真实 cookie 解密 + rbac 会话)。
取不到实现时 **fail-closed**(返回 False)——宁可 401 也不放行匿名。
"""
rg = _route_guard_module()
if rg and hasattr(rg, "verify_session"):
try:
res = await rg.verify_session(request)
except Exception as exc: # noqa: BLE001
return False, "session_verify_error:%s" % type(exc).__name__
if isinstance(res, tuple):
return bool(res[0]), str(res[1] if len(res) > 1 else "")
return bool(res), ""
return False, "no_session_verifier"
#: 测试替身挂载点(selftest 覆盖此名字)
_verify_session = _verify_session_impl
async def verify_session(request):
return await _verify_session(request)
# --------------------------------------------------------------------------- #
# 纯函数判定(可脱框架单测)
# --------------------------------------------------------------------------- #
def gate_decision(url_path, exists=None, session_ok=None):
"""返回 (http_code, reason)。http_code 为 None = 放行。
exists / session_ok 传入 None 时按真实实现推算(exists 用文件系统,
session_ok 无法在纯函数里 await,必须显式传入)。
"""
p = normalize_request_path(url_path)
if not p.lower().endswith(PROTECTED_SUFFIXES):
return None, "not_dspy"
# 豁免先于存在性(与真实中间件同口径,T4)
if is_public_endpoint(p):
return None, "public_endpoint"
if exists is None:
exists = resource_exists(p)
if not exists:
return 404, "no such resource"
if not session_ok:
return 401, "authentication required"
return None, "authorized"
# --------------------------------------------------------------------------- #
# aiohttp 中间件
# --------------------------------------------------------------------------- #
def _json_response(status, payload, headers=None):
"""构造 JSON 响应;aiohttp 不可用时退化为轻量对象(供离线自测)。"""
body = json.dumps(payload, ensure_ascii=False)
try:
from aiohttp import web
return web.Response(status=status, text=body, content_type="application/json",
headers=headers or {})
except Exception: # noqa: BLE001
class _LiteResponse(object):
def __init__(self, status, text, hdrs):
self.status = status
self.text = text
self.headers = dict(hdrs or {})
self.content_type = "application/json"
def __repr__(self):
return "<LiteResponse %s %s>" % (self.status, self.text)
return _LiteResponse(status, body, headers)
def _gate_enabled():
return (os.environ.get(GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no")
async def auth_route_gate_middleware(request, handler):
"""存在性 → 鉴权(顺序不可颠倒;404 不得被 401 覆盖)。"""
if not _gate_enabled():
return await handler(request)
path = normalize_request_path(getattr(request, "path", "") or "")
if not path.lower().endswith(PROTECTED_SUFFIXES):
return await handler(request)
# ① 豁免(**必须先于存在性与会话校验**,T4 / Bug uifdBIMdmixZcmJEdV33O):
# 登录/登出/注册/会话自检端点在「还没有会话」的时刻被调用,任何形式的
# 404/401 都会造成「先登录才能登录」的自指死循环。豁免判定放在最前,
# 意味着 wwwroot 软链缺失(存在性为假)、会话组件不可用(校验抛错)
# 都不会把登录端点变成本门禁的错误信封;真不存在时由框架自己给 404。
if is_public_endpoint(path):
return await handler(request)
# ② 存在性:不存在 → 404(先于鉴权)
if not resource_exists(path):
return _json_response(404, {
"status": 404,
"success": False,
"ok": False,
"reason": "no such resource",
"path": path,
})
# ③ 鉴权:无有效会话 → 401
try:
ok, reason = await verify_session(request)
except Exception as exc: # noqa: BLE001
ok, reason = False, "session_verify_error:%s" % type(exc).__name__
if not ok:
return _json_response(401, {
"status": 401,
"success": False,
"ok": False,
"reason": "authentication required",
"path": path,
"detail": reason or "",
}, headers={"WWW-Authenticate": 'Session realm="yuanjing"'})
# ④ 有身份 → 放行(权限不足的 403 由框架 RBAC 判定)
return await handler(request)
# --------------------------------------------------------------------------- #
# 装配
# --------------------------------------------------------------------------- #
def _middleware_names(app):
return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or ())]
def install(app):
"""把门禁插到 app.middlewares 首位(最外层)。幂等,永不抛出。"""
out = {"layer": "auth_route_gate", "ok": False, "added": [], "error": None,
"position": None}
try:
names = _middleware_names(app)
if MIDDLEWARE_NAME in names:
out["ok"] = True
out["position"] = names.index(MIDDLEWARE_NAME)
out["added"] = []
else:
# [R52 chain-contract] 必须插 new-style(带 __middleware_version__)
# 中间件:裸 async def 进链会被 aiohttp 按 legacy 工厂 mw(app) 调用,
# request 形参收到的是 AHApp → `request.remote` AttributeError(R48 E1)。
app.middlewares.insert(0, _new_style(auth_route_gate_middleware))
names = _middleware_names(app)
out["added"] = [MIDDLEWARE_NAME]
out["position"] = names.index(MIDDLEWARE_NAME) if MIDDLEWARE_NAME in names else None
out["ok"] = out["position"] == 0
out["middlewares"] = names
if not out["ok"] and not out["error"]:
out["error"] = "not at outermost position: names=%s" % (names,)
except Exception as exc: # noqa: BLE001
out["error"] = "%s: %s" % (type(exc).__name__, exc)
return out
# --------------------------------------------------------------------------- #
# 覆盖矩阵(全模块同类排查的机械口径)
# --------------------------------------------------------------------------- #
def _scan_roots():
"""返回 [(物理根目录, URL 前缀)]:应用 wwwroot + 可选的外部模块仓库根。
外部根由环境变量 YUANJING_AUTH_GATE_SCAN_ROOTS 注入(逗号分隔的目录,
每个目录下按 <模块>/wwwroot 扫描),**脚本内不写任何绝对路径**——
部署机与开发工作区的目录层级不同,写死会误报「漏管」。
"""
roots = [(os.path.join(_app_root(), "wwwroot"), "")]
extra = (os.environ.get("YUANJING_AUTH_GATE_SCAN_ROOTS") or "").strip()
for one in extra.split(","):
one = one.strip()
if not one or not os.path.isdir(one):
continue
try:
names = sorted(os.listdir(one))
except OSError:
continue
for name in names:
ww = os.path.join(one, name, "wwwroot")
if os.path.isdir(ww):
roots.append((ww, "/" + name))
return roots
def iter_wwwroot_dspy():
"""遍历 wwwroot 下全部 .dspy(含经软链挂载的各模块),产出 URL 路径。"""
found = []
for root, prefix in _scan_roots():
for dirpath, _dirnames, filenames in os.walk(root, followlinks=True):
for fn in filenames:
if not fn.endswith(".dspy"):
continue
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root).replace(os.sep, "/")
found.append((prefix + "/" + rel) if prefix else ("/" + rel))
return sorted(set(found))
def coverage_matrix(paths=None):
"""返回 [{path, decision, public}],用于「纳管/漏管」机械核验。"""
rows = []
for p in (paths if paths is not None else iter_wwwroot_dspy()):
public = is_public_endpoint(p)
rows.append({
"path": p,
"public": public,
"decision": "public" if public else "protected",
})
return rows
def assert_no_wildcard(items):
"""白名单禁通配符(QC 硬门禁 #6)。"""
bad = [p for p in items if "*" in str(p) or "%" in str(p)]
if bad:
raise AssertionError("wildcard not allowed in allowlist: %r" % (bad,))
return True
assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES))
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):本文件的 `auth_route_gate_middleware` 是**裸** `async def
# (request, handler)`,没有 `@web.middleware`。aiohttp 用 `__middleware_version__`
# 区分中间件风格,缺该标记的可调用对象会被当作 **legacy 工厂** 调用 `mw(app)` ——
# 应用对象被填进第一个形参(request),于是 `pkgs/ahserver/ahserver/real_ip.py:16`
# 的 `request['client_ip'] = request.remote` 抛
# `AttributeError: 'AHApp' object has no attribute 'remote'`,全站 500。
# 这里在**模块级**把它规范成 new-style,并在 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):
"""把中间件规范成 new-style(带 __middleware_version__)。
**禁止静默放行裸函数**(R48 E1 根因):规范化不可用也要显式补标记并记
error 日志;规范化后立即自校验,不成立直接抛错而不是继续装配。
"""
_mcc = _load_contract_module()
fixed = mw
if _mcc is not None:
try:
fixed = _mcc.ensure_middleware(mw)
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
auth_route_gate_middleware = _new_style(auth_route_gate_middleware)

View File

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""请求体「读后回填」的唯一实现(P0 崩点修复,QC 意见 #2)。
要解决的问题(目标机 traceback 原文已写进本迭代 P0 单 _HCoWd3He1axFKKa8ae91)
--------------------------------------------------------------------------
`app/http_semantics_middleware.py` 的 `restore_request_body()` 为了让我方读完 body 后
下游 `processorResource.getPostData()` 还能再解析一次,做了:
from aiohttp.payload import BytesPayload
request._payload = BytesPayload(body)
在 aiohttp 3.10.10 里 `Payload` / `BytesPayload` **没有** `at_eof()` 方法,而
`aiohttp.web_request.BaseRequest.can_read_body` 的实现是:
return not self._payload.at_eof()
于是本中间件一旦回填过 `_payload`,下游任何一次 `can_read_body` 读取都会抛
`AttributeError: 'BytesPayload' object has no attribute 'at_eof'` → 该请求 **HTTP 500**。
影响面:全站 `*.dspy` 500 = 37/215,其中 POST/PUT 写类端点(CRUD `add_*` /
`create_*` / `entity_create` / `runtime_control` / `add_world` 等 42 条)必崩,
本迭代的 500 门禁(须为 0)不可能清零。
两种改法(QC 给了 A1/A2 二选一)
------------------------------
* A1:完全不替换 `_payload`,只保留 `request._read_bytes` 缓存,并把所有下游读取点
改为优先走缓存。**问题**:下游读取点在基座 `ahserver.processorResource`(本应用
`referenced_modules`,按 project-directory-spec §三「引用模块写保护」只读,
改了会破坏所有依赖 ahserver 的项目),本角色无权修改。
* A2(本文件采用):改用 `aiohttp.streams.StreamReader` + `feed_data/feed_eof`
重建 `_payload`。`StreamReader` **有** `at_eof()`(`self._eof and not self._data`),
因此 `can_read_body` 正常返回 bool;同时新流未 EOF 前可被 `post()/multipart()`
重新消费,等价于原意图。
三条硬约束
----------
1. **绝不构造 `BytesPayload`**:构造失败/类型不可用时,宁可**不碰** `_payload`
(原 `_payload` 本身就是 `StreamReader`,有 `at_eof`),也绝不塞一个没有
`at_eof` 的对象进去——把 AttributeError 崩点换成"下游读不到 body"是倒退。
2. 回填同时保留(并写回)`request._read_bytes`,让 `await request.read()` 幂等。
3. 本模块**顶层不 import aiohttp**:aiohttp 只在函数内部按需导入,因此可以脱离
web 运行时做离线自测(见 `scripts/selftest_body_restore_r50.py`)。
"""
from __future__ import annotations
import logging
log = logging.getLogger("yuanjing.body_restore")
#: 回填结果码
RESTORED = "restored" # _payload 已用 StreamReader 重建,下游可重读
CACHE_ONLY = "cache_only" # 只写了 _read_bytes 缓存,未碰 _payload(安全降级)
SKIPPED_EMPTY = "skipped_empty" # body 为空,无需回填
FAILED = "failed" # 回填抛异常(已吞异常,仅告警)
def _make_stream_reader(body, template=None, loop=None):
"""构造一个已喂入 body 并到达 EOF 的 aiohttp StreamReader。
返回 (reader, "") 或 (None, reason)。构造成功后必须满足:
* 有 `at_eof()`(`can_read_body` 不再 AttributeError);
* `at_eof()` 在 EOF 后返回 True(不谎报"还有 body 可读")。
"""
try:
from aiohttp.streams import StreamReader
except Exception as exc: # noqa: BLE001
return None, "no aiohttp.streams.StreamReader: %s" % exc
kwargs = {}
# 尽量沿用原 payload 的分片/上限/事件循环参数,避免行为漂移
for attr, key in (("_limit", "limit"), ("_chunk", "chunk"), ("_timer", "timer"),
("_loop", "loop")):
val = getattr(template, attr, None) if template is not None else None
if val is None and key == "loop":
val = loop
if val is not None:
kwargs[key] = val
try:
reader = StreamReader(**kwargs)
except Exception: # noqa: BLE001
# 参数签名随版本变化:退回到无参构造( aiohttp 全版本支持)
try:
reader = StreamReader()
except Exception as exc: # noqa: BLE001
return None, "StreamReader() failed: %s" % exc
try:
reader.feed_data(body)
reader.feed_eof()
except Exception as exc: # noqa: BLE001
return None, "feed_data/feed_eof failed: %s" % exc
if not callable(getattr(reader, "at_eof", None)):
return None, "constructed reader has no at_eof()"
# 自检:at_eof() 必须**可安全求值并返回 bool**(这正是线上 500 的位置)。
# 注意:喂入 body + feed_eof 之后 at_eof() 仍应为 False(body 未被消费),
# 这正是让下游 `can_read_body=True → read()` 能重读到 body 的语义。
try:
if not isinstance(reader.at_eof(), bool):
return None, "at_eof() did not return bool"
except Exception as exc: # noqa: BLE001
return None, "at_eof() raised: %s" % exc
return reader, ""
def restore_request_payload(request, body):
"""把已读走的 body 安全回填给下游。返回 (ok: bool, code: str)。
安全顺序(任一步失败都不影响前面已完成的步骤):
1. 写 `request._read_bytes`(aiohttp 自己的读缓存,重复读幂等);
2. 用 `StreamReader` 重建 `request._payload`(A2 方案,有 at_eof);
3. 第 2 步不可用时**保持 `_payload` 原样**(= CACHE_ONLY 降级),
绝不写 `BytesPayload`(那正是 P0 500 的崩点)。
"""
if not body:
return True, SKIPPED_EMPTY
if not isinstance(body, (bytes, bytearray)):
return False, FAILED
# 1) 读缓存(一定先做:即使后面 payload 重建失败,handler 走 read() 仍有内容)
cache_ok = False
try:
request._read_bytes = bytes(body)
cache_ok = True
except Exception as exc: # noqa: BLE001
log.warning("[body_restore] 写 _read_bytes 失败 path=%s: %s",
getattr(request, "path", ""), exc)
# 2) 用 StreamReader 重建 _payload
template = None
loop = None
try:
template = getattr(request, "_payload", None)
except Exception: # noqa: BLE001
template = None
try:
loop = getattr(request, "_loop", None) or getattr(template, "_loop", None)
except Exception: # noqa: BLE001
loop = None
reader, reason = _make_stream_reader(bytes(body), template=template, loop=loop)
if reader is None:
# 3) 安全降级:**不碰** _payload
log.warning("[body_restore] 跳过 _payload 重建(保持原流,避免 at_eof 缺失)"
"path=%s reason=%s cache_ok=%s",
getattr(request, "path", ""), reason, cache_ok)
return (cache_ok, CACHE_ONLY)
try:
request._payload = reader
# 让 aiohttp 的 post()/multipart() 重新解析(缓存的旧解析结果已不属于新流)
for attr in ("_post_params", "_body_attrs_set"):
try:
if getattr(request, attr, None) is not None:
setattr(request, attr, None)
except Exception: # noqa: BLE001
pass
try:
request._reading = False
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
log.warning("[body_restore] 赋 _payload 失败 path=%s: %s",
getattr(request, "path", ""), exc)
return (cache_ok, CACHE_ONLY)
# 自检:回填后 can_read_body 必须可安全求值(这正是线上 500 的位置)
try:
request.can_read_body
except Exception as exc: # noqa: BLE001
log.error("[body_restore] 回填后 can_read_body 仍抛异常 path=%s: %s "
"→ 回退为仅缓存", getattr(request, "path", ""), exc)
try:
if template is not None:
request._payload = template
except Exception: # noqa: BLE001
pass
return (cache_ok, CACHE_ONLY)
return True, RESTORED
def describe(request):
"""诊断串(不含任何 body 内容/凭据,只有类型名),用于日志与自测。"""
payload = getattr(request, "_payload", None)
return "payload_type=%s has_at_eof=%s read_bytes_len=%s" % (
type(payload).__name__,
callable(getattr(payload, "at_eof", None)),
len(getattr(request, "_read_bytes", b"") or b""),
)

View File

@ -0,0 +1,213 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""业务错误码 → HTTP 状态行 回写(Bug DGzTjOkr_OYM4vdOYcl2_ / F12 / F16 / **FnRUGqsY7 R18**)。
问题
----
模块与生成器产出的 .dspy 端点把「缺必填参数 / 校验失败」写成**响应信封**
`{"status": 400, "success": false, "message": "缺少必填参数: name(表 world)"}`,
但 ahserver 序列化该 dict 时 HTTP 状态行仍是 200。R18 复测实测(部署态,带 cookie):
HTTP=200 /world/api/create_world.dspy BODY={"status":400,...}
HTTP=200 /scene/api/create_scene.dspy BODY={"status":400,...}
HTTP=200 /entity/api/entity_delete.dspy BODY={"status":400,...}
HTTP=200 /entity/api/entity_create.dspy BODY={"status":400,...}
HTTP=200 /world/api/create_world.dspy name=rt_probe_world_232348 BODY={"status":200,...} ← 正例
R18 结论(本轮根因,**不是**回写逻辑本身错,而是装配层又一次没生效)
------------------------------------------------------------------
上一轮只把它做成 aiohttp **middleware**,靠 `app/notfound_middleware.compose_guard_chain()`
用 `__import__("app." + 模块名)` 动态导入再串链。部署态进程是 `python app/scense.py`
(cwd=部署目录,sys.path 不含应用根),`app` 包导入静默失败 → `compose_guard_chain`
抛 RuntimeError → 入口**降级为只做 404 异常映射**,业务 400 回写层整层消失:
log: [semantics] 组合链装配失败,降级为仅 404 异常映射: 语义中间件不可导入: ...
因此本轮在原有 middleware 之外新增**装配无关的第二条件路径**:aiohttp 的
`on_response_prepare` 信号(`app.on_response_prepare.append(hook)`)。该信号在响应头
发出前逐请求同步触发,**不依赖中间件链、不依赖包导入**,任何 Response 类型(含
ahserver 自定义 Response 子类、StreamResponse)都能命中;入口只需在拿到 aiohttp app
的那一刻调用一次 `install_business_status_signal(app)`(见 `app/scense.py`、
`app/yuanjing.py` 的 `_install_business_status_signal`,按**绝对文件路径**加载本模块,
避开包导入陷阱)。
设计约束(不掩错)
------------------
1. **只回写 .dspy 端点的 JSON 信封**:路径不以 .dspy 结尾(.ui / .js / .css / .html)
一律不碰,避免改变页面渲染语义。
2. **只在 HTTP 200 时改写**:handler / 内层中间件已经给出 4xx/5xx 状态行时不覆盖
(不降低语义,也不会把 401/404 改坏)。
3. **只认信封里的整型 status 且取值在 ERR_ENVELOPE_CODES 内**:400/401/403/404/405/409/422。
其它键(如 {"ok": true, "status": "running"} 这类业务枚举字符串)不参与判定 ——
不会把正常业务数据误判成错误;`{"status":200,"success":true,"id":...}` 正例不触发。
4. **不改响应体**:信封原样返回,前端可继续读 status/success/message/missing。
5. 解析失败(非 JSON / 大 body / 无 body)一律原样放行,**不因回写层自身异常影响业务响应**。
6. 幂等:信号重复安装按函数对象去重;middleware 与信号双层同时存在也只回写一次
(第一次回写后 response.status 已非 200,第二层自然跳过)。
"""
import json
import logging
from aiohttp import web
log = logging.getLogger("yuanjing.business_status")
# 信封里出现这些 status 值即认为「业务错误码」,需回写到 HTTP 状态行
ERR_ENVELOPE_CODES = frozenset((400, 401, 403, 404, 405, 409, 422))
# 只有 .dspy 端点参与回写(.ui 页面里的 status 是业务字段,不改)
REWRITE_SUFFIXES = (".dspy",)
# 超过该长度的 body 不做解析(性能保护;正常业务信封远小于此)
MAX_BODY_BYTES = 1024 * 1024
# 内容类型白名单关键字:ahserver 的 dspy 处理器可能给 application/json,
# 也可能给 text/plain、application/octet-stream(json.dumps 字符串直出)——都要认,
# R18 根因之一就是旧版只认 json/text 导致部分 Response 被跳过。
_CONTENT_TYPE_OK = ("json", "text", "octet-stream", "")
def _should_rewrite(request):
path = getattr(request, "path", "") or ""
return path.endswith(REWRITE_SUFFIXES)
def _response_body_bytes(response):
"""取响应体字节(兼容 Response(body=) / Response(text=) / 自定义子类)。"""
body = getattr(response, "body", None)
if isinstance(body, bytes):
return body
if isinstance(body, bytearray):
return bytes(body)
if isinstance(body, str):
return body.encode("utf-8", "replace")
text = getattr(response, "text", None)
if isinstance(text, str):
return text.encode("utf-8", "replace")
return None
def _envelope_status(response):
"""从响应对象解析出需要回写的 HTTP 状态码;不需要回写时返回 None。"""
body = _response_body_bytes(response)
if not body or len(body) > MAX_BODY_BYTES:
return None
text = body.decode("utf-8", "replace").strip()
if not text.startswith("{"):
return None
try:
payload = json.loads(text)
except Exception: # noqa: BLE001
return None
if not isinstance(payload, dict):
return None
code = payload.get("status")
if isinstance(code, bool) or not isinstance(code, int):
return None
if code not in ERR_ENVELOPE_CODES:
return None
return code
def _content_type_ok(response):
ctype = (getattr(response, "content_type", None) or "").lower()
return any(k in ctype for k in _CONTENT_TYPE_OK)
def apply_business_status(request, response):
"""把业务错误信封的 status 回写到 HTTP 状态行(幂等、只升不降)。
:return: True=已回写,False=未命中
"""
if not _should_rewrite(request):
return False
if response is None:
return False
if getattr(response, "prepared", False):
return False
if getattr(response, "status", None) != 200:
return False
if not _content_type_ok(response):
return False
code = _envelope_status(response)
if code is None:
return False
try:
if hasattr(response, "set_status"):
response.set_status(code)
else: # 极简自定义 Response
response.status = code
except Exception as exc: # noqa: BLE001
# 状态行改不动也不掩错:记录后原样返回(HTTP 仍 200,body 信封不变)
log.error("[business_status] set_status 失败 %s: %s", code, exc)
return False
log.warning("[http-%d] 业务信封 status 回写状态行: %s %s",
code, getattr(request, "method", "-"), getattr(request, "path", "-"))
return True
@web.middleware
async def business_status_middleware(request, handler):
"""中间件路径(构造期装配可达时生效;与信号层等价、幂等)。"""
response = await handler(request)
try:
apply_business_status(request, response)
return response
except Exception as exc: # noqa: BLE001
log.error("[business_status] 回写失败,保持原响应 %s: %s",
getattr(request, "path", "-"), exc)
return response
async def business_status_on_response_prepare(request, response):
"""`on_response_prepare` 信号处理器(**装配无关**的第二条生效路径,R18 新增)。"""
try:
apply_business_status(request, response)
except Exception as exc: # noqa: BLE001
log.error("[business_status] 信号回写失败,保持原响应 %s: %s",
getattr(request, "path", "-"), exc)
def install_business_status_signal(app):
"""在 aiohttp Application 上挂 `on_response_prepare` 信号(幂等)。
:param app: aiohttp.web.Application
:return: dict(ok=bool, hook=str, reason=str)
"""
try:
hook = getattr(app, "on_response_prepare", None)
if hook is None:
return {"ok": False, "hook": "on_response_prepare",
"reason": "app 无 on_response_prepare 信号(非 aiohttp Application?)"}
if business_status_on_response_prepare in list(hook):
return {"ok": True, "hook": "on_response_prepare", "reason": "already installed"}
hook.append(business_status_on_response_prepare)
log.info("[init] 业务信封→HTTP 状态行 信号层已装配(on_response_prepare)")
return {"ok": True, "hook": "on_response_prepare", "reason": "installed"}
except Exception as exc: # noqa: BLE001
log.error("[business_status] 信号装配失败: %s", exc)
return {"ok": False, "hook": "on_response_prepare", "reason": str(exc)}
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):本模块中间件虽已 `@web.middleware` 装饰,但「装饰被漏/被覆盖」
# (例如有人新增未装饰的中间件、或以裸函数再导出)就会让 aiohttp 按 legacy 工厂
# `mw(app)` 调用 → request 形参收到 AHApp → `real_ip.py:16` `request.remote`
# AttributeError → 全站 500。这里在**模块级**再幂等规范化一次,把契约钉死在提供方。
def _ensure_new_style(mw, name=None):
"""幂等把中间件规范成 new-style(带 __middleware_version__);不可用时原样返回。"""
try:
from middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
try:
from app.middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
return mw
try:
return _ensure_mw(mw, name=name)
except Exception: # noqa: BLE001
return mw
business_status_middleware = _ensure_new_style(business_status_middleware)

View File

@ -0,0 +1,240 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""R19 运行入口硬守卫链(Bug nrOjOBSxzsHGa1HgElzmn / DGzTjOkr_OYM4vdOYcl2_ /
WhOYy9nvG-EfNgZMvbJbO 的**运行入口根因修复**)。
## 为什么还要这个文件(根因结论,不是又写一遍语义实现)
三条语义的实现早就存在于仓库里:
/healthz 非 GET → 405 + Allow app/entry_semantics.py (BUILTIN_GET_ONLY_PATHS)
业务信封 status>=400 → HTTP 状态行 app/business_status_middleware.py (middleware + 信号)
非公开 .dspy 无会话 → 401 app/auth_gate_strict.py / route_guard_middleware.py
但目标机真实运行入口是 `app/scense.py`(`ps -ef` 唯一进程 `venv/bin/python app/scense.py`),
R10 复测 5/5 全部在部署态失效(HTTP=404 / 200 / 503 / 200)。三处装配缺口:
1. **404 先于 405**:`entry_semantics` 的判定顺序是「资源是否存在」→ 再判方法。
`/healthz` 是 `app.router.add_get()` 注册的**动态路由**,不在 wwwroot 文件树里,
于是「资源不存在」分支先命中 → 404 no such resource,`BUILTIN_GET_ONLY_PATHS` 的
405 分支根本走不到。`/healthz` 自检里 `http_semantics.installed=true, added=2`
指的是**装配两层**(`entry_semantics` 零依赖层 + `semantic_wiring` 完整层)成功,
不是「405 分支命中」,因此自检为真而行为为假。
2. **业务 400 层缺席**:`business_status_middleware` 从未进入
`app/scense.py::_load_semantic_middlewares()` 的构造期清单(清单里只有
notfound / http_semantics / auth_gate_strict 三项)。信号层
`_install_business_status_signal` 挂在 `_on_ahapp_built` 钩子里,而构造期路径
`entry_semantics_ctor.make_app(mws)` 成功时钩子只做幂等核验,不保证 `on_response_prepare`
被挂到最终 aiohttp Application 上;钩子与中间件两条路径**都不在链上** → 4/4 端点
缺参仍 HTTP 200(body 里 status=400)。
3. **鉴权闸门不在链上**:同上,`auth_gate_strict_middleware` 虽然在清单里,但装配走
`entry_semantics_ctor` 构造期 + `notfound_middleware.compose_guard_chain()` 包导入
`__import__("app." + 模块名)`,部署态 `python app/scense.py` 的 sys.path 不含应用根
→ 包导入静默失败 → 组合链降级「仅 404」→ 匿名 `GET /scense/api/list_scense.dspy`
返回 200(同轮 `session_create.dspy` 返 401,说明闸门代码本身没坏,是没上链)。
## 本文件的设计要点(为什么这样修才在运行入口生效)
* **零应用包依赖**:只 import 标准库 + aiohttp + 同目录文件按**绝对路径**加载
(`auth_gate_strict.py` 复用已验证的会话校验),彻底绕开 sys.path 陷阱;
* **顺序即语义**:作为 `app.middlewares` 的**第 1 位(最外层)**装配,
「方法不允许」与「未登录」在业务处理器/404 判定之前返回,天然修复 404 抢答;
* **不掩错**:只处理 405 / 401 / 业务信封 4xx 回写三类;其它异常一律 re-raise,
真实后端缺陷仍暴露为 500。
"""
import importlib.util
import json
import logging
import os
import urllib.parse
from aiohttp import web
log = logging.getLogger("yuanjing.entry_guard_r19")
APP_DIR = os.path.dirname(os.path.abspath(__file__))
#: 内建「只允许读」的路径(精确匹配,禁通配)。新增内建 GET 端点必须逐条登记。
GET_ONLY_PATHS = frozenset((
"/healthz",
"/health",
))
#: 405 时下发的 Allow 头(清单顺序固定,便于断言)。
ALLOW_HEADER = "GET, HEAD, OPTIONS"
#: 视为「读」的安全方法——非此集合即方法不允许。
SAFE_METHODS = frozenset(("GET", "HEAD", "OPTIONS"))
#: 业务信封回写只作用于 .dspy(页面/静态资产语义不动)。
ENVELOPE_SUFFIX = ".dspy"
# ---------------------------------------------------------------- 纯逻辑(可离线桩测)
def normalize_path(path):
"""归一化路径:去掉重复斜杠 / 尾斜杠 / 查询串,并解 URL 编码。
与 `auth_gate_strict.normalize_path` 同口径,保证 `/HEALTHZ/`、
`//healthz`、`/healthz?x=1` 都能命中 GET_ONLY_PATHS,不能靠写法绕过 405。
"""
p = urllib.parse.unquote(str(path or ""))
p = p.split("?", 1)[0].split("#", 1)[0]
segs = [s for s in p.split("/") if s]
p = "/" + "/".join(segs)
return p.lower() if p != "/" else p
def method_not_allowed(path, method):
"""(path, method) 是否需要 405。返回 Allow 头字符串;不需要则 None。
判定在**任何资源存在性检查之前**完成——这是本文件相对 entry_semantics 的
关键差异(后者先判存在性,动态路由 /healthz 被判成「不存在」→ 404)。
"""
p = normalize_path(path)
if p not in GET_ONLY_PATHS:
return None
if str(method or "").upper() in SAFE_METHODS:
return None
return ALLOW_HEADER
def envelope_status(raw_body):
"""从响应体解析业务信封 status(只认 JSON 对象的 4xx/5xx 整数 status)。"""
if not raw_body:
return None
try:
if isinstance(raw_body, bytes):
raw_body = raw_body.decode("utf-8", "replace")
obj = json.loads(raw_body)
except (TypeError, ValueError):
return None
if not isinstance(obj, dict):
return None
code = obj.get("status")
if isinstance(code, bool):
return None
if isinstance(code, int) and 400 <= code <= 599:
return code
return None
def should_rewrite_status(path, http_status, raw_body):
"""是否需要把 HTTP 状态行回写成信封 status。返回目标码或 None。"""
p = normalize_path(path)
if not p.endswith(ENVELOPE_SUFFIX):
return None
if int(http_status or 0) != 200:
return None
return envelope_status(raw_body)
# ---------------------------------------------------------------- 会话闸门(复用已验证实现)
_GATE = {"mod": None}
def _auth_gate():
"""按绝对路径加载 auth_gate_strict.py(部署态 sys.path 不含应用根,禁包导入)。"""
if _GATE["mod"] is not None:
return _GATE["mod"]
path = os.path.join(APP_DIR, "auth_gate_strict.py")
spec = importlib.util.spec_from_file_location("yj_auth_gate_strict_r19", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_GATE["mod"] = mod
return mod
def auth_verdict(path, request):
"""鉴权判定:返回 "public" / "allow" / "deny"。
与 auth_gate_strict 同一套公开清单与会话校验(精确路径,禁通配);
任何异常都 fail-closed 成 deny(绝不 fail-open)。
"""
try:
gate = _auth_gate()
if not gate.gate_enabled():
return "public"
if gate.is_public_path(path):
return "public"
if not gate.is_gated_path(path):
return "public"
# [R21 401 根因补修] auth_gate_strict._has_session() 返回的是
# **(ok, reason) 元组**,不是布尔。原写法 `"allow" if gate._has_session(request)`
# 让任何非空元组(含 (False, "no_session"))都为真 → 匿名请求被判 allow,
# 401 在入口层彻底失效(即便会话校验实现正确)。此处显式取第 0 位布尔,
# 对列表/字典/布尔三种返回形态都做归一,绝不依赖对象真值。
verdict = gate._has_session(request)
if isinstance(verdict, (tuple, list)):
ok = bool(verdict[0]) if verdict else False
elif isinstance(verdict, dict):
ok = bool(verdict.get("ok"))
else:
ok = bool(verdict)
return "allow" if ok else "deny"
except Exception as exc: # noqa: BLE001
log.error("[guard19] 鉴权判定异常,fail-closed 401: %s", exc)
return "deny"
# ---------------------------------------------------------------- 中间件本体
def _json_response(status, payload, extra_headers=None):
headers = {"Cache-Control": "no-store"}
if extra_headers:
headers.update(extra_headers)
return web.json_response(payload, status=status, headers=headers)
@web.middleware # ← aiohttp>=3.9 新式中间件标记(见下方 R19d 说明)
async def entry_guard_middleware(request, handler):
"""入口硬守卫:405(方法语义)→ 401(会话闸门)→ 业务信封 4xx 回写。
[fix-iter17 r19d] 必须带 `@web.middleware`(= 设置 `__middleware_version__ = 1`)。
aiohttp >= 3.9(requirements 锁 `aiohttp>=3.9,<4.0`)在
`Application._prepare_middleware()` 里按该标记区分新旧式:**未标记的普通协程函数
会被当成「旧式工厂」** m(app, handler) 调用,于是本函数收到 request=app、
handler=真实 handler,`await handler(request)` 抛
`TypeError: 'Response' object is not callable` → 每个请求 500;
/healthz 的 405 退化由 aiohttp 路由自身给出(Allow 只有 GET,HEAD,body 为
`405: Method Not Allowed` 纯文本),闸门与信封回写整层失效。
实测证据(本机真实执行回显,非引用):
projects/proj_qEVGJSL3/docs/02-develop/evidence/r19_entry_assembly_verify_20261026.txt
(由 apps/yuanjing/scripts/verify_entry_assembly_r19.py 生成,含 TOTAL=/PASS=/FAIL= 行)
"""
path = getattr(request, "path", "") or ""
# ① 方法语义(先于 404/鉴权;/healthz 等内建只读路径非读方法 → 405 + Allow)
#
# [QC#7] 原写法 `if allow == "GET, HEAD, OPTIONS"` 把返回值退化成布尔信号,
# 与 ALLOW_HEADER 形成两处字面量(改 ALLOW_HEADER 后判定静默漂移),且后面
# 再判一次 OPTIONS(method_not_allowed 已用 SAFE_METHODS 排除了 OPTIONS/GET/HEAD)。
# 现在:返回值即单一真源——None = 不需要 405,非 None = 需要 405 且它就是 Allow 头。
allow = method_not_allowed(path, getattr(request, "method", ""))
if allow:
return _json_response(
405,
{"status": 405, "success": False, "reason": "method not allowed",
"path": normalize_path(path), "allow": allow},
{"Allow": allow},
)
# ② 会话闸门(非公开 .dspy 无会话 → 401,fail-closed)
verdict = auth_verdict(path, request)
if verdict == "deny":
return _json_response(
401,
{"status": 401, "success": False, "reason": "unauthorized",
"path": normalize_path(path)},
)
# ③ 业务信封 status>=400 → HTTP 状态行(只改状态行,不改 body)
resp = await handler(request)
try:
target = should_rewrite_status(path, getattr(resp, "status", 200),
getattr(resp, "body", None))
if target is not None and hasattr(resp, "set_status"):
resp.set_status(target)
log.info("[guard19] 业务信封 status=%s → HTTP %s path=%s",
target, target, normalize_path(path))
except Exception as exc: # noqa: BLE001
log.warning("[guard19] 状态行回写失败(不影响 body): %s", exc)
return resp

View File

@ -0,0 +1,599 @@
#!/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

View File

@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""入口 HTTP 语义健壮装配引导(Bug Wp6gXrrEquzg3njONz60b / INT-404-neg)。
缺陷(本轮复现,2026-10-21 14:21 目标机实测,带 admin cookie)
--------------------------------------------------------------
```
GET /runtime/api/__no_such_endpoint__.dspy -> 500 (期望 404)
GET /nonexist/x.dspy -> 500 (期望 404)
(无 cookie 对照同两条 -> 401,说明鉴权层正常,异常出在鉴权之后)
```
ahserver `processorResource.py:399` 对「URL 无法映射到磁盘资源」抛的是**通用 `Exception`**
(`f'{str(request.url)=} invalid path'`),没有任何 HTTP 语义,一路冒到 aiohttp 默认错误处理,
被渲染成 `500 Internal Server Error / Server got itself in trouble`。
R17-2(Bug 28PuqnfpWzB2ypJoUTU_N)已在 `app/semantic_wiring.py` 写好 404/405/400 中间件,并由
`app/scense.py` 在 `ahapp_built` 钩子里装配。但那条装配链路有一个**结构性静默失败点**:
try:
from app.semantic_wiring import install_semantic_middlewares # ← 依赖 app 包可导入
install_semantic_middlewares(app, entry="app/scense.py")
except Exception:
log.exception(...) # 只打日志,进程照常起,/healthz 照常 200
只要 `app` 包在目标机不可导入(`python app/scense.py` 的 sys.path[0]=<deploy>/app 遮蔽、
投递缺文件、pyc 过期、包名冲突),语义中间件就**一个都装不上**,而系统看起来「全部正常」,
线上 500 原样存在——这正是 R17-2「修了但等于没生效」的机制。
本模块的职责
------------
把装配做成**与 app 包可导入性无关**的两层结构,并让装配结果可远程核验:
* 第 1 层 `entry_semantics`(零应用内依赖):用**绝对文件路径 + importlib** 加载
`app/entry_semantics.py`,装 notfound_guard(→404)+ method_guard(→405)。
这一层不 import 本应用任何包,只要文件在投递包里就一定能装上。
* 第 2 层 `semantic_wiring`(完整语义):同样按绝对路径加载,装
`resource_notfound_middleware` + `http_semantics_middleware`(含 400 参数缺失语义)。
失败仅降级为「第 1 层单层生效」,不再影响 404 语义。
返回结构(供入口写进 app["http_semantics"] 并由 `/healthz` 暴露)::
{"installed": bool, # 至少一层装上且含 notfound 守卫
"degraded": bool, # 仅第 1 层生效(完整语义未装上)
"layers": {"entry_semantics": {...}, "semantic_wiring": {...}},
"middlewares": [...], "added": int, "error": str|None}
铁律:**绝不抛出**——装配点在 ahapp_built 钩子里,抛错会让 run_app 失败、整机启不来。
"""
import datetime
import importlib.util
import json
import logging
import os
import traceback
log = logging.getLogger("scense.entry.semantics_boot")
APP_DIR = os.path.dirname(os.path.abspath(__file__))
#: 零应用内依赖层(404 + 405)
LAYER_ZERO_DEP = "entry_semantics"
#: 完整语义层(404 + 405 + 400)
LAYER_FULL = "semantic_wiring"
#: 判定「notfound 守卫是否生效」的中间件名集合(两层任一名命中即算生效)
LAYER_AUTH_GATE = "auth_route_gate"
AUTH_GATE_NAMES = ("auth_route_gate_middleware",)
NOTFOUND_GUARD_NAMES = ("entry_notfound_guard", "resource_notfound_middleware")
METHOD_GUARD_NAMES = ("entry_method_guard", "http_semantics_middleware")
def middleware_names(app):
"""取当前 app.middlewares 的函数名列表(用于装配后置核验)。"""
return [getattr(m, "__name__", repr(m)) for m in (getattr(app, "middlewares", None) or [])]
def load_module_by_file(name, filename):
"""按绝对文件路径加载模块(不依赖 sys.path / 包导入)。
`name` 只作为 sys.modules 里的键;加载失败抛异常,由调用方降级处理。
"""
path = os.path.join(APP_DIR, filename)
if not os.path.isfile(path):
raise OSError("模块文件不存在: %s" % path)
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ImportError("无法为 %s 构造 import spec" % path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _install_zero_dep(app, web=None):
"""第 1 层:entry_semantics(零应用内依赖,绝对路径加载)。"""
res = {"layer": LAYER_ZERO_DEP, "ok": False, "added": [], "error": None}
try:
mod = load_module_by_file("_entry_semantics_zero_dep", "entry_semantics.py")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
try:
out = mod.install(app, web=web) if web is not None else mod.install(app)
res["ok"] = bool(out.get("ok"))
res["added"] = list(out.get("added") or [])
res["error"] = out.get("error")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
def _install_full(app, entry="app/scense.py"):
"""第 2 层:semantic_wiring(完整 404/405/400 语义,绝对路径加载)。"""
res = {"layer": LAYER_FULL, "ok": False, "added": [], "error": None, "evidence": None}
try:
mod = load_module_by_file("_semantic_wiring_full", "semantic_wiring.py")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
try:
out = mod.install_semantic_middlewares(app, entry=entry) or {}
res["ok"] = bool(out.get("ok"))
res["added"] = list(out.get("added") or out.get("added_names") or [])
res["error"] = out.get("error")
res["evidence"] = out.get("evidence")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
def _install_auth_route_gate(app):
"""第 3 层:统一鉴权门禁(存在性→鉴权,404/401 三态互不覆盖)。
绝对路径加载 auth_route_gate.py(零应用内依赖),插到中间件链**最外层**,
保证先于 ahserver 鉴权层执行(否则框架自身对不存在路径的 401 会抢在 404 之前)。
永不抛出。
"""
res = {"layer": LAYER_AUTH_GATE, "ok": False, "added": [], "error": None,
"position": None}
try:
mod = load_module_by_file("_auth_route_gate", "auth_route_gate.py")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
try:
out = mod.install(app) or {}
res["ok"] = bool(out.get("ok"))
res["added"] = list(out.get("added") or [])
res["position"] = out.get("position")
res["error"] = out.get("error")
res["middlewares"] = out.get("middlewares")
except Exception as exc: # noqa: BLE001
res["error"] = "%s: %s" % (type(exc).__name__, exc)
res["traceback"] = traceback.format_exc()
return res
def _install_http_semantics_impl(app, entry="app/scense.py", full=True, web=None):
"""两层装配实现(内部函数,勿直接调用;对外入口是文件末尾的
install_http_semantics 包装器,它在此基础上追加 evidence 落盘)。
**永不抛出**。
幂等:两层安装器自身按中间件名字去重,重复调用不会重复 append。
"""
result = {"entry": entry, "installed": False, "degraded": False,
"layers": {}, "middlewares": [], "added": 0, "error": None}
try:
before = middleware_names(app)
zero = _install_zero_dep(app, web=web)
result["layers"][LAYER_ZERO_DEP] = {k: v for k, v in zero.items() if k != "traceback"}
if not zero["ok"]:
log.error("[semantics_boot] 零依赖层装配失败:%s", zero.get("error"))
if full:
fll = _install_full(app, entry=entry)
result["layers"][LAYER_FULL] = {k: v for k, v in fll.items() if k != "traceback"}
if not fll["ok"]:
log.error("[semantics_boot] 完整语义层装配失败(降级为 404/405 单层):%s",
fll.get("error"))
else:
result["layers"][LAYER_FULL] = {"layer": LAYER_FULL, "ok": False,
"skipped": True, "error": "switch_disabled"}
gate = _install_auth_route_gate(app)
result["layers"][LAYER_AUTH_GATE] = {k: v for k, v in gate.items() if k != "traceback"}
if not gate.get("ok"):
log.error("[semantics_boot] 统一鉴权门禁装配失败:%s", gate.get("error"))
names = middleware_names(app)
result["middlewares"] = names
result["added"] = len([n for n in names if n not in before]) if before else len(names)
has_auth_gate = any(n in AUTH_GATE_NAMES for n in names)
has_notfound = any(n in NOTFOUND_GUARD_NAMES for n in names)
has_method = any(n in METHOD_GUARD_NAMES for n in names)
result["installed"] = bool(has_notfound and has_method and has_auth_gate)
result["auth_gate"] = {"present": has_auth_gate, "layer": LAYER_AUTH_GATE}
result["degraded"] = bool(result["installed"]
and not result["layers"][LAYER_FULL].get("ok"))
if not result["installed"]:
result["error"] = ("语义中间件缺失 notfound=%s method=%s auth_gate=%s names=%s"
% (has_notfound, has_method, has_auth_gate, names))
log.info("[semantics_boot] installed=%s degraded=%s added=%d names=%s",
result["installed"], result["degraded"], result["added"], names)
except Exception as exc: # noqa: BLE001
result["error"] = "%s: %s" % (type(exc).__name__, exc)
result["traceback"] = traceback.format_exc()
log.error("[semantics_boot] 装配整体异常(不抛出,应用继续启动):%s", result["error"])
return result
# ── 装配结果落盘(Bug Wp6gXrrEquzg3njONz60b:装配是否真生效不能只靠日志)──
# 线上是否真装上语义中间件,此前只能从进程日志推断(日志 roll 掉就无证)。这里把
# install_http_semantics 的返回值(installed / degraded / layers / middlewares)落成
# JSON 证据文件,QC 与部署角色可直接读取,且**两个入口共用同一份代码**(app/scense.py
# 与 app/yuanjing.py 都走本函数),避免"只在一个入口修"的同类缺陷复发。
# 落盘失败绝不影响启动(语义装配优先),只在日志告警。
WIRING_EVIDENCE_REL = os.path.join("docs", "evidence", "http_semantics_wiring.json")
def persist_wiring(result, entry=None, base_dir=None):
"""把装配结果写入 <app dir>/docs/evidence/http_semantics_wiring.json(永不抛出)。"""
try:
root = base_dir or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(root, WIRING_EVIDENCE_REL)
os.makedirs(os.path.dirname(path), exist_ok=True)
payload = {
"ts": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"pid": os.getpid(),
"entry": entry or (result or {}).get("entry"),
"installed": bool((result or {}).get("installed")),
"degraded": bool((result or {}).get("degraded")),
"middlewares": list((result or {}).get("middlewares") or []),
"added": (result or {}).get("added"),
"error": (result or {}).get("error"),
"layers": (result or {}).get("layers") or {},
"evidence_path": path,
}
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fp:
json.dump(payload, fp, ensure_ascii=False, indent=2, default=str)
os.replace(tmp, path)
log.info("[semantics_boot] 装配证据已落盘 %s installed=%s degraded=%s",
path, payload["installed"], payload["degraded"])
return payload
except Exception as exc: # noqa: BLE001
log.warning("[semantics_boot] 装配证据落盘失败(不影响启动):%s: %s",
type(exc).__name__, exc)
return None
def install_http_semantics(app, entry="app/scense.py", full=True, web=None):
"""install_http_semantics + 装配证据落盘(保持原签名与返回结构,永不抛出)。"""
result = _install_http_semantics_impl(app, entry=entry, full=full, web=web)
try:
result["evidence"] = persist_wiring(result, entry=entry)
except Exception: # noqa: BLE001
result["evidence"] = None
return result

View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""语义中间件的**构造期装配**(Bug Wp6gXrrEquzg3njONz60b / INT-404-neg)。
为什么需要这个文件(根因)
--------------------------
`ahserver` 的 AHApp 是 aiohttp `Application` 的子类。aiohttp 的中间件链在
`Application._make_handler()` 里**一次性**构建:
middlewares = self._prepare_middleware() # 读 self._middlewares
handler = reduce(lambda h, mw: partial(mw, h), middlewares, handler)
链一旦构建完成,再往 `app.middlewares` append 不会进入已构建的链(`FrozenList`
在 `freeze()` 之后 append 直接 `RuntimeError`)。`Application.middlewares` 只有
property、**没有 setter**,所以历史代码里的
app.middlewares = mws # → AttributeError: can't set attribute
app.middlewares.append(mw) # → freeze 之后 RuntimeError
两条路径都会被 `install_semantic_middlewares()` 里的 `except Exception` 吞掉,
装配结果只落到一个 `ok=False` 的 dict 与一行 ERROR 日志:进程照常启动、
`/healthz` 照常 200、**不存在端点照旧 500**。这就是「R17-2 修复已在仓库但线上
仍然 500」的机制——不是没写修复代码,而是修复代码**装配在一个不会生效的时刻**。
修复策略(本文件)
------------------
把装配从「事后 append」改成「**构造参数**」:
app = make_app([resource_notfound_middleware, http_semantics_middleware])
webapp(init, app=app) # ahserver 公开钩子:传入 app 则直接用,不再自建
构造期传入的中间件天然在 `_make_handler()` 之前就位,且列表顺序 = 装饰顺序
(aiohttp:**列表第一个是最外层**),因此 404 守卫包住包括 ahserver 内置处理器
在内的整个链,`invalid path` 异常能被稳定改写为 404。
设计约束
--------
* 零应用内依赖:只 import aiohttp 与(可选的)ahserver,不 import 本应用任何模块,
避免「`python app/scense.py` 时 sys.path[0] 是 app/ 子目录」造成的包导入失败;
* 探测不到 AHApp 类时**不静默成功**:退回 `None`,由入口走框架自建路径并在
`/healthz` 里如实暴露 `ctor=unavailable`,让部署方能看到「这一层没生效」;
* 不做任何 `except Exception: pass`——任何意外都以 `SemanticWiringError` 抛出或
由调用方显式记录 degraded。
"""
import logging
log = logging.getLogger("yuanjing.entry_semantics_ctor")
class SemanticWiringError(RuntimeError):
"""构造期语义装配失败(不允许被静默吞掉)。"""
# AHApp 的定义位置随 ahserver 版本移动过,按「新→旧」顺序探测。
# 只做 import 探测,不做任何副作用。
_AHAPP_CANDIDATES = (
("ahserver.configuredServer", "AHApp"),
("ahserver.webapp", "AHApp"),
("ahserver.ahapp", "AHApp"),
("ahserver", "AHApp"),
)
def load_ahapp_class():
"""返回 AHApp 类;探测不到返回 ``(None, 原因字符串)``。"""
errors = []
for mod_name, attr in _AHAPP_CANDIDATES:
try:
mod = __import__(mod_name, fromlist=[attr])
except ImportError as exc:
errors.append("%s: ImportError(%s)" % (mod_name, exc))
continue
cls = getattr(mod, attr, None)
if cls is not None:
return cls, ""
errors.append("%s: 无属性 %s" % (mod_name, attr))
return None, "; ".join(errors)
def _mw_name(mw):
return getattr(mw, "__name__", getattr(mw, "__qualname__", repr(mw)))
def make_app(middlewares, app_kwargs=None):
"""构造 AHApp 并**在构造期**带上 middlewares。
:param middlewares: 语义中间件序列(顺序 = 外层优先)
:param app_kwargs: 透传给 AHApp 的其它关键字(如 ``client_max_size``)
:return: (app, info) —— info 记录装配事实,供 /healthz 与证据文件回读
:raises SemanticWiringError: AHApp 探测不到(调用方必须显式降级,不得当成功)
"""
cls, reason = load_ahapp_class()
if cls is None:
raise SemanticWiringError("AHApp 类探测失败:" + reason)
mws = [m for m in (middlewares or ()) if m is not None]
kwargs = dict(app_kwargs or {})
kwargs["middlewares"] = mws
try:
app = cls(**kwargs)
except TypeError as exc: # AHApp 不认识 middlewares 关键字
raise SemanticWiringError(
"AHApp(%s) 不接受 middlewares 关键字:%s" % (getattr(cls, "__module__", "?"), exc))
info = {
"ctor": "ok",
"ahapp_class": "%s.%s" % (getattr(cls, "__module__", "?"), getattr(cls, "__name__", "?")),
"requested": [_mw_name(m) for m in mws],
"applied": [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])],
}
log.info("[ctor] AHApp 构造期装配 middlewares=%s class=%s",
info["applied"], info["ahapp_class"])
return app, info
def verify(app, expected_names):
"""核验 app 的中间件链是否真的含期望中间件(只读,不改 app)。
:return: {"ok": bool, "names": [...], "missing": [...]}
"""
try:
names = [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])]
except Exception as exc: # noqa: BLE001
return {"ok": False, "names": [], "missing": list(expected_names),
"error": "%s: %s" % (type(exc).__name__, exc)}
missing = [n for n in (expected_names or ()) if n not in names]
return {"ok": not missing, "names": names, "missing": missing}

View File

@ -0,0 +1,558 @@
#!/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)

View File

@ -0,0 +1,799 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""yuanjing / scense 应用级 HTTP 语义中间件(404 / 405 / 400 / .ui 渲染链路守卫)。
R41 收口(2026-10-23,4 条「期望 400 / 实测 500」缺参业务语义归位)
--------------------------------------------------------------
Bug:VDZUFp42a-9flnNAl95ST / V40hwlHuiPR7F3aq5B_NF / 7cRqNE8v6DxfhM_kztJwr /
ldfABwx6m4Vx9RafSUvnu(证据:projects/proj_qEVGJSL3/deploy/upgrade-result-r1.json 9-verify)
口径(本文件是实现单点,禁止逐端点改生成产物):
* ⑤ LOGIN_REQUIRED_PARAMS:登录端点缺参 → 400 + missing:[...](不再把 None 灌进 rc4._crypt)
* ⑥ DB 缺参类错误收敛:1364(Field 'x' doesn't have a default value)/
1048(Column 'x' cannot be null)→ 400 + field:x
* 不掩错:非 1364/1048 的真实后端异常仍 500;不存在端点 → 404;正常读端点空参 → 200
离线复验:scripts/verify_r41_four_bugs.py(21/21 PASS,rc=0)、scripts/verify_login_param_400.py
目标机实探:curl -s -o /dev/null -w '%{http_code}\n' -X POST -d '{}' <base>/rbac/user/up_login.dspy → 400
(待 test/deploy 阶段在目标机执行)
历史来源(Bug 族)
------------------
* 404:DO2jJM8OLYtFS3QTBjwpK / hqsutOGHBmthByQ8Ea4mW / tfZ2JPtf0GbTTTiwqHEin
—— ahserver 的 processorResource 对「路径不存在」抛通用 Exception
(`raise Exception(f'{str(request.url)=} invalid path')`)→ 被兜底成 500,
与真实后端故障不可区分,并污染「全站 *.dspy 500 数 = 0」门禁(R17-4)。
* 405:i5rCHDGdmv926crwBjV5i —— 方法不允许(GET 登录端点)应为 405 + Allow 头。
* 400:iFsP7Yklktq_AixTnEWYi / **R17-3c(1 ldfABwx6m4Vx9RafSUvnu 的同族)**
—— 缺必填参数时应 400;模块把 Python None 拼进 SQL → 500(entity_create{} 曾返回 200、
world_create{} 返回 500、runtime_control{} 返回 200)。
* **R17-2(Bug 28PuqnfpWzB2ypJoUTU_N)**:本文件所在应用仓 apps/yuanjing 不是目标机实际
运行入口(运行的是 `app/scense.py`),中间件从未被装配 → 上述修复在目标机不生效。
该装配问题在 `app/semantic_wiring.py` + `app/scense.py` 修复;本文件只负责语义实现。
* **R36 后回归 P-01/F-02(Bug 906IGBEVANkne-og1cv0r,P0)**:6 个 .ui 页面全 500。
根因是 ahserver 的 `BricksUIProcessor.datahandle()` 渲染任何 .ui 前都无条件取
`/bricks/header.tmpl` 与 `/bricks/footer.tmpl`;目标机 `wwwroot/bricks` 从未建立
(build.sh 旧软链循环只认 `pkgs/<m>/wwwroot`,而前端包 bricks 的资源在
`pkgs/bricks/bricks/`)→ `ProcessorResource.path_call()` 里 `processor=None`
直接 `await processor.be_call(...)` → `AttributeError` → **每个** .ui 都 500,
而 .html/.js/.dspy 全正常。本文件负责两件事:
①(修复)请求进入 bui 渲染器之前,确保 `/bricks/{header,footer}.tmpl` 可解析
—— 见 `ensure_ui_render_ready()` 与 `app/ui_render_guard.py`(启动期自愈 +
每请求快速复核,自愈动作有节流);
②(F-02 口径).ui 纳入方法门禁:DELETE/PUT/PATCH/POST → 405 + `Allow: GET, HEAD, OPTIONS`。
设计约束(不掩错)
------------------
只改写 **404(资源不存在)/ 405(方法不允许)/ 400(缺必填参数)** 三类;
其余异常一律 re-raise,保证真实后端缺陷仍然是 500,不把生产故障伪装成客户端错误。
**唯一例外**是 .ui 渲染链路的 `AttributeError: 'NoneType' object has no attribute 'be_call'`
——它不是业务缺陷,而是「bricks 共享片段缺失」的确定性症状(见 app/ui_render_guard.py),
本中间件把它转成 **503 + 结构化原因 + 完整 traceback 日志**(比 55 字节裸 500 可诊断),
且只在 path 以 .ui 结尾且 missing 片段可指名时才转,其余 AttributeError 照旧上抛。
框架源码只读(base 模块 apppublic/sqlor/ahserver/rbac/bricks 任何项目不得修改),
故所有修正都落在应用层中间件。
"""
import importlib.util
import sys
import json
import logging
import os
import re
import time
from aiohttp import web
log = logging.getLogger("yuanjing.http_semantics")
# ahserver 路径解析失败的异常文本签名(processorResource.py 原文,与 app/notfound_middleware.py 同源)
RESOURCE_MISSING_SIGNATURES = (
"invalid path",
"fpath is None",
)
# 「方法不允许」的异常类型/文本签名
METHOD_NOT_ALLOWED_TYPE_HINTS = (
"HTTPMethodNotAllowed",
"MethodNotAllowed",
)
METHOD_NOT_ALLOWED_SIGNATURES = (
"Method Not Allowed",
"method not allowed",
"405 Method",
)
# --------------------------------------------------------------------------
# ② 方法白名单(Bug i5rCHDGdmv926crwBjV5i / R17-3 的 DELETE/PUT 语义)
# --------------------------------------------------------------------------
# .dspy 端点全部是「读列表 / 提交表单」语义,只接受 GET/POST/HEAD/OPTIONS;
# .ui 页面是**只读页面**,只接受 GET/HEAD/OPTIONS。
# DELETE / PUT / PATCH 必须 405 + Allow 头(RFC 7231 §6.5.5)。
ALLOWED_METHODS = ("GET", "POST", "HEAD", "OPTIONS")
#: .ui 页面允许的方法(页面无写入语义)——F-02 口径:DELETE /index.ui 必须 405 + Allow
PAGE_ALLOWED_METHODS = ("GET", "HEAD", "OPTIONS")
#: 方法门禁覆盖的后缀(Bug 906IGBEVANkne-og1cv0r 起纳入 .ui)
_METHOD_GATE_SUFFIXES = (".dspy", ".ui")
# 逐端点方法端口收窄(缺省用 ALLOWED_METHODS)
PORT_METHOD_OVERRIDE = {
# 登录端点只接受 POST(此前 GET 返回 500,R17-3 期望 405 + Allow)
"/rbac/user/up_login.dspy": ("POST", "OPTIONS"),
}
# --------------------------------------------------------------------------
# ③ 必填参数门禁(Bug iFsP7Yklktq_AixTnEWYi / R17-3c ldfABwx6m4Vx9RafSUvnu)
# --------------------------------------------------------------------------
# 逐端点显式必填参数(参数名可在部署库/CRUD 定义核对;未配置的端点走下面的「零参数」判据)
REQUIRED_PARAM_ENDPOINTS = {
"/appbase/get_code.dspy": ("table",),
}
# 「零参数即无意义」的端点:写操作(add/create/delete/update/save/remove/reset/register/
# import/export)在没有任何可用参数时不可能成功——sqlor 会把 None 拼进 SQL 并抛异常(500)。
# 按「最后一段路径名」的词干模式判定,覆盖 xls2ui 生成的全部增删端点。
_ZERO_PARAM_STEM_RE = re.compile(
r"(^|_)(add|create|delete|update|save|remove|reset|register|import|export|publish|enable|disable)(_|$)",
re.IGNORECASE,
)
# 词干模式覆盖不到、但实测「空请求 → 500」的端点(R17-4 矩阵 36 条中的显式清单)。
# 依据:docs/04-deploy/evidence_r17/r17_dspy_codes_excl_appbase.txt
ZERO_PARAM_UNSAFE_PATHS = frozenset((
# bricks 示例(基础模块只读,无法在模块内修,故在应用层兜住语义)
"/bricks/examples/channels.dspy",
"/bricks/examples/list_tables.dspy",
"/bricks/examples/tableinfo.dspy",
# rbac 基础模块:列表/检索端点缺参时把 None 拼进 SQL
"/rbac/find_unauth_files.dspy",
"/rbac/list_path_roles.dspy",
# scene 搜索类端点(type/status/world_id 为必填检索条件)
"/scene/api/get_search_scene_type.dspy",
"/scene/api/get_search_status.dspy",
"/scene/api/get_search_world_id.dspy",
# runtime 控制端点(action/command 为必填)
"/runtime/api/runtime_control.dspy",
))
# --------------------------------------------------------------------------
# ⑤ 登录/认证端点必填参数 → 400(R41:缺参业务语义归位)
# --------------------------------------------------------------------------
# 探针(deploy/upgrade-r1.sh 第 419/430/448/449 行)对下列 4 条 Bug 使用**同一条**请求:
# POST /rbac/user/up_login.dspy -d '{}' 期望 400,实测 500
# VDZUFp42a-9flnNAl95ST / V40hwlHuiPR7F3aq5B_NF / 7cRqNE8v6DxfhM_kztJwr / ldfABwx6m4Vx9RafSUvnu
#
# 实测根因(目标机 /d/scense/scense_app/logs/scense.log 原文,2026-10-23):
# ahserver/auth_api.py:189 except='NoneType' object is not iterable
# appPublic/rc4.py:22 _crypt: for char in data: # data = password = None
# 即:登录端点被当作「不吃业务参数的放行端」跳过参数校验,请求体缺 username/password 时
# password_encode(None) 直接崩成 500(前端看到 "Server got itself in trouble")。
# rbac 属只读基础模块(框架源码任何项目不得修改),故校验落在应用层中间件。
LOGIN_REQUIRED_PARAMS = {
"/rbac/user/up_login.dspy": (
("username", ("username", "user", "name", "login")),
("password", ("password", "passwd", "pwd")),
),
"/rbac/user/login.dspy": (
("username", ("username", "user", "name", "login")),
("password", ("password", "passwd", "pwd")),
),
}
def missing_login_params(path, params):
"""登录端点缺失的必填参数(返回 canonical 名列表);非登录端点返回 []。"""
specs = LOGIN_REQUIRED_PARAMS.get(path or "")
if not specs:
return []
lowered = {}
for k, v in (params or {}).items():
lowered[str(k).lower()] = v
missing = []
for canonical, aliases in specs:
hit = False
for alias in aliases:
val = lowered.get(alias)
if val is not None and str(val).strip() != "":
hit = True
break
if not hit:
missing.append(canonical)
return missing
# --------------------------------------------------------------------------
# ⑤b 登录端点多源取值与判定(R49-b:修「提交了合法凭据仍被判 missing → 400」)
# --------------------------------------------------------------------------
# 实现单点在 app/login_param_source.py(按文件路径加载,与 _load_ui_guard / _get_only_405
# 同策略:`python app/scense.py` 启动时 sys.path[0]=app/,不能依赖 `app.` 包导入)。
_login_param_source_mod = {"loaded": False, "mod": None}
def _login_param_source():
"""加载 app/login_param_source.py;失败返回 None(调用方退回旧单源口径)。"""
if _login_param_source_mod["loaded"]:
return _login_param_source_mod["mod"]
_login_param_source_mod["loaded"] = True
mod = sys.modules.get("login_param_source")
if mod is None:
path = os.path.join(_APP_DIR, "login_param_source.py")
if os.path.isfile(path):
try:
spec = importlib.util.spec_from_file_location("_yj_login_param_source", path)
if spec is not None and spec.loader is not None:
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
mod = m
except Exception: # noqa: BLE001
log.exception("[login-params] login_param_source.py 加载失败(退回单源口径)")
mod = None
_login_param_source_mod["mod"] = mod
return mod
def judge_login_gate(path, params, evidence, lps):
"""返回 (verdict, missing);lps 可用走多源判定,否则退回 missing_login_params。
verdict ∈ {"present","missing","unknown"},**只有 missing 才允许出 400**。
"""
if lps is not None:
try:
return lps.judge_required(path, params, evidence, LOGIN_REQUIRED_PARAMS)
except Exception: # noqa: BLE001
log.exception("[login-params] 多源判定异常,退回单源判定:%s", path)
miss = missing_login_params(path, params)
return ("missing" if miss else "present"), miss
# --------------------------------------------------------------------------
# ⑥ 「参数语义的 DB 错误」→ 400(1364/1048 收敛,其余异常仍 500)
# --------------------------------------------------------------------------
# xls2ui 生成型写 CRUD(wwwroot/{alias}/ 只读构建产物)无法逐条补校验,
# 缺必填列时 DB 直接报 NOT NULL 无默认值。这类错误的语义是**客户端缺参数**,
# 不是服务端故障,因此在中间件统一收敛为 400(与 NSNiMg-qSjj0aPRpgHBgD /
# Re30-1mhyrwOHEBtSKdX5「26 条写类 CRUD 500→400」合并处理,不再逐端点改生成产物)。
_DB_PARAM_ERROR_PATTERNS = (
(re.compile(r"\(1364,\s*[\"']?Field '([^']+)' doesn't have a default value", re.IGNORECASE),
"missing required parameter"),
(re.compile(r"\(1048,\s*[\"']?Column '([^']+)' cannot be null", re.IGNORECASE),
"missing required parameter"),
(re.compile(r"doesn't have a default value", re.IGNORECASE),
"missing required parameter"),
(re.compile(r"cannot be null", re.IGNORECASE),
"missing required parameter"),
)
def db_error_as_param_error(exc):
"""识别 1364/1048 类缺参数 DB 错误 → (reason, extra);非此类返回 None(保持 500)。"""
text = "%s %s" % (type(exc).__name__, exc)
for pat, reason in _DB_PARAM_ERROR_PATTERNS:
m = pat.search(text)
if m:
extra = {}
if m.groups():
extra["field"] = m.group(1)
return reason, extra
return None
_JSON_SUFFIXES = (".dspy", ".json", ".do", ".api")
# --------------------------------------------------------------------------
# ④ .ui → bricks(bui) 渲染链路守卫(Bug 906IGBEVANkne-og1cv0r,P0)
# --------------------------------------------------------------------------
# bui 渲染器对**每一个** .ui 都无条件取这两个共享片段;任一不可解析即全部 .ui 500。
BRICKS_REQUIRED_ASSETS = ("header.tmpl", "footer.tmpl")
#: 每请求快速复核的结果缓存(秒),避免每个 .ui 请求都做一次 stat
_UI_PROBE_TTL = 5.0
#: 自愈节流(秒):资源真的缺失时不至于每个请求都重试建链并刷日志
_UI_HEAL_MIN_INTERVAL = 60.0
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_DEPLOY_DIR = os.path.dirname(_APP_DIR)
_ui_probe_state = {"ts": 0.0, "ok": None, "last_heal": 0.0}
def _load_ui_guard():
"""按**文件路径**加载 app/ui_render_guard.py(不依赖 `app` 包可导入,与入口同策略)。"""
path = os.path.join(_APP_DIR, "ui_render_guard.py")
spec = importlib.util.spec_from_file_location("_scense_ui_render_guard_hsm", path)
if spec is None or spec.loader is None:
raise ImportError("无法为 %s 构造 import spec" % path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def install_ui_render_guard(deploy_dir=None):
"""模块导入期装配守卫(幂等,fail-soft)。返回诊断 dict,失败返回 None。"""
try:
guard = _load_ui_guard()
summary = guard.install_entry_guard(deploy_dir or _DEPLOY_DIR)
log.info("[ui-guard] 装配完成 ok=%s bricks_action=%s .ui问题=%d",
summary.get("ok"), (summary.get("bricks") or {}).get("action"),
len(summary.get("problems") or []))
return summary
except Exception: # noqa: BLE001
log.exception("[ui-guard] 装配失败(不影响请求处理;缺陷仍由 build.sh 门禁兜住)")
return None
def _missing_assets(directory):
return [f for f in BRICKS_REQUIRED_ASSETS
if not os.path.isfile(os.path.join(directory, f))]
def ensure_ui_render_ready(force=False):
"""请求路径的快速复核 + 按需自愈;返回 (ok: bool, missing: list, heal: dict|None)。
快路径:5 秒内命中缓存直接返回,不碰文件系统。
慢路径:stat `wwwroot/bricks/{header,footer}.tmpl`;缺失时调 ui_render_guard 自愈,
自愈动作按 _UI_HEAL_MIN_INTERVAL 节流。
"""
now = time.time()
if (not force and _ui_probe_state["ok"] is not None
and now - _ui_probe_state["ts"] < _UI_PROBE_TTL):
return _ui_probe_state["ok"], [], None
mount = os.path.join(_DEPLOY_DIR, "wwwroot", "bricks")
missing = _missing_assets(mount)
heal = None
if missing and (force or now - _ui_probe_state["last_heal"] >= _UI_HEAL_MIN_INTERVAL):
_ui_probe_state["last_heal"] = now
try:
guard = _load_ui_guard()
heal = guard.ensure_bricks_assets(deploy_dir=_DEPLOY_DIR)
missing = list(heal.get("missing") or [])
except Exception: # noqa: BLE001
log.exception("[ui-guard] 请求期自愈失败")
ok = not missing
_ui_probe_state["ok"] = ok
_ui_probe_state["ts"] = now
return ok, missing, heal
def is_ui_path(path):
""".ui 页面路径判定。"""
return bool(path) and path.endswith(".ui")
def is_bui_chain_broken(exc):
"""异常是否就是「bricks 共享片段缺失 → processor=None → be_call」这一确定症状。"""
msg = str(exc) or ""
if type(exc).__name__ != "AttributeError":
return False
return ("'NoneType' object has no attribute 'be_call'" in msg
or "'NoneType' object has no attribute 'call'" in msg)
def is_method_gated(path):
"""该路径是否属于方法门禁覆盖范围(.dspy 端点 + .ui 页面)。"""
if not path:
return False
return any(path.endswith(sfx) for sfx in _METHOD_GATE_SUFFIXES)
def is_resource_missing(exc):
"""异常是否为「资源不存在」语义(ahserver 路径解析失败),而非真实服务端缺陷。"""
msg = str(exc) or ""
return any(sig in msg for sig in RESOURCE_MISSING_SIGNATURES)
def is_method_not_allowed(exc):
"""异常是否为「方法不允许」语义。"""
tn = type(exc).__name__
if any(h in tn for h in METHOD_NOT_ALLOWED_TYPE_HINTS):
return True
msg = str(exc) or ""
return any(sig in msg for sig in METHOD_NOT_ALLOWED_SIGNATURES)
def allowed_methods_for(path):
"""该路径允许的 HTTP 方法元组。"""
if path in PORT_METHOD_OVERRIDE:
return PORT_METHOD_OVERRIDE[path]
if is_ui_path(path):
# 页面只读:DELETE/PUT/PATCH/POST 一律 405 + Allow: GET, HEAD, OPTIONS(F-02)
return PAGE_ALLOWED_METHODS
return ALLOWED_METHODS
def needs_params(path):
"""该端点是否属于「没有参数就不可能成功」的写/检索端点。"""
if not path or not path.endswith(".dspy"):
return False
if path in REQUIRED_PARAM_ENDPOINTS or path in ZERO_PARAM_UNSAFE_PATHS:
return True
stem = path.rsplit("/", 1)[-1][: -len(".dspy")]
return bool(_ZERO_PARAM_STEM_RE.search(stem))
def _json_body(request, status, reason, extra=None):
path = request.path or ""
payload = {
"status": status,
"success": False,
"reason": reason,
"path": path,
}
if extra:
payload.update(extra)
if path.endswith(_JSON_SUFFIXES):
return web.json_response(payload, status=status)
return web.Response(status=status, text="%d %s: %s" % (status, reason, path),
content_type="text/plain")
# --------------------------------------------------------------------------
# 请求参数收集(query + 路径变量 + POST body)——Bug FVyDUwxMV0RguXfyMDgtC(R19b)
# --------------------------------------------------------------------------
# ⑤/③ 两道门禁都依赖本函数。历史缺陷(2026-10-27 07:17 R19b 目标机实测):
# 必填参数门只对 query string 求值时,application/json / x-www-form-urlencoded /
# multipart-form-data 三种 body 形态都拿不到 username/password,登录端点恒 400,
# 唯一能 200 的形态是把口令塞进 query string(落 access log / Referer,安全缺陷)。
#
# 修复要点(**不再**依赖 `request.can_read_body`,也**不再**依赖 `request.body` 属性):
# 1. body 读取优先取 aiohttp 的 `_read_bytes` 缓存,缓存为空时 `await request.read()`;
# 上游 route_guard_middleware._has_business_params() 可能已把数据流消费掉
# (此后 can_read_body 恒 False),但缓存里仍是完整 body——重复读安全。
# 2. 按 Content-Type 分派解析:multipart(按 boundary 自解析,不依赖
# request.post() 的流状态)→ JSON → form-urlencoded,三者都并入 params。
# 3. 只收非空标量(None / 空串 / 嵌套对象不作为业务参数),与 query 分支同口径。
_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE")
_MULTIPART_BOUNDARY_RE = re.compile(r'boundary="?([^";]+)"?', re.IGNORECASE)
_MULTIPART_NAME_RE = re.compile(r'name="([^"]*)"', re.IGNORECASE)
def _clean_scalar(value):
"""业务参数只接受非空标量(None / 空串 / 容器类型一律丢弃)。"""
if value is None:
return None
if isinstance(value, (dict, list, tuple, set)):
return None
if isinstance(value, bytes):
value = value.decode("utf-8", "replace")
if isinstance(value, str):
text = value.strip()
return value if text != "" else None
return value
def _request_content_type(request):
"""取原始 Content-Type(含 boundary 等参数),header 缺失时回退 request.content_type。"""
ctype = ""
headers = getattr(request, "headers", None)
if headers is not None:
try:
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
except Exception: # noqa: BLE001
ctype = ""
if not ctype:
try:
ctype = getattr(request, "content_type", "") or ""
except Exception: # noqa: BLE001
ctype = ""
return ctype
def parse_multipart_fields(body, content_type):
"""从 multipart/form-data 原始字节里取普通文本字段(跳过文件字段)。
不调用 `request.post()`:上游已消费数据流时 can_read_body=False,post() 的
行为依赖 aiohttp 版本;按 boundary 切分对简单表单稳定可复现。
⚠️ 旧实现用 `body.split("--"+boundary)`,这是同类缺陷(QC 意见 #4,
Bug kYOLWaWQGFcPi4ZqfTezv scene_import 收不到 multipart 文件/字段):
* delimiter 的前导 CRLF 属于分隔符,直接 split 会把上一段结尾的 `\r\n` 留在
下一段头部 → `username` 取值变成 `'admin\r\n--XY\r\nContent-Disposition...'`;
* final boundary `--XY--` 之后的 epilogue 也会被当成一段,产出
`--xy\r\ncontent-disposition: form-data; name` 这类脏 key;
* boundary 从 lower() 后的 Content-Type 取会丢大小写(RFC 2046 大小写敏感)。
现委托 `login_param_source.multipart_plain_fields`(逐段定位 delimiter、
识别 final boundary、剥尾部 CRLF、跳 filename= 字段)。
"""
out = {}
if not body:
return out
m = _MULTIPART_BOUNDARY_RE.search(content_type or "")
if not m:
return out
try:
from .login_param_source import multipart_plain_fields
except Exception: # noqa: BLE001
try:
from login_param_source import multipart_plain_fields
except Exception as exc: # noqa: BLE001
log.warning("[params] multipart 解析器不可用:%s", exc)
return out
for name, raw in multipart_plain_fields(body, m.group(1).strip(),
keep_case=True).items():
value = _clean_scalar(raw)
if value is not None:
out[name] = value
return out
def parse_body_params(body, content_type):
"""把 POST body 字节解析成业务参数字典(JSON / form-urlencoded / multipart 全覆盖)。"""
out = {}
if not body:
return out
ctype = (content_type or "").lower()
if ctype.startswith("multipart/"):
out.update(parse_multipart_fields(body, content_type))
if out:
return out
text = body.decode("utf-8", "replace")
stripped = text.strip()
if stripped.startswith("{") or ("json" in ctype and stripped):
try:
parsed = json.loads(stripped)
except Exception: # noqa: BLE001
parsed = None
if isinstance(parsed, dict):
for k, v in parsed.items():
value = _clean_scalar(v)
if value is not None:
out[k] = value
if out:
return out
for k, v in _parse_qsl(stripped):
value = _clean_scalar(v)
if value is not None:
out[k] = value
return out
def restore_request_body(request, body):
"""把已读走的请求体**放回**,保证下游仍能解析(关键,不可省)。
aiohttp 的 `read()` 只缓存 `_read_bytes`;而 ahserver 的
`processorResource.getPostData()` 走的是 `request.multipart()` /
`request.post()` / `if request.can_read_body: await request.read()`:
* JSON body:`multipart()` 抛 AssertionError(被 ahserver 吃掉)→ `post()` 对
application/json 返回空 → 只靠 `can_read_body` 判断是否 `read()` 再 json.loads;
本中间件读过 body 后 can_read_body 变 False → **handler 拿不到 username/password**。
* form / multipart:`post()`/`multipart()` 直接消费 `_payload` 流 → 读过即为空。
⚠️ 本函数**曾经**用 `aiohttp.payload.BytesPayload` 重建 `_payload`,那是本迭代
P0 单 `_HCoWd3He1axFKKa8ae91` 的崩点:aiohttp 3.10.10 的 Payload/BytesPayload
**没有 `at_eof()`**,而 `request.can_read_body` 的实现是
`return not self._payload.at_eof()` → 下游每次读 `can_read_body` 抛
AttributeError → body POST 全线 500(全站 37/215、写端点 42 条)。
现在实现下沉到 `app/body_restore.py`(A2 方案:`aiohttp.streams.StreamReader`
+ feed_data/feed_eof,该类型有 at_eof;构造不可用时降级为「仅写 _read_bytes、
绝不碰 _payload」)。禁止在本文件重新引入 BytesPayload。
"""
if not body:
return False
try:
from .body_restore import restore_request_payload # 包内导入
except Exception: # noqa: BLE001
try:
from body_restore import restore_request_payload # 顶层脚本直跑
except Exception as exc: # noqa: BLE001
log.warning("[params] body_restore 不可用,跳过请求体回填:%s", exc)
return False
ok, code = restore_request_payload(request, body)
if code == "failed":
log.warning("[params] 请求体回填失败(下游 handler 可能读不到 body):%s",
getattr(request, "path", ""))
return ok
async def _read_request_body(request):
"""读取请求体(幂等)。返回 (body: bytes, ok: bool)。
优先用 aiohttp 的 `_read_bytes` 缓存(`await request.read()` 之后即存在),
缓存为空时再 `await request.read()`;两者都**不**受 can_read_body 影响,
因此「上游中间件已读走 body」与「本中间件自己读」两种时序都能拿到内容。
读到内容后回填 payload(restore_request_body),保证 handler 侧仍可解析。
"""
cached = getattr(request, "_read_bytes", None)
if isinstance(cached, (bytes, bytearray)):
body = bytes(cached)
restore_request_body(request, body)
return body, True
try:
body = await request.read()
except Exception: # noqa: BLE001
log.warning("[params] 读取请求体失败,按无 body 处理:%s", getattr(request, "path", ""))
return b"", False
if isinstance(body, (bytes, bytearray)):
body = bytes(body)
restore_request_body(request, body)
return body, True
return b"", True
async def collect_request_params(request):
"""收集请求携带的**非空**参数(query + 路径变量 + POST JSON/form/multipart body)。
返回 (params: dict, body_read_ok: bool)。body 通过 aiohttp 的读缓存读取,
处理器随后再读同一请求体不受影响(aiohttp 复用 `_read_bytes` 缓存)。
"""
params = {}
for k, v in request.query.items():
value = _clean_scalar(v)
if value is not None:
params[k] = value
for k, v in (request.match_info or {}).items():
value = _clean_scalar(v)
if value is not None:
params[k] = value
body_read_ok = True
if request.method in _BODY_METHODS:
body, body_read_ok = await _read_request_body(request)
if body:
try:
params.update(parse_body_params(body, _request_content_type(request)))
except Exception: # noqa: BLE001
log.exception("[params] body 解析失败(缺参门禁只看 query/路径参数):%s",
getattr(request, "path", ""))
return params, body_read_ok
def _parse_qsl(text):
try:
from urllib.parse import parse_qsl
return parse_qsl(text, keep_blank_values=True)
except Exception: # noqa: BLE001
return []
def _get_only_405():
"""取 `app/get_only_405.py::maybe_405`(绝对路径优先,不依赖 sys.path)。
与 `app/notfound_middleware.py::_import_attr` 同源:`python app/scense.py` 启动时
sys.path[0] 是 app/,包导入 `app.get_only_405` 会失败,必须能按文件路径加载。
"""
mod = sys.modules.get("get_only_405")
fn = getattr(mod, "maybe_405", None) if mod is not None else None
if fn is not None:
return fn
try:
m = __import__("get_only_405", fromlist=["maybe_405"])
fn = getattr(m, "maybe_405", None)
if fn is not None:
return fn
except Exception: # noqa: BLE001
pass
try:
import importlib.util
app_dir = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(app_dir, "get_only_405.py")
if not os.path.isfile(path):
return None
spec = importlib.util.spec_from_file_location("_yj_get_only_405", path)
if spec is None or spec.loader is None:
return None
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return getattr(m, "maybe_405", None)
except Exception as exc: # noqa: BLE001
log.error("[405] get_only_405 加载失败: %s", exc)
return None
@web.middleware
async def http_semantics_middleware(request, handler):
# --- Bug EPaXUem2: 入口内建只读路径非读方法 → 405 + Allow(pre-handler)
# 真源单点 app/get_only_405.py;命中才接管,未命中一律放行给下面的原逻辑。
_r405 = None
try:
_fn405 = _get_only_405()
if _fn405 is None:
log.error("[405] get_only_405.maybe_405 不可用(方法语义降级,其余语义不受影响)")
else:
_r405 = _fn405(web, request)
except Exception as _exc405: # noqa: BLE001
log.error("[405] 方法语义判定异常(放行到原逻辑): %s", _exc405)
_r405 = None
if _r405 is not None:
return _r405
"""404 / 405 / 400 语义修正 + .ui 渲染链路守卫;其余异常原样上抛(保 500 语义)。"""
path = request.path or ""
# ---- ② 方法不允许 → 405 + Allow(在进入处理器之前拦)----
# 覆盖:
# * .dspy 的 DELETE/PUT/PATCH(历史 Bug i5rCHDGdmv926crwBjV5i)
# * .ui 的 DELETE/PUT/PATCH/POST(Bug 906IGBEVANkne-og1cv0r 的 F-02:此前 DELETE
# /index.ui 会一路落到 bui 渲染器 → 500 且无 Allow 头)
if is_method_gated(path) and request.method not in allowed_methods_for(path):
allow = ", ".join(allowed_methods_for(path))
log.warning("[405] method not allowed (pre-handler): %s %s (Allow: %s)",
request.method, path, allow)
resp = _json_body(request, 405, "method not allowed",
{"method": request.method, "allow": allow,
"hint": "该端点只接受 %s;删除/更新请改用 POST" % allow})
resp.headers["Allow"] = allow
return resp
# ---- ④ .ui 渲染链路:进入 bui 渲染器前确保 /bricks/{header,footer}.tmpl 可解析 ----
# 不接管成功响应,只做「前置就位 + 失败归因」;片段本就正常时走 5s 缓存快路径。
if is_ui_path(path):
ok, missing, heal = ensure_ui_render_ready()
if not ok:
log.error("[ui-render] .ui 渲染片段仍缺失 %s(heal=%s):%s 将无法渲染",
missing, (heal or {}).get("action"), path)
# ---- ⑤ 登录端点必填参数 → 400(缺 username/password 时 password_encode(None) 崩 500)----
# R49-b 修正(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx):
# 原实现只读 collect_request_params() 一路(query + 本层 read() 的 body 字节)。当语义链上
# 游中间件先消费了请求流时,`request.read()` 返回 b"" → body 解析为空 → **已提交的
# username/password 被判成 missing** → 合法登录也 400,全站无会话(P0 阻断)。
# 现改走 app/login_param_source.py:多源取值(框架参数缓存 / query / 路径变量 / 原始 body /
# request.post())+ 证据分级;只有「有证据表明确实没提交」才 400,证据不足一律放行给
# 业务处理器(宁可让真实缺参由下游报错,也不伪造 400 挡死合法凭据)。
if path in LOGIN_REQUIRED_PARAMS:
lps = _login_param_source()
evidence = None
params = None
if lps is not None:
try:
params, evidence = await lps.collect_login_params(request, LOGIN_REQUIRED_PARAMS)
except Exception: # noqa: BLE001
log.exception("[login-params] 多源取值失败,退回旧单源口径:%s", path)
params = None
if params is None:
params, _body_read_ok = await collect_request_params(request)
verdict, miss = judge_login_gate(path, params, evidence, lps)
if verdict == "missing" and miss:
log.warning("[400] login endpoint missing params %s for %s (%s)",
miss, path, lps.masked_evidence(evidence) if lps else "-")
return _json_body(request, 400, "missing required parameter",
{"missing": miss,
"hint": "登录端点必填 username/password;缺参不再落到 "
"password_encode(None) 崩溃成 500"})
if verdict == "unknown":
# 不 400、不掩错:记录证据后放行,由 rbac 自己判定(真实缺参时它会给出自己的错误)
log.warning("[login-params] 必填参数取值证据不足,放行不判 400:%s (%s)",
path, lps.masked_evidence(evidence) if lps else "-")
# ---- ③ 缺必填参数 → 400(避免 None 拼进 SQL 变成 500)----
if needs_params(path) or path in REQUIRED_PARAM_ENDPOINTS:
params, body_read_ok = await collect_request_params(request)
required = REQUIRED_PARAM_ENDPOINTS.get(path, ())
missing = [name for name in required if name not in params]
if not missing and not params and body_read_ok and not required:
# 零参数写/检索端点:请求体读到了、确认一个可用参数都没有
missing = ["<至少一个业务参数>"]
if missing:
log.warning("[400] missing required params %s for %s", missing, path)
return _json_body(request, 400, "missing required parameter",
{"missing": missing,
"hint": "该端点必填参数缺失:补齐后重试(框架不再把 None 拼进 SQL)"})
try:
return await handler(request)
except web.HTTPException:
# aiohttp 自身抛出的 HTTP 语义异常(404/401/403/405…)已是正确状态码,直接放行
raise
except Exception as exc: # noqa: BLE001
if is_method_not_allowed(exc):
allow = ", ".join(allowed_methods_for(path))
log.warning("[405] method not allowed: %s %s (%s)",
request.method, path, type(exc).__name__)
resp = _json_body(request, 405, "method not allowed",
{"method": request.method, "allow": allow,
"hint": "该端点只接受 %s" % allow})
# RFC 7231 §6.5.5:405 响应必须带 Allow 头
resp.headers["Allow"] = allow
return resp
param_err = db_error_as_param_error(exc)
if param_err:
# 1364/1048:客户端缺必填参数导致的 DB 拒绝,语义是 400 而非 500
reason, extra = param_err
log.warning("[400] db param error mapped to 400: %s %s (%s)",
request.method, path, exc)
payload = dict(extra)
payload["hint"] = ("必填字段缺失或为空,DB 拒绝写入(1364/1048);"
"补齐后重试")
return _json_body(request, 400, reason, payload)
if is_ui_path(path) and is_bui_chain_broken(exc):
# bui 渲染器取 /bricks/{header,footer}.tmpl 失败(processor=None)的确定症状:
# 记录**完整**栈(这是原始 500 的证据来源),并给出可执行原因,而非 55 字节裸 500。
log.exception("[ui-render] bui 渲染链路断裂(bricks 共享片段不可解析): %s", path)
ok, missing, heal = ensure_ui_render_ready(force=True)
return _json_body(
request, 503, "ui render chain broken",
{"missing_bricks_assets": missing,
"heal": (heal or {}).get("action"),
"wwwroot_bricks": os.path.join(_DEPLOY_DIR, "wwwroot", "bricks"),
"hint": ("BricksUIProcessor 渲染 .ui 前必须解析 /bricks/header.tmpl 与 "
"/bricks/footer.tmpl;缺失时 processor=None → AttributeError。"
"修复:确保 <deploy>/pkgs/bricks/bricks 已投递并软链为 "
"<deploy>/wwwroot/bricks(Bug 906IGBEVANkne-og1cv0r)")},
)
if is_resource_missing(exc):
log.warning("[404] resource not found: %s -> HTTPNotFound", path)
return _json_body(request, 404, "no such resource",
{"hint": "端点/静态资源不存在:核对 wwwroot 是否已软链、端点名是否与磁盘文件一致"})
raise
# 模块导入期装配:本文件由 app/entry_semantics_boot.py / app/semantic_wiring.py 导入,
# 目标机入口 app/scense.py 经语义装配链一定会走到这里(R17-2 已修复装配),
# 因此导入即执行 = 启动期就把 wwwroot/bricks 修好,不必等第一个 .ui 请求。
install_ui_render_guard()

View File

@ -0,0 +1,501 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""登录端点入参的多源取值与「是否真的缺参」判定(Bug kQkhtciiSmu3EMhovy3Lf / UKeLdiljGVbc7IQZO12nV / Iq_o8E0510HAm7dPpGsZx)。
为什么需要这个模块(实测根因)
------------------------------
`app/http_semantics_middleware.py` 的 ⑤ 号闸门(R41 引入)本意是:**缺** username/password
时早返回 400,避免 None 被灌进 `appPublic/rc4._crypt` 崩成 500。它只读
`collect_request_params()` 的结果。
R49-b 线上回显(docs/03-test/R49-b-deployed-verify-decision.md §2.2,2026-10-26):
POST -d 'username=admin&password=admin123' /rbac/user/up_login.dspy -> HTTP=400
POST -H 'Content-Type: application/json' -d '{"username":"admin",...}' -> HTTP=400
body: {"status":400, "reason":"missing required parameter", "missing":["username","password"]}
即**参数明明提交了,却判成缺失**。原因不在 `parse_body_params`(form/JSON 解析在离线自测里是
通过的),而在取值时序:本中间件之前还有语义中间件链(`app/semantic_wiring.py` 装配的
auth_gate / notfound / business_status 等)。只要链上任何一环先消费过请求流(走
`request.multipart()` / StreamReader 而不是 `request.read()`),aiohttp 的 `_read_bytes`
缓存就是空的,`request.read()` 此时返回 `b""` —— 于是 body 解析得到 `{}`,
`missing_login_params()` 判「两个必填都缺」,登录被 400 挡死。
后果是**阻断面**:没有会话 → 全部受保护端点在部署态不可观测,test/deploy 的矩阵只能匿名跑。
修法(两层)
------------
1. **多源取值**:query / 路径变量 / ahserver 已合并的请求参数缓存(若存在)/ 原始 body
字节(JSON、form-urlencoded、multipart 普通字段)/ `request.post()` 兜底,逐源合并,
任一源取到即用。
2. **证据分级 + fail-open**:只有「确实读到了请求体(或确实没有请求体)且必填名为空」
才判 `missing` → 400;如果所有源都取不到值**且请求体读取失败/不可判**,判 `unknown`,
**不再伪造 400**,放行业务处理器(让它按真实情况成功或返回自己的错误)。
理由:把「已提交凭据」误判成「缺参」是**功能性阻断**(P0,全站不可用),
比漏判一次 500 严重得多;而真正的缺参请求(`-d '{}'` / 无 body)证据链完整,
仍然稳定 400(保留 R41 的原口径,不回退)。
硬约束
------
* 不打印口令(日志里只出现参数名与证据键,值一律 `***`);
* 不修改基座只读模块(rbac / ahserver / apppublic),一切收敛在应用层;
* 本模块只做取值与判定,不写响应体(响应仍由 http_semantics_middleware 统一出)。
"""
from __future__ import annotations
import json
import logging
import os
import re
log = logging.getLogger("yuanjing.login_param_source")
#: 判定结论
VERDICT_MISSING = "missing" # 证据充分:必填参数确实没提交 → 可以 400
VERDICT_PRESENT = "present" # 取到了必填参数 → 放行
VERDICT_UNKNOWN = "unknown" # 证据不足(请求体不可读且各源皆空)→ 放行(不伪造 400)
#: ahserver/aiohttp 上可能已存放「合并后请求参数」的属性名(best-effort 探测,命中即用)
_CACHED_PARAM_ATTRS = (
"params_kw", # ahserver 约定:query+body 合并后的参数字典
"_params_kw",
"ah_params",
"request_params",
"_ah_params",
)
_BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE")
#: 标量白名单:口令之类允许空串判定,但容器值一律丢弃(避免把 dict 当参数值)
_SCALAR_TYPES = (str, int, float, bool, bytes)
# 自测钩子:置 1 时 `_read_body_bytes` 模拟「流已被上游消费」的场景
_FORCE_BODY_UNREADABLE = bool(os.environ.get("YJ_FAKE_BODY_UNREADABLE"))
def _is_meaningful(value):
"""非空标量才算「提交了该参数」(空串/纯空白/容器/None 都算没提交)。"""
if value is None:
return False
if isinstance(value, (dict, list, tuple, set)):
return False
if isinstance(value, bytes):
try:
value = value.decode("utf-8", "replace")
except Exception: # noqa: BLE001
return False
if isinstance(value, str):
return value.strip() != ""
return True
#: 合法参数名:可见字符,不含空白/控制符/换行,不以 -- 开头(防 multipart 脏 key 入库)
_INVALID_NAME_RE = re.compile(r"[\r\n\t\x00-\x1f]|^--|;|content-disposition", re.IGNORECASE)
def _valid_name(name):
return bool(name) and not _INVALID_NAME_RE.search(str(name))
def _flatten_mapping(raw, out):
"""把 dict / MultiDict 类容器摊平成 {小写名: 值}(只收有意义的标量)。"""
if raw is None:
return out
items = None
if isinstance(raw, dict):
items = raw.items()
elif hasattr(raw, "items") and callable(getattr(raw, "items")):
try:
items = raw.items()
except Exception: # noqa: BLE001
items = None
elif hasattr(raw, "getall") and callable(getattr(raw, "getall")):
items = None
if items is not None:
try:
for k, v in items:
out.setdefault(str(k).lower(), v)
except Exception: # noqa: BLE001
pass
else:
try:
for k in raw:
out.setdefault(str(k).lower(), raw[k])
except Exception: # noqa: BLE001
pass
return out
def _from_framework_cache(request, out):
"""源①:ahserver 已解析并挂到 request / _run_ns 上的参数缓存(存在即最可信)。"""
hit = None
carriers = [request]
run_ns = getattr(request, "_run_ns", None)
if run_ns is not None:
carriers.append(run_ns)
for carrier in carriers:
for attr in _CACHED_PARAM_ATTRS:
candidate = getattr(carrier, attr, None)
if isinstance(candidate, (dict, list, tuple)) or hasattr(candidate, "items"):
hit = candidate
break
if hit is not None:
break
if hit is None:
return out, False
_flatten_mapping(hit, out)
return out, True
def _from_query_and_match(request, out):
"""源②:查询串 + 路径变量(两者都是 aiohttp 的只读缓存,读取无副作用)。"""
try:
_flatten_mapping(request.query, out)
except Exception: # noqa: BLE001
pass
try:
_flatten_mapping(request.match_info, out)
except Exception: # noqa: BLE001
pass
# 兼容历史写法:GET 表单走 request.GET
try:
_flatten_mapping(getattr(request, "GET", None), out)
except Exception: # noqa: BLE001
pass
return out
def _json_dict(text):
try:
parsed = json.loads(text)
except Exception: # noqa: BLE001
return None
return parsed if isinstance(parsed, dict) else None
def _qsl_pairs(text):
try:
from urllib.parse import parse_qsl
return parse_qsl(text, keep_blank_values=True)
except Exception: # noqa: BLE001
return []
_NAME_RE = re.compile(r"name=\"([^\"]*)\"", re.IGNORECASE)
_NAME_RE2 = re.compile(r"name=([^;\r\n\"]+)", re.IGNORECASE)
_BOUNDARY_RE = re.compile(r'boundary="?([^";,]+)"?', re.IGNORECASE)
def _boundary_of(content_type):
"""取 Content-Type 里的 boundary(**保留原始大小写**)。"""
m = _BOUNDARY_RE.search(content_type or "")
return m.group(1).strip() if m else ""
def _field_name(header_text):
"""从 Content-Disposition 头里取 name= (带引号优先,其次裸值)。"""
m = _NAME_RE.search(header_text)
if m:
return m.group(1).strip()
m = _NAME_RE2.search(header_text)
if m:
return m.group(1).strip().strip('"').strip("'")
return None
def multipart_plain_fields(body, boundary, keep_case=False):
"""按 RFC 2046 精确切分 multipart 分段,取**普通文本字段**(跳过 filename= 文件字段)。
keep_case=True 时返回的字段名保持原始大小写(`request.params` 口径需要),
默认小写(本模块内部判定口径)。
为什么不能 `body.split("--"+boundary)`(原实现的缺陷,QC 意见 #4):
* delimiter 的**前导 CRLF** 属于分隔符的一部分;直接用 `--boundary` split 会把
上一段正文结尾的 `\\r\\n` 留在下一段的头部,导致 `username` 的值被吞成
`'admin\\r\\n--XY\\r\\nContent-Disposition...'`;
* 每段结尾的 `\\r\\n`(属于下一个 delimiter)会被当成正文的一部分留下;
* `--boundary--`(final boundary)之后的 epilogue 也会被当成一段解析,产出
`--xy\\r\\ncontent-disposition: form-data; name` 这类脏 key。
本实现逐段定位 delimiter(含前导 CRLF),识别 final boundary 即终止,
并在截取的段内剥离尾部 CRLF,只取 Content-Disposition 的 name= 与紧随其后的正文。
"""
out = {}
if not body or not boundary:
return out
delim = ("--" + str(boundary)).encode("utf-8", "replace")
first = body.find(delim)
if first < 0:
return out
idx = first
while True:
start = idx + len(delim)
# final boundary: "--boundary--" → 后面只有 epilogue,解析结束
if body[start:start + 2] == b"--":
break
# delimiter 之后必须紧跟 CRLF(或流末尾),否则不是合法分段起点
if body[start:start + 2] != b"\r\n":
nxt = body.find(delim, start)
if nxt < 0:
break
idx = nxt
continue
start += 2
nxt = body.find(delim, start)
if nxt < 0:
part = body[start:]
else:
part = body[start:nxt]
# 段尾的 CRLF 属于下一个 delimiter,剥掉
if part.endswith(b"\r\n"):
part = part[:-2]
if part and part[-2:] == b"\r\n":
part = part[:-2]
head_raw, sep, payload = part.partition(b"\r\n\r\n")
if sep:
head_text = head_raw.decode("utf-8", "replace")
low = head_text.lower()
if "content-disposition" in low and "filename=" not in low:
name = _field_name(head_text)
if name:
key = name if keep_case else name.lower()
out.setdefault(key, payload.decode("utf-8", "replace"))
if nxt < 0:
break
idx = nxt
return out
#: 向后兼容旧私有名
def _multipart_plain_fields(body, boundary):
return multipart_plain_fields(body, boundary)
def _parse_body_bytes(body, content_type, out):
"""源④:原始请求体字节(multipart → JSON → form-urlencoded)。"""
if not body:
return out
ctype_raw = content_type or ""
ctype = ctype_raw.lower()
if ctype.startswith("multipart/"):
# boundary 是**大小写敏感**的(RFC 2046),必须从原始 Content-Type 取值,
# 不能从 lower() 后的串取——否则 `boundary=XY` 会被当成 `xy` 而一段都切不到,
# 再落进 form-urlencoded 兜底,产出 `--xy\r\ncontent-disposition...` 脏 key。
merged = multipart_plain_fields(body, _boundary_of(ctype_raw))
for k, v in merged.items():
out.setdefault(k, v)
if out:
return out
# 没解析出任何字段也**不得**回落到 qsl 解析(会把整段 multipart 当表单串切坏)
return out
text = body.decode("utf-8", "replace").strip()
if not text:
return out
if text.startswith("{") or "json" in ctype:
parsed = _json_dict(text)
if isinstance(parsed, dict):
for k, v in parsed.items():
out.setdefault(str(k).lower(), v)
return out
for k, v in _qsl_pairs(text):
out.setdefault(str(k).lower(), v)
return out
async def _read_body_bytes(request):
"""读请求体字节,返回 (body, readable)。
readable=False 表示「拿不到证据」:上游已消费流且补读失败——此时**不得**判缺参。
"""
if _FORCE_BODY_UNREADABLE:
return b"", False
cached = getattr(request, "_read_bytes", None)
if isinstance(cached, (bytes, bytearray)) and bytes(cached):
return bytes(cached), True
# 上游已消费过:_read_bytes 为空 → read() 只会拿到 b"",此时尝试 can_read_body 判定
if not getattr(request, "can_read_body", True):
try:
body = await request.read()
except Exception: # noqa: BLE001
body = b""
if isinstance(body, (bytes, bytearray)) and bytes(body):
return bytes(body), True
# 请求声明了 body 却读不到内容:证据不足(真实提交过凭据也可能落在这里)
return b"", False
try:
body = await request.read()
except Exception: # noqa: BLE001
return b"", False
if isinstance(body, (bytes, bytearray)):
return bytes(body), True
return b"", False
def _declares_body(request):
"""请求是否在协议层**声明**带了 body(Content-Length>0 或 Transfer-Encoding: chunked)。
这是判定「证据不足」的关键:只要协议声明有 body,而本层一个字节都没读到
(上游中间件已消费流 / aiohttp 缓存为空),就**不能**断言「参数没提交」。
"""
try:
if getattr(request, "content_length", None):
return True
except Exception: # noqa: BLE001
pass
try:
headers = getattr(request, "headers", None)
except Exception: # noqa: BLE001
headers = None
if headers is not None:
try:
cl = headers.get("Content-Length")
except Exception: # noqa: BLE001
cl = None
try:
if cl is not None and int(cl) > 0:
return True
except Exception: # noqa: BLE001
pass
try:
te = str(headers.get("Transfer-Encoding") or "").lower()
except Exception: # noqa: BLE001
te = ""
if "chunked" in te:
return True
# 有 Content-Type 但没有 Content-Length 的替身对象(离线自测):视为声明带 body
try:
ctype = getattr(request, "content_type", "") or ""
except Exception: # noqa: BLE001
ctype = ""
if ctype and getattr(request, "content_length", None) is None:
return True
return False
async def _from_request_post(request, out):
"""源⑤:`request.post()`(form/multipart 的官方解析路径)兜底。"""
try:
post = await request.post()
except Exception: # noqa: BLE001
return out
_flatten_mapping(post, out)
return out
def _required_names(path, spec_table):
"""取该端点的必填 canonical 名与别名表(缺配置返回 None)。"""
specs = (spec_table or {}).get(path or "")
if not specs:
return None
return [(canonical, tuple(a.lower() for a in aliases)) for canonical, aliases in specs]
async def collect_login_params(request, spec_table):
"""多源收集登录端点参数,返回 (params, evidence)。
params : {小写参数名: 原始值}(只保留有意义的标量)
evidence : {"sources":[..], "body_readable":bool, "body_len":int,
"framework_cache_hit":bool}
"""
params = {}
sources = []
merged, cache_hit = _from_framework_cache(request, params)
if cache_hit and any(_is_meaningful(v) for v in merged.values()):
sources.append("framework_cache")
before = len([k for k, v in params.items() if _is_meaningful(v)])
_from_query_and_match(request, params)
if len([k for k, v in params.items() if _is_meaningful(v)]) > before:
sources.append("query/match_info")
body_readable = True
body_len = 0
declared_body = False
if request.method in _BODY_METHODS:
declared_body = _declares_body(request)
body, body_readable = await _read_body_bytes(request)
body_len = len(body or b"")
if body:
_parse_body_bytes(body, request.content_type, params)
sources.append("raw_body")
if not any(_is_meaningful(v) for v in params.values()):
await _from_request_post(request, params)
if any(_is_meaningful(v) for v in params.values()):
sources.append("request.post")
clean = {k: v for k, v in params.items() if _is_meaningful(v)}
evidence = {
"sources": sources,
"body_readable": body_readable,
"body_len": body_len,
"declared_body": declared_body,
"framework_cache_hit": cache_hit,
"names": sorted(clean.keys()),
}
return clean, evidence
def judge_required(path, params, evidence, spec_table):
"""返回 (verdict, missing_names)。
* 必填都取到 → present
* 确实缺(且有证据) → missing
* 取不到但证据不足 → unknown(调用方**不得**据此 400)
"""
specs = _required_names(path, spec_table)
if not specs:
return VERDICT_PRESENT, []
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
missing = []
for canonical, aliases in specs:
if not any(canonicalize_alias(a) in lowered and _is_meaningful(lowered.get(canonicalize_alias(a)))
for a in aliases):
missing.append(canonical)
if not missing:
return VERDICT_PRESENT, []
# 有必填没取到:区分「真没提交」与「没读到」
ev = evidence or {}
body_readable = ev.get("body_readable", True)
body_len = ev.get("body_len", 0) or 0
declared_body = bool(ev.get("declared_body"))
# 口径①(QC 意见 #5 的核心):协议声明带 body(Content-Length>0 / chunked)而本层
# 读不到内容 → 证据不足,必须判 unknown 放行,绝不伪造 400 挡死合法登录。
if not body_readable and (declared_body or body_len > 0):
return VERDICT_UNKNOWN, missing
# 口径②:读到过内容(body_len>0 或已取到别的参数)但仍缺必填,且流不可重读 →
# 同样属于"取值链不完整",证据不足放行。
if not body_readable and (lowered or body_len > 0):
return VERDICT_UNKNOWN, missing
# 口径③:Content-Length=0 / 确实无 body / 读到了 body 但必填为空 → 真缺参,可以 400
return VERDICT_MISSING, missing
def canonicalize_alias(alias):
return str(alias).lower()
def masked_evidence(evidence):
"""日志安全的证据摘要(不含任何值,只有参数名/长度/来源)。"""
ev = evidence or {}
return "sources=%s names=%s body_len=%s body_readable=%s fw_cache=%s" % (
",".join(ev.get("sources") or []) or "-",
",".join(ev.get("names") or []) or "-",
ev.get("body_len", 0),
ev.get("body_readable", True),
ev.get("framework_cache_hit", False),
)
# --- 兼容旧调用(离线自测里按同口径构造过判定)-----------------------------
def judge_missing(path, params, spec_table):
"""旧接口:只做名字判定,返回缺失 canonical 列表。"""
specs = _required_names(path, spec_table) or []
lowered = {str(k).lower(): v for k, v in (params or {}).items()}
out = []
for canonical, aliases in specs:
if not any(a in lowered and _is_meaningful(lowered.get(a)) for a in aliases):
out.append(canonical)
return out

View File

@ -0,0 +1,341 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""中间件链**签名契约**层(R48 根因修复)。
根因(R48 实测 2026-10-26 06:43,evidence/retest-r48-probe-20261026-064308.log)
----------------------------------------------------------------------------
部署态全站 500(`500 Internal Server Error / Server got itself in trouble`),
A/B/C 三族(/healthz 方法语义、匿名读鉴权、缺参写状态行)全部被遮蔽。
调用链:
app/get_only_405.py (healthz 兜底 / 链最外层)
-> 内层链
-> pkgs/ahserver/ahserver/real_ip.py:16 request['client_ip'] = request.remote
-> AttributeError: 'AHApp' object has no attribute 'remote'
`request` 位置上出现的是 **AHApp(应用对象)**,不是 `web.Request`。这不是 real_ip
写错,而是**中间件注册/装配的签名契约不匹配**:
aiohttp 的 `Application` 用 `__middleware_version__` 属性区分两种中间件风格——
* **new-style**:`@web.middleware` 装饰过的 `async def mw(request, handler)`,
装饰器写入 `__middleware_version__ = 1`;
* **legacy-style**:**没有**该标记的可调用对象,被当作「工厂」调用 `mw(app)`。
因此把一个**裸** `async def mw(request, handler)`(忘记 `@web.middleware`)塞进
`app.middlewares` / 构造期 `mws` 列表,aiohttp 会按 legacy 工厂路径用 **app** 去填
第一个形参 —— 中间件眼里的 `request` 变成 `AHApp`,`request.remote` 立刻
AttributeError;同时内层链可能返回**未被 await 的 coroutine/function**,产生
`Expect response, not <class 'function'>`(R21 历史日志原文)。
本仓库的暴露点(本轮机械核对,全部是「裸函数进链」):
app/get_only_405.py :: method_gate_middleware (无 @web.middleware)
app/auth_route_gate.py :: auth_route_gate_middleware(无 @web.middleware)
且 install() 直接 insert 裸函数
另有已正确装饰但仍需契约兜底的:`route_guard_middleware.py`、
`business_status_middleware.py`(它们的注册点分布在多条路径上,任一处漏装饰
就会复现同类 500)。
修复策略(本文件,确定性、可自测)
----------------------------------
1. `ensure_middleware(mw)` —— 幂等地把任意中间件规范成 new-style(补
`__middleware_version__ = 1`),保留 `__name__`;非可调用/签名不符则拒绝。
2. `assert_middleware_contract(seq)` —— 返回违规清单(供 /healthz 诊断与 selftest)。
3. `normalize_chain(seq)` —— 就地规范化中间件序列,返回诊断 dict(不静默失败:
不可修复项写进 `rejected`)。
4. `safe_bind(mw, handler)` —— 构造 chain 时把 handler 绑到**第二个**形参位
(`mw(request, handler)`),**禁止** `partial(mw, handler)` 这类把 handler 绑进
第一个(request)位的写法;并校验返回值必须是 `web.StreamResponse`,否则抛
`MiddlewareContractError`(不产生 "Expect response, not function" 的隐晦 500)。
5. `install_chain_contract(app)` —— 构造期/装配后统一规范化 `app._middlewares`
(或 `app.middlewares`),幂等,永不抛出(返回诊断)。
6. `selftest()` —— 进程内 aiohttp 应用自测(/healthz GET/HEAD/POST/PUT/DELETE +
受保护 .dspy 匿名/带 cookie 两口径 + 裸中间件回归),见 `tests/test_middleware_chain_contract.py`。
设计约束
--------
* 零应用内依赖(可被 scense.py / yuanjing.py 以 `import middleware_chain_contract`
或 `from app import ...` 两种路径导入,不触发包内其他模块的副作用)。
* 一切「不可修复」都进诊断,不 `except: pass`。
"""
import inspect
import logging
log = logging.getLogger("yuanjing.middleware_chain_contract")
#: 契约修订号(/healthz 与 selftest 断言用;改动契约语义必须递增)
CONTRACT_REVISION = "r52-chain-contract-1"
#: aiohttp new-style 中间件标记属性
MARKER = "__middleware_version__"
#: aiohttp 要求中间件的形参个数(request, handler)
ARITY = 2
class MiddlewareContractError(RuntimeError):
"""中间件不符合 `(request, handler)` 签名契约,或返回了非响应对象。"""
# ---------------------------------------------------------------- 判定
def _aiohttp_web():
"""取 aiohttp.web(取不到返回 None,调用方必须降级而不能静默成功)。"""
try:
from aiohttp import web as _w # noqa: PLC0415
return _w
except Exception: # noqa: BLE001
return None
def has_marker(mw):
"""该中间件是否已带 new-style 标记(aiohttp 才不会再按 legacy 工厂调用)。"""
return getattr(mw, MARKER, None) == 1
def positional_arity(fn):
"""可调用对象接受的位置参数个数;无法判定时返回 -1。"""
try:
sig = inspect.signature(fn)
except (TypeError, ValueError):
return -1
return len([
p for p in sig.parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
])
def is_middleware_shaped(mw):
"""形如 `(request, handler)` 的协程函数(或带 1 个位置的 legacy 工厂)。"""
if not callable(mw):
return False
n = positional_arity(mw)
if n == ARITY:
return True
# legacy 工厂:def factory(app) -> middleware;本轮只告警,不当作本应用风格
return False
def contract_violations(mw):
"""返回单项违规描述列表(空列表 = 合规)。"""
out = []
if not callable(mw):
out.append("not callable: %r" % (mw,))
return out
if not is_middleware_shaped(mw):
out.append("signature != (request, handler): %s%s"
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
if not has_marker(mw):
out.append("missing %s (aiohttp 会按 legacy 工厂 mw(app) 调用 → "
"'AHApp' object has no attribute 'remote'): %s"
% (MARKER, getattr(mw, "__name__", repr(mw))))
return out
def kind_suffix(mw):
try:
return " (arity=%d)" % positional_arity(mw)
except Exception: # noqa: BLE001
return ""
def assert_middleware_contract(seq):
"""返回 `[{index, name, violations}]`(空列表 = 全部合规)。"""
out = []
for i, mw in enumerate(seq or ()):
v = contract_violations(mw)
if v:
out.append({"index": i, "name": getattr(mw, "__name__", repr(mw)),
"violations": v})
return out
def describe(seq):
"""中间件名列表(诊断用)。"""
return [getattr(m, "__name__", repr(m)) for m in (seq or ())]
# ---------------------------------------------------------------- 规范化
def ensure_middleware(mw, name=None):
"""幂等规范化:返回带 new-style 标记的中间件;不合规抛 MiddlewareContractError。"""
if not callable(mw):
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
if not is_middleware_shaped(mw):
raise MiddlewareContractError(
"middleware signature must be (request, handler): %s%s"
% (getattr(mw, "__name__", repr(mw)), kind_suffix(mw)))
if has_marker(mw):
if name:
try_set_name(mw, name)
return mw
w = _aiohttp_web()
fixed = mw
if w is not None:
try:
fixed = w.middleware(mw) # aiohttp 官方装饰器,写入标记
except Exception: # noqa: BLE001
fixed = mw
if not has_marker(fixed):
try:
setattr(fixed, MARKER, 1) # 兜底:手工补标记
except Exception as exc: # noqa: BLE001
raise MiddlewareContractError(
"cannot mark middleware %s: %s"
% (getattr(mw, "__name__", repr(mw)), exc))
try_set_name(fixed, name or getattr(mw, "__name__", None))
return fixed
def try_set_name(mw, name):
if not name:
return mw
try:
mw.__name__ = name
except Exception: # noqa: BLE001
pass
return mw
def normalize_chain(seq, only_missing_marker=True):
"""就地规范化中间件序列,返回诊断 dict(幂等)。
:param seq: 可变序列(list)。`app._middlewares` / 构造期 mws 列表。
:param only_missing_marker: True 时只补标记,不改变顺序、不删项。
:return: {revision, total, normalized, rejected, names, ok}
"""
diag = {"revision": CONTRACT_REVISION, "total": 0, "normalized": [],
"rejected": [], "names": [], "ok": True}
if seq is None:
diag["ok"] = False
diag["rejected"].append("chain is None")
return diag
try:
total = len(seq)
except Exception: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append("chain not a sized sequence")
return diag
diag["total"] = total
for i in range(total):
mw = seq[i]
name = getattr(mw, "__name__", repr(mw))
if has_marker(mw) and is_middleware_shaped(mw):
continue
try:
seq[i] = ensure_middleware(mw)
diag["normalized"].append({"index": i, "name": name})
log.warning("[chain-contract] 第 %d 层中间件 %s 未带 %s,已规范化为 "
"new-style(原实现会被 aiohttp 按 legacy 工厂 mw(app) 调用)",
i, name, MARKER)
except MiddlewareContractError as exc:
diag["rejected"].append({"index": i, "name": name, "error": str(exc)})
log.error("[chain-contract] 第 %d 层中间件 %s 不可修复:%s", i, name, exc)
diag["names"] = describe(seq)
diag["violations"] = assert_middleware_contract(seq)
diag["ok"] = not diag["rejected"] and not diag["violations"]
return diag
# ---------------------------------------------------------------- 绑定与执行
def safe_bind(mw, handler):
"""把 `handler` 绑到中间件的**第二个**形参位,返回 `chain(request)`。
**禁止** `partial(mw, handler)`:partial 会把 handler 填到第一个形参
(request)位,请求对象反而落到 handler 位 —— 这正是
「`Expect response, not <class 'function'>`」与
「`'AHApp' object has no attribute 'remote'`」两类 500 的同源错误绑定。
"""
if not callable(mw):
raise MiddlewareContractError("middleware not callable: %r" % (mw,))
async def _chain(request):
resp = await mw(request, handler)
return assert_response(resp, mw, request)
_chain.__name__ = "chain(%s)" % getattr(mw, "__name__", "mw")
return _chain
def assert_response(resp, mw=None, request=None):
"""中间件必须返回 `web.StreamResponse`;否则抛清晰契约错误。"""
w = _aiohttp_web()
name = getattr(mw, "__name__", "mw") if mw is not None else "mw"
if w is None:
return resp
if isinstance(resp, w.StreamResponse):
return resp
if inspect.isawaitable(resp):
raise MiddlewareContractError(
"%s returned an awaitable (%r) —— 中间件漏 await"
% (name, type(resp).__name__))
raise MiddlewareContractError(
"Expect response, not %r from %s (path=%s)"
% (type(resp).__name__, name, getattr(request, "path", "") or "?"))
def build_chain(middlewares, handler):
"""按 `[最外层 ... 最内层]` 顺序组链,返回 `chain(request)`;全程契约校验。"""
chain = handler
for mw in reversed(list(middlewares or ())):
chain = safe_bind(ensure_middleware(mw), chain)
return chain
# ---------------------------------------------------------------- 装配钩子
def _mw_list(app):
"""取 app 的中间件列表(aiohttp 版本差异:`middlewares` / `_middlewares`)。"""
mws = getattr(app, "_middlewares", None)
if mws is not None:
return mws, "_middlewares"
mws = getattr(app, "middlewares", None)
if mws is not None:
return mws, "middlewares"
return None, None
def install_chain_contract(app, middlewares=None):
"""构造期/装配后统一规范化中间件链签名契约(幂等,永不抛出)。
:param app: AHApp / aiohttp Application 实例(可为 None)
:param middlewares: 构造期 mws 列表(在 `make_app()` 之前先规范化)
:return: 诊断 dict(含 revision / normalized / rejected / names / ok)
"""
diag = {"revision": CONTRACT_REVISION, "ok": True, "sources": [],
"normalized": [], "rejected": [], "names": []}
try:
if middlewares is not None:
d = normalize_chain(middlewares)
diag["sources"].append("ctor-arg")
diag["normalized"] += d.get("normalized", [])
diag["rejected"] += d.get("rejected", [])
diag["ok"] = diag["ok"] and d.get("ok", True)
diag["names"] = d.get("names", [])
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append({"source": "ctor-arg",
"error": "%s: %s" % (type(exc).__name__, exc)})
try:
if app is not None:
mws, src = _mw_list(app)
if mws is None:
diag["ok"] = False
diag["rejected"].append({"source": "app", "error": "no middleware list"})
else:
d = normalize_chain(mws)
diag["sources"].append(src)
diag["normalized"] += d.get("normalized", [])
diag["rejected"] += d.get("rejected", [])
diag["ok"] = diag["ok"] and d.get("ok", True)
diag["names"] = d.get("names", [])
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["rejected"].append({"source": "app",
"error": "%s: %s" % (type(exc).__name__, exc)})
if not diag["ok"]:
log.error("[chain-contract] 装配核验未通过 rejected=%s", diag["rejected"])
else:
log.info("[chain-contract] 装配核验通过 rev=%s sources=%s names=%s",
CONTRACT_REVISION, diag["sources"], diag["names"])
return diag

View File

@ -0,0 +1,312 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""aiohttp 中间件链「签名归一 + 响应契约」模块(Bug rvtGi1mlUsTwQrXnybZj6 / R60 正解)。
问题(部署态实测,证据 docs/04-deploy/evidence_r58/)
----------------------------------------------------
`POST /rbac/user/up_login.dspy` 恒返回 `401 {"error": "auth_unavailable"}`、无 `Set-Cookie`;
`logs/scense.log:1735-1737` 三条原文:
[auth_strict] 闸门异常但为公开路径,放行: /rbac/user/up_login.dspy
[auth_strict] 拒绝匿名/无效会话请求: POST /rbac/user/up_login.dspy reason=auth_unavailable
[auth_strict] 闸门异常: Expect response, not <class 'function'>
`Expect response, not <class 'function'>` = **链上某层把「未 await 的 handler 函数本体」
当成 Response 交回上游**。成因是 13 层中间件里新式 `async def (request, handler)` 与旧式工厂
`def (app, handler) -> handler` 混排,链构建器按新式语义调用旧式工厂时拿到的就是 handler。
真实故障被 `auth_gate_strict` 的 fail-closed `except` 降级成 401,于是「登录端点自己 401」,
形成 "先登录才能登录" 的自锁。
为什么不能靠 `__middleware_version__` 标记(R55 已实测失败,勿重试)
--------------------------------------------------------------------
aiohttp>=3.9 用 `getattr(m, "__middleware_version__", None) == 1` 区分新式/旧式。
但标记只能由 `web.middleware` 装饰器写在**函数对象**上;链上还可能存在
`functools.partial` / 可调用实例 / 已冻结 FrozenList 中的对象,setattr 静默失败,
R55 目标机实测 `new_style_marked=[]`(标记一个没生效)。**标记不可依赖**。
本模块的正解
------------
不再猜协议、不再改原对象,而是把**每一个**中间件包成同一种适配器:
* 适配器自身是 `async def (request, handler)`,且带 `__middleware_version__ = 1`
(属性写在适配器函数对象上,一定成功——不是去改别人的对象);
* 适配器**按返回值自适应**:awaitable → 新式,直接 await;callable → 旧式工厂,
继续以其返回值收口(旧式 `(app, handler)` 用 `request.app` 作为 app 传入);
* 出口做**响应契约校验**:最终必须是非 callable 的 Response 类对象,否则抛
`ResponseContractError`(含层名 + 原始类型)。这样「把 handler 当 Response 返回」
会立刻变成一条带层名的明确 500,而不是被下游闸门静默改写成 401。
用法(`app/scense.py::_load_semantic_middlewares()` 的调用点)
--------------------------------------------------------------
from mw_signature import normalize_chain
mws = normalize_chain(_load_semantic_middlewares()) # 幂等
# app.middlewares 已就位时(钩子期兜底,可选):
from mw_signature import normalize_app_middlewares
normalize_app_middlewares(app)
不依赖 aiohttp 的导入(本地无 aiohttp 的可测性):只在需要时探 `aiohttp.web`
判断 Response 类型,缺失时退化为鸭子类型(`hasattr(resp, "status")` 且非 callable),
因此单元自测可在无 aiohttp 环境跑(见 scripts/selftest_mw_signature.py)。
"""
import inspect
import logging
log = logging.getLogger("yuanjing.mw_signature")
#: aiohttp 判定新式中间件用的属性名(只写在我们自己的适配器上)。
NEW_STYLE_ATTR = "__middleware_version__"
#: 适配器幂等标记(同一对象重复 normalize 不再套娃)。
ADAPTER_MARK = "__mw_signature_adapter__"
#: 旧式工厂的第一形参惯用名。命中即按「工厂 (app, handler) -> handler」调用。
LEGACY_FIRST_PARAMS = frozenset(("app", "application", "_app", "ahapp"))
try: # pragma: no cover - 目标机必有 aiohttp,本机可能没有
from aiohttp import web as _aiohttp_web
_RESPONSE_BASES = (_aiohttp_web.StreamResponse,)
except Exception: # noqa: BLE001
_aiohttp_web = None
_RESPONSE_BASES = ()
class ResponseContractError(RuntimeError):
"""中间件把非 Response(典型:handler 函数本体)返回给了上游。
这不是 401 语义,是**装配/契约缺陷**:必须 500 + traceback,禁止降级成鉴权失败。
"""
def describe(mw):
"""给中间件一个可读名字(日志/诊断用,永不抛)。"""
for attr in ("__qualname__", "__name__"):
val = getattr(mw, attr, None)
if isinstance(val, str) and val:
mod = getattr(mw, "__module__", "") or ""
return ("%s.%s" % (mod, val)) if mod else val
return type(mw).__name__
def is_new_style(mw):
"""是否已被标记为新式 `(request, handler)` 中间件。"""
return getattr(mw, NEW_STYLE_ATTR, None) == 1
def is_adapter(mw):
return bool(getattr(mw, ADAPTER_MARK, False))
def _first_param_name(fn):
try:
params = list(inspect.signature(fn).parameters.values())
except (TypeError, ValueError):
return None
for p in params:
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
return p.name.lower()
return None
def _looks_like_response(obj):
"""Response 判定:优先 aiohttp 真实类型,无 aiohttp 时鸭子类型。"""
if obj is None or callable(obj):
return False
if _RESPONSE_BASES and isinstance(obj, _RESPONSE_BASES):
return True
# 鸭子类型:StreamResponse 家族都有 status + headers
return hasattr(obj, "status") and hasattr(obj, "headers") and not inspect.iscoroutine(obj)
def _contract_check(result, mw, request):
"""出口响应契约校验:非 Response → ResponseContractError(带层名,可定位)。"""
if _looks_like_response(result):
return result
path = getattr(request, "path", "-")
kind = type(result).__name__
if callable(result):
detail = ("中间件返回了可调用对象(%s),而不是 Response —— "
"典型的旧式工厂被按新式调用 / handler 未 await 直接返回" % kind)
elif result is None:
detail = "中间件返回 None(漏写 return 或 return 写在 async with 块内)"
else:
detail = "中间件返回了非 Response 对象(%s)" % kind
raise ResponseContractError(
"Expect response, not %r: layer=%s path=%s: %s"
% (kind, describe(mw), path, detail))
def _call_legacy_factory(factory, request, handler):
"""旧式工厂:(app, handler) -> 新 handler。app 取 request.app(取不到给 None)。"""
app = getattr(request, "app", None)
return factory(app, handler)
async def _invoke(mw, request, handler):
"""按返回值自适应地执行一层中间件,并做响应契约校验。"""
first = _first_param_name(mw)
if first in LEGACY_FIRST_PARAMS:
result = _call_legacy_factory(mw, request, handler)
else:
result = mw(request, handler)
if inspect.isawaitable(result):
return _contract_check(await result, mw, request)
if callable(result):
# 旧式工厂的返回值:新 handler,继续收口一次
inner = result
resp = inner(request)
if inspect.isawaitable(resp):
resp = await resp
return _contract_check(resp, mw, request)
return _contract_check(result, mw, request)
def adapt(mw):
"""把单个中间件包成统一的新式适配器(幂等;已是适配器则原样返回)。"""
if is_adapter(mw):
return mw
layer = describe(mw)
async def _adapter(request, handler):
try:
return await _invoke(mw, request, handler)
except ResponseContractError:
# 契约错误原样上抛(带层名),由上层/闸门转 500 —— 绝不改写成 401
raise
except Exception as exc: # noqa: BLE001
log.exception("[mw-signature] 层内部异常 layer=%s path=%s: %s: %s",
layer, getattr(request, "path", "-"),
type(exc).__name__, exc)
raise
try:
_adapter.__name__ = "%s__mw_sig" % getattr(mw, "__name__", type(mw).__name__)
_adapter.__qualname__ = _adapter.__name__
_adapter.__doc__ = "mw_signature adapter of %s" % layer
except Exception: # noqa: BLE001
pass
setattr(_adapter, NEW_STYLE_ATTR, 1)
setattr(_adapter, ADAPTER_MARK, True)
setattr(_adapter, "__mw_signature_of__", layer)
return _adapter
def normalize_chain(middlewares):
"""归一整个中间件列表(顺序不变:列表首位 = 最外层)。
:param middlewares: 可迭代中间件
:return: `ChainResult`(list 子类,可直接 `app = web.Application(middlewares=normalize_chain(mws))`)。
报告从属性取:`res = normalize_chain(mws); res.report`。
[fix rvtGi1mlUsTwQrXnybZj6 / R61] 旧的 docstring 声称支持
`mws, report = normalize_chain(x)` 两值解包 —— **实际不支持**:ChainResult 是
list 子类,两值解包会按元素迭代,抛 `ValueError: too many values to unpack`
(R61 本地 e2e 实测命中)。这里改正契约说明,避免调用方按错误文档取报告。
"""
out = []
table = []
for pos, mw in enumerate(list(middlewares or ())):
layer = describe(mw)
old_style = _first_param_name(mw) in LEGACY_FIRST_PARAMS
marked = is_new_style(mw)
wrapped = adapt(mw)
out.append(wrapped)
table.append({
"pos": pos,
"layer": layer,
"new_style_marked": marked,
"legacy_factory": old_style,
"adapter": describe(wrapped),
})
report = {
"count": len(out),
"legacy_factory_layers": [e["layer"] for e in table if e["legacy_factory"]],
"unmarked_layers": [e["layer"] for e in table if not e["new_style_marked"]],
"all_adapter_verified": all(is_new_style(m) for m in out),
"table": table,
}
if report["legacy_factory_layers"] or report["unmarked_layers"]:
log.warning("[mw-signature] 链签名归一:%d 层;旧式工厂=%s;原未标记=%s;全部适配器新式=%s",
report["count"], report["legacy_factory_layers"],
report["unmarked_layers"], report["all_adapter_verified"])
else:
log.info("[mw-signature] 链签名归一:%d 层,全部已是新式并完成适配器包裹", report["count"])
return ChainResult(out, report)
class ChainResult(list):
"""兼容 `mws = normalize_chain(x)` 直接赋值用法:本体就是中间件列表。"""
def __init__(self, items, report):
super().__init__(items)
self.report = report
def normalize_app_middlewares(app):
"""把 `app.middlewares` 就地换成一整串适配器(含子应用),幂等。
返回 report dict。FrozenList 已冻结时依次尝试 `_list[i]` / `_middlewares` 回写;
全部失败则记 ERROR(该层仍按原样进链,但下一次 normalize 会再试)。
"""
fixed = []
def _fix(application):
mws = getattr(application, "middlewares", None)
if not mws:
return
for i, m in enumerate(list(mws)):
nm = adapt(m)
if nm is m:
continue
written = False
for target in (mws, getattr(mws, "_list", None)):
if target is None:
continue
try:
target[i] = nm
written = True
break
except Exception: # noqa: BLE001
continue
if written:
fixed.append(describe(m))
else:
log.error("[mw-signature] 回写 app.middlewares 失败 pos=%s layer=%s", i, describe(m))
_fix(app)
for sub in list(getattr(app, "_subapps", None) or ()):
_fix(sub)
report = {"normalized": fixed, "count": len(fixed)}
if fixed:
log.info("[mw-signature] app.middlewares 归一 %d 层: %s", len(fixed), fixed)
return report
def signature_table(middlewares):
"""纯诊断:13 层中间件的签名形态表(写进 /healthz 诊断或启动日志)。"""
table = []
for pos, mw in enumerate(list(middlewares or ())):
table.append({
"pos": pos,
"layer": describe(mw),
"new_style_marked": is_new_style(mw),
"adapted": is_adapter(mw),
"legacy_factory": _first_param_name(mw) in LEGACY_FIRST_PARAMS,
})
return table
__all__ = [
"NEW_STYLE_ATTR",
"ResponseContractError",
"ChainResult",
"adapt",
"normalize_chain",
"normalize_app_middlewares",
"signature_table",
"is_new_style",
"is_adapter",
"describe",
]

View File

@ -0,0 +1,367 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""yuanjing 应用级「资源不存在 → 404」中间件(Bug ALTq8Xbvpi0mLlos7Rtyg)。
同时是本应用的**语义中间件组合入口**(Bug 族 404/401/405/400 的统一收口点)。
背景(为什么在应用层修)
------------------------
ahserver 的 processorResource._handle() 在 URL 无法映射到磁盘文件
(`request_filename is None`)时执行:
raise Exception(f'{str(request.url)=} invalid path')
该 Exception 被 aiohttp 兜底成 **HTTP 500**,语义上却是「资源不存在」应为 **404**。
后果:任何 URL 写错 / 模块 wwwroot 尚未软链,都表现为 500,与「后端代码异常」不可区分,
是本次「全站 *.dspy 500 / fpath is None」表象的直接放大器(误导排障方向)。
框架源码不自持:`/d/doit/doit/pkgs/ahserver` 是平台共享的**只读**挂载
(实测 `OSError: [Errno 30] Read-only file system`),且 project-directory-spec 铁律
规定基础模块任何项目不得修改 —— 因此本修复落在**应用层**(应用是部署单元,应用代码由
本项目自持可写可提交):
* 应用自带 AHApp 实例(ahserver `webapp(init_func, app=...)` 公开钩子,
ConfiguredServer 对传入 app 直接 `self.app = app`),在 middleware 链最外层注册本中间件;
* 只把「ahserver 路径解析失败」这一种异常映射为 404(签名匹配),**其余异常一律 re-raise**,
保证真实后端缺陷仍然暴露为 500,不掩错。
为什么本文件还是「组合入口」(2026-10-21 补充,R28)
---------------------------------------------------
真实运行入口 `app/scense.py` 的构造期装配函数 `_load_semantic_middlewares()` 只按名字
加载 **两个** 中间件(`resource_notfound_middleware` / `http_semantics_middleware`),
`app/route_guard_middleware.py`(404 前置 / 401 / 405+400)与
`app/business_status_middleware.py`(业务 400 → 状态行)**从未进入构造期中间件链** ——
这就是「修复代码在仓库里、线上仍 401/200/500」的装配缺口。
为避免在 500 行入口文件里做大段改动(且 `app/scense.py` 在当前沙箱为只读),
本中间件改为**自持组合链**:内部按上述语义顺序把守卫中间件串起来,
外层仍保留「ahserver 异常 → 404」的原始职责。装配顺序(外层 → 内层):
0. **豁免判定**(`auth_exempt.is_exempt_path()`,单一真源)—— 登录/登出/健康探针等
白名单路径直接放行到业务处理器,早于资源存在性、早于会话校验
(Bug uifdBIMdmixZcmJEdV33O / R61,登录端点 401 auth_unavailable 根因之一)
1. `resource_preflight_middleware` 路径不存在 → 404(**早于鉴权**,Bug P-Wn8N / WXXvX)
2. `auth_required_middleware` 已存在 .dspy 无会话凭据 → 401(Bug WhOYy9nvG)
3. `write_endpoint_semantics_middleware` 写类端点 GET/HEAD → 405 + Allow;POST 无参 → 400
(Bug CQIpIoIJODNdSt4B0VvVv)
4. `business_status_middleware` 业务信封 {"status":400,...} → HTTP 状态行
(Bug DGzTjOkr_OYM4vdOYcl2_ / F12 / F16)
5. 业务处理器(ahserver)
> 注:上面的 0 号豁免判定在 `resource_notfound_middleware` 入口处执行,
> 命中即 `return await handler(request)`(不进入 1~5 层)。
组合链构造失败时**显式降级**为原始行为(只做 404 异常映射)并打 ERROR 日志,
不静默、不抛出 —— 入口起不来比少一层语义更严重。
上游根治仍需 ahserver 维护方把该处 `raise Exception(...)` 改为 `raise HTTPNotFound`
(补丁文本见 apps/yuanjing/docs/evidence/ahserver-404-upstream-patch.md,本文件的
签名匹配常量与之同源;上游修复后本中间件自然退化为不触发的冗余保险丝)。
"""
import importlib.util
import logging
import os
import sys
from aiohttp import web
log = logging.getLogger("yuanjing.notfound")
#: 应用根(本文件 → app/ → 应用根)。绝对路径加载中间件用(不依赖 sys.path)。
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ahserver 路径解析失败的异常文本签名(上游 processorResource.py:399 原文)。
# 只在命中这些签名时才改写为 404;新增签名须同步 evidence 文档与 verify_404_semantics.py。
RESOURCE_MISSING_SIGNATURES = (
"invalid path",
"fpath is None",
)
# 这些后缀的请求返回 JSON 404(接口调用方),其余返回 text 404(页面/静态资源)。
_JSON_SUFFIXES = (".dspy", ".json", ".do", ".api")
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(Bug uifdBIMdmixZcmJEdV33O / T4)──
# [fix rvtGi1mlUsTwQrXnybZj6 / R61 · QC#2] 本文件是「语义中间件组合入口」,
# 历史实现在这里**没有任何豁免判定**(内联兜底闸门对任何 .dspy 一律 401),
# 于是登录端点走这条链必然 401 —— 这正是 auth_exempt.py docstring 点名的待修路径。
# 现在:组合链**最前置**(早于资源存在性、早于会话校验)判定豁免,
# 与 auth_exempt 判据第 3 条一致;未豁免路径仍走原逻辑(有单测断言,不写成无条件放行)。
def _load_auth_exempt():
"""加载豁免清单单一真源(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
spec = importlib.util.spec_from_file_location(
"yj_auth_exempt_nf", os.path.join(APP_DIR, "app", "auth_exempt.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
try:
EXEMPT = _load_auth_exempt()
except Exception as _load_exc: # noqa: BLE001
log.error("[semantics] 豁免清单单一真源加载失败(不豁免任何路径): %s", _load_exc)
EXEMPT = None
def is_exempt_path(path):
"""该路径是否免鉴权(委托 `auth_exempt.is_exempt_path()`,单一真源)。
:param path: 请求路径
:return: bool —— 豁免 True;清单不可用/判定异常一律 False(fail-closed)
"""
if EXEMPT is None:
return False
try:
return bool(EXEMPT.is_exempt_path(path))
except Exception as exc: # noqa: BLE001
log.error("[semantics] 豁免判定异常(按不豁免处理): %s", exc)
return False
#: 组合链装配顺序(**内层 → 外层**,即列表最后一项是最外层)。
_COMPOSED_INNER_FIRST = (
("business_status_middleware", "business_status_middleware"),
# ↓ 最外层:入口内建只读路径 + 非读方法 → 405 + Allow(Bug EPaXUem2 / QC #4 #5)
# 必须排在 resource_preflight 之外:/healthz 是入口 add_get 注册的运行时路由,
# 预检层按磁盘资源判定会把它当「不存在」→ 404,方法语义就永远轮不到。
("get_only_405", "method_gate_middleware"),
)
def is_resource_missing(exc):
"""异常是否为「资源不存在」语义(ahserver 路径解析失败),而非真实服务端缺陷。"""
msg = str(exc) or ""
return any(sig in msg for sig in RESOURCE_MISSING_SIGNATURES)
def _module_file(mod):
"""`mod` → 中间件文件的绝对路径(`mod` 可能是 `notfound_middleware` 或 `app.xxx`)。
注意目录层级(Bug EPaXUem2 复验暴露):本文件位于 `<应用根>/app/`,
`APP_DIR` 是**应用根**,中间件文件在 `<应用根>/app/<mod>.py`。
旧实现只拼 `APP_DIR/<mod>.py`(少一级 app/)→ 绝对路径兜底恒失败 →
组合链被判「不可导入」→ 降级 fail-closed(线上靠 sys.path[0]=app/ 侥幸可用)。
现两处都试:`<应用根>/app/` 优先,再兜 `<应用根>/`(兼容历史扁平布局)。
"""
name = mod.split(".")[-1] + ".py"
for cand in (os.path.join(APP_DIR, "app", name), os.path.join(APP_DIR, name)):
if os.path.isfile(cand):
return cand
return os.path.join(APP_DIR, "app", name)
def _import_attr(mod, attr):
"""取中间件对象:先按包名导入,**失败再按绝对文件路径加载**。
为什么必须有绝对路径兜底(Bug vRZrx0Ff9tz / R18 复测根因):
部署态入口用 `python app/scense.py` 启动时 `sys.path[0]` 是 `app/` 而非应用根,
`__import__("route_guard_middleware")` 可能 ImportError;旧实现 `continue` 掉
不记录 → `compose_guard_chain()` 抛 RuntimeError → 外层**降级为 handler-only**
(只做 404 映射)→ 鉴权层整层缺席 → 匿名请求直达业务处理器返回 200。
绝对路径加载(importlib.util.spec_from_file_location)不依赖 sys.path,
与 `app/scense.py::_bsm_load_by_path` 的做法同源。
"""
for name in (mod, "app." + mod):
try:
m = __import__(name, fromlist=[attr])
obj = getattr(m, attr, None)
if obj is not None:
return obj
except ImportError:
continue
except Exception: # noqa: BLE001
continue
# 绝对文件路径兜底
try:
import importlib.util
path = _module_file(mod)
if not os.path.isfile(path):
return None
spec = importlib.util.spec_from_file_location(
"_yj_guard_" + os.path.basename(path)[:-3], path)
if spec is None or spec.loader is None:
return None
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return getattr(m, attr, None)
except Exception as exc: # noqa: BLE001
log.error("[semantics] 中间件按绝对路径加载失败 %s.%s: %s", mod, attr, exc)
return None
def _maybe_405(request):
"""入口内建只读路径 + 非读方法 → 405 响应对象;否则 None(继续原逻辑)。
真源单点:`app/get_only_405.py::maybe_405`(QC #4「新模块零引用=死代码」的接线点)。
取模块走 `_import_attr` 同一通道:先包导入,失败按绝对文件路径加载 —— 兼容
`python app/scense.py` 启动时 sys.path[0]=app/ 的场景。
"""
try:
mod = sys.modules.get("get_only_405")
fn = getattr(mod, "maybe_405", None) if mod is not None else None
if fn is None:
fn = _import_attr("get_only_405", "maybe_405")
if fn is None:
return None
return fn(web, request)
except Exception as exc: # noqa: BLE001
log.error("[405] get_only_405 判定失败(放行到原逻辑): %s", exc)
return None
def _wrap(mw, nxt):
"""把一个 `(request, handler)` 中间件包成 `chain(request)`。
不用 functools.partial:partial(mw, nxt) 会把 nxt 绑到 **request** 形参位上,
中间件内部就再也拿不到 request(历史装配 bug 的同型错误)。闭包绑定无歧义。
"""
async def _chain(request):
return await mw(request, nxt)
return _chain
def compose_guard_chain(handler):
"""把语义中间件按「外层优先」串成一条链,返回最外层可调用对象。
:param handler: 业务处理器
:return: 可 await 的中间件链(签名 `chain(request)`)
:raises RuntimeError: 任一中间件不可导入(调用方须降级,不得静默当成功)
"""
chain = handler
missing = []
for mod, attr in _COMPOSED_INNER_FIRST:
mw = _import_attr(mod, attr)
if mw is None:
missing.append(attr)
continue
chain = _wrap(mw, chain)
if missing:
raise RuntimeError("语义中间件不可导入: %s" % ",".join(missing))
return chain
def _notfound_response(request):
path = request.path or ""
is_api = path.endswith(_JSON_SUFFIXES)
log.warning(
"[404] resource not found: path=%s (client=%s) -> HTTPNotFound",
path,
request.remote or "-",
)
if is_api:
return web.json_response(
{
"status": 404,
"success": False,
"reason": "no such resource",
"path": path,
"hint": "端点/静态资源不存在:核对 wwwroot 是否已软链、端点名是否与磁盘文件一致",
},
status=404,
)
return web.Response(
status=404,
text="404 Not Found: " + path,
content_type="text/plain",
)
def _failclosed_auth_gate():
"""组合链装配失败时的**兜底闸门**(fail-closed,绝不 handler-only 放行)。
Bug vRZrx0Ff9tz / R18 复测:旧实现 `chain = handler` 是 fail-**open** ——
任一中间件导入失败即整层语义(含鉴权)缺席,匿名请求直达业务处理器返回 200。
改为:装配失败时至少挂上 `auth_gate_strict_middleware`(绝对路径加载,
零 app 包依赖);连它也取不到则用本函数内联的极简闸门(非公开 `.dspy` → 401)。
"""
strict = _import_attr("auth_gate_strict", "auth_gate_strict_middleware")
if strict is not None:
log.error("[semantics] 组合链装配失败,降级为 auth_gate_strict 兜底闸门"
"(fail-closed,匿名受保护 API 一律 401)")
return strict
async def _inline_deny(request, handler):
# [R62 / 匿名读全站 P0] 旧实现缺失本函数(连兜底闸门都没有 → fail-open,
# 匿名直达业务处理器返回 200)。语义:
# 1. 豁免清单(单一真源 auth_exempt)命中 → 放行(否则登录端点自己 401);
# 2. `.dspy` 后缀 **或** 命中业务 API 前缀覆盖面(/scense /runtime /scene
# /world /entity 下的 api 端点)→ 401,绝不返回业务数据;
# 3. 判定自身异常 → 按受保护处理(401),判不了就拒绝。
path = ""
try:
path = request.path or ""
if is_exempt_path(path):
return await handler(request)
low = path.split("?", 1)[0].lower()
gated = low.endswith(".dspy")
if not gated:
try:
gated = bool(EXEMPT.is_protected_api_path(path)) if EXEMPT else True
except Exception: # noqa: BLE001
gated = True
if gated:
log.error("[semantics] 内联兜底闸门 401(语义层全部不可用): %s", path)
return web.json_response(
{"ok": False, "status": 401, "error": "auth_unavailable", "path": path},
status=401)
except Exception: # noqa: BLE001
try:
if path and not is_exempt_path(path):
return web.json_response(
{"ok": False, "status": 401, "error": "auth_unavailable",
"path": path}, status=401)
except Exception: # noqa: BLE001
pass
return await handler(request)
log.error("[semantics] 组合链与 auth_gate_strict 均不可用,降级为内联兜底闸门")
return _inline_deny
@web.middleware
async def resource_notfound_middleware(request, handler):
"""语义组合中间件:404(异常侧 + 前置侧)/ 401 / 405 / 400 统一收口。
* 401/403/404/405 等语义响应由内层中间件直接产生,原样返回;
* ahserver「路径解析失败」异常 → 404;
* 其余异常原样上抛(真实后端缺陷仍为 500,不掩错)。
* 组合链装配失败 → 挂兜底鉴权闸门(fail-closed),**不再**降级为 handler-only。
"""
# ① 豁免判定(**最前置**:早于资源存在性、早于会话校验、早于方法闸门)。
# 与 auth_exempt 判据第 3 条一致:登录/登出/健康探针在「还没有会话」的时刻
# 就必须可达,否则构成"先登录才能登录"的自指死循环(R57b/R59 实测根因)。
# 注意:这里**只对显式豁免路径**生效,未豁免路径继续走下面的原逻辑
# (scripts/selftest_notfound_scope.py 有「未豁免仍拦截」的反向断言)。
if is_exempt_path(request.path):
log.debug("[semantics] 豁免路径放行: %s", request.path)
return await handler(request)
# ② 前置方法闸门(最外层;组合链装配失败也照样生效,不受 try 影响)
gate = _maybe_405(request)
if gate is not None:
return gate
try:
chain = compose_guard_chain(handler)
except Exception as exc: # noqa: BLE001
log.error("[semantics] 组合链装配失败(%s),降级为 fail-closed 鉴权闸门", exc)
chain = _wrap(_failclosed_auth_gate(), handler)
try:
return await chain(request)
except web.HTTPException:
# 框架自身抛出的 HTTP 语义异常(404/401/403 等)不拦截
raise
except Exception as exc: # noqa: BLE001
if is_resource_missing(exc):
# 异常侧同样先判方法语义:POST/PUT/DELETE /healthz 会一路落到
# ahserver 资源解析抛 invalid path,这里必须升 405 而不是 404。
gate = _maybe_405(request)
if gate is not None:
return gate
return _notfound_response(request)
raise

View File

@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""宿主侧命名空间注入接线(T4 dspy 命名空间符号注入族)。
【为什么在宿主侧还有一层】ahserver 的 .dspy exec 命名空间由框架在每次请求构造,
模块 init() 的 `env.xxx = xxx` 只有在**宿主已调用 load_{模块}()** 时才生效;基础模块
(rbac / appbase / bricks)是 referenced_modules(只读,禁改源码),其端点若依赖
`env.<handler>` 而该符号未挂/为 None,表现就是 `TypeError: 'NoneType' object is not callable`
(R42 集群⑤)。宿主侧做两件确定性的事:
1) `install_module_shim(env)` —— 把业务模块的统一 dispatch 入口挂到 ServerEnv
(scense.ns_shim.install;scense_demo 复用同一入口,不重复实现)。
2) `backfill_base_handlers(env)` —— 对只读基础模块,用包内真实存在且可调用的函数
补齐 `env.<name>`(**只 setattr 运行时命名空间,不改基础模块源码**);
包内也不存在的符号不伪造,交由端点侧 ns-shim 给 `symbol-missing:<op>` 结构化 503。
接线点:应用入口 init() 在全部 load_*() 之后调用本模块 `wire_ns_shim(env)`,并把
`env.NS_SHIM_WIRING` 落一条自证字符串,供部署态核验。
"""
import importlib
NS_WIRING_VERSION = "t4-host-wiring-1"
# 只读基础模块的 handler 候选(包内存在才补;不存在不伪造)
BASE_HANDLER_CANDIDATES = (
("rbac", ("find_unauth_files", "list_path_roles", "list_roles", "list_permissions")),
("appbase", ("get_code", "get_appcodes_kv", "get_appcodes", "get_svgicon")),
("bricks", ("list_tables", "tableinfo", "channels")),
)
def _get(env, name):
try:
return getattr(env, name, None)
except Exception:
return None
def _set(env, name, value):
try:
setattr(env, name, value)
return True
except Exception:
pass
try:
env[name] = value
return True
except Exception:
return False
def install_module_shim(env):
"""业务模块统一 dispatch 入口(scense 域)。返回已挂载入口名清单。"""
installed = []
if _get(env, "dspy_ns_call") is not None:
installed.append("dspy_ns_call(pre-existing)")
try:
from scense.ns_shim import install as _install_scense
_install_scense(env)
installed.append("scense.ns_shim.install")
except Exception as exc: # 模块未装/未加载都不是致命错误,但要留痕
installed.append("scense.ns_shim.install:FAIL:%s" % exc)
return installed
def backfill_base_handlers(env):
"""只读基础模块 handler 补齐。返回 {module: [补齐符号]}。"""
report = {}
for modpath, names in BASE_HANDLER_CANDIDATES:
try:
mod = importlib.import_module(modpath)
except Exception:
report[modpath] = []
continue
fixed = []
for name in names:
fn = getattr(mod, name, None)
if fn is None or not callable(fn):
continue
if _get(env, name) is None:
if _set(env, name, fn):
fixed.append(name)
report[modpath] = fixed
return report
def matrix(env):
"""注入生效矩阵(部署态可经 /scense/api 侧或脚本读取)。"""
rows = []
try:
from scense.ns_shim import ns_shim_matrix
rows = ns_shim_matrix(env).get("rows", [])
except Exception:
rows = []
base = {}
for modpath, names in BASE_HANDLER_CANDIDATES:
base[modpath] = {
name: {
"env_has": _get(env, name) is not None,
"module_has": _module_has(modpath, name),
} for name in names
}
return {"version": NS_WIRING_VERSION, "ops": rows, "base_handlers": base}
def _module_has(modpath, name):
try:
mod = importlib.import_module(modpath)
except Exception:
return False
return callable(getattr(mod, name, None))
def wire_ns_shim(env):
"""入口接线:install + backfill + 自证。永不抛异常(启动期不能让接线阻断服务)。"""
out = {"version": NS_WIRING_VERSION, "installed": [], "backfilled": {}, "error": None}
try:
out["installed"] = install_module_shim(env)
except Exception as exc:
out["error"] = "install:" + str(exc)
try:
out["backfilled"] = backfill_base_handlers(env)
except Exception as exc:
out["error"] = (out["error"] or "") + "|backfill:" + str(exc)
summary = "; ".join(
["installed=" + ",".join(out["installed"])] +
["%s=%s" % (k, ",".join(v) or "-") for k, v in out["backfilled"].items()]
)
try:
env.NS_SHIM_WIRING = NS_WIRING_VERSION + " " + summary
env.ns_shim_matrix = lambda: matrix(env) # noqa: E731 部署态自检入口
except Exception:
pass
return out

View File

@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
"""PASSWORD_KEY 占位解析兜底 —— 两个入口共用的**单一实现**(QC #9/#15 根因修复)。
背景(QC #15 实测):
conf/config.json 的 password_key 写的是 `$[env:PASSWORD_KEY]$`(环境变量注入形态),
但实测 `getConfig().password_key` 返回的**仍是未解析的占位字面量**(len=20,
prefix='$[en'),而 .env 的 `env.PASSWORD_KEY` 是有效值(len=24)。sqlor 的
`unpassword()` 正是拿 config.password_key 当 AES key 去解 db password,
于是报 `Invalid base64-encoded string`,最终 MySQL `using password: NO` (1045)
→ `/rbac/user/up_login.dspy` 401 auth_unavailable。
同文件对 `DB_PASSWORD` 早已写了「占位校验 + 回落兜底」(apps/yuanjing/app/yuanjing.py
L488/L822+),`PASSWORD_KEY` 缺对称逻辑 —— 本模块补齐,并保证
app/scense.py(目标机实际运行的入口)与 app/yuanjing.py(现行入口)**同一份实现**。
与 DB_PASSWORD 对称的三件事:
① 识别占位字面量(非空字符串,真值判断会把 '= 有效 key',这正是缺陷机制);
② 三路事实源回落:进程环境 PASSWORD_KEY → <APP_DIR>/run/password_key → 现场生成+持久化;
③ 就地回填 os.environ 与已构造的 config 对象,并打印 `[password-key] source=env|file|generated`。
本模块无任何凭据字面量,key 一律不回显(只打长度/来源)。
"""
import os
PLACEHOLDER_HINT = '$[env:PASSWORD_KEY]$'
def is_unresolved_placeholder(v):
"""判断 v 是否为未解析的 `$[...]$` 占位字面量(如 '$[env:PASSWORD_KEY]$')。
**不能**用真值判断:占位字面量是非空 str,`if key:` 会把它当有效 key 一路用下去。
"""
if not isinstance(v, str):
return False
s = v.strip()
if not s:
return False
return s.startswith('$[') and s.endswith(']$')
def read_key_file(app_dir):
"""读部署本地 key 文件 <APP_DIR>/run/password_key;缺失/不可读 → None(不抛)。"""
path = os.path.join(app_dir, 'run', 'password_key')
try:
with open(path, 'r', encoding='utf-8') as fp:
return (fp.read() or '').strip() or None
except Exception: # noqa: BLE001
return None
def _persist(app_dir, key, logger=None):
path = os.path.join(app_dir, 'run', 'password_key')
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as fp:
fp.write(key)
try:
os.chmod(path, 0o600)
except Exception: # noqa: BLE001
pass
return True
except Exception as exc: # noqa: BLE001
if logger:
logger.error("[password-key] 自动生成的 key 无法持久化到 %s:%s", path, exc)
return False
def default_resolver(app_dir, logger=None):
"""默认三路解析:进程环境 → 本地 key 文件 → 现场生成+持久化。返回 (key, source)。"""
key = (os.environ.get('PASSWORD_KEY') or '').strip() or None
if key and is_unresolved_placeholder(key):
if logger:
logger.warning("[password-key] 进程环境 PASSWORD_KEY 本身是占位字面量(len=%d),忽略",
len(key))
key = None
if key:
return key, 'env'
key = read_key_file(app_dir)
if key and is_unresolved_placeholder(key):
key = None
if key:
return key, 'file'
import secrets
gen = secrets.token_hex(16)
if _persist(app_dir, gen, logger=logger):
if logger:
logger.warning("[password-key] 未注入:按部署期自动生成策略在本机生成并持久化到 "
"<APP_DIR>/run/password_key(0600, len=%d);该文件须随部署目录保留,"
"否则既有口令将无法解密。", len(gen))
return gen, 'generated'
return None, None
def normalize_password_key_env(app_dir, resolver=None, logger=None):
"""把解析后的 PASSWORD_KEY 回填进程环境 + 已构造 config 对象。
调用时机(硬约束):load_app_env_file() 之后(模块导入期已执行)、
**任何 getConfig() / sqlor unpassword() 之前**。
返回 True = key 可用且已回填;False = 三路事实源皆不可得。
"""
resolver = resolver or (lambda: default_resolver(app_dir, logger=logger))
key, source = resolver()
if not key:
if logger:
logger.error("[password-key] 三路事实源皆不可得(env / <APP_DIR>/run/password_key / "
"自动生成),sqlor 解 db 口令将失败;检查 <APP_DIR>/.env 与 run/ 权限")
return False
# ① 回填进程环境:getConfig() 每次从 conf/config.json 经 argsConvert 解析
# $[env:PASSWORD_KEY]$,改写 os.environ 即对后续读取生效。
os.environ['PASSWORD_KEY'] = key
patched = []
# ② 就地改写已构造的 appPublic config 对象(argsConvert 未解析时的兜底)
try:
from appPublic.jsonConfig import getConfig
cfg = getConfig(app_dir)
cur = getattr(cfg, 'password_key', None)
if is_unresolved_placeholder(cur) or not cur:
try:
cfg.password_key = key
patched.append('appPublic.jsonConfig(%s)' % app_dir)
if logger:
logger.warning("[password-key] config.password_key 原值为未解析占位"
"(len=%s),已回填解析后 key",
len(cur) if isinstance(cur, str) else cur)
except Exception as exc: # noqa: BLE001
if logger:
logger.error("[password-key] config.password_key 回填失败:%s", exc)
else:
patched.append('appPublic.jsonConfig(already-resolved)')
except Exception as exc: # noqa: BLE001
if logger:
logger.warning("[password-key] appPublic.jsonConfig 不可用,跳过 config 回填:%s", exc)
# ③ 基座 globalEnv 若缓存了配置对象,同样修正占位字面量
try:
import ahserver.globalEnv as _ge
for attr in ('_CONFIG', '_config', 'CONFIG', '_global_config'):
obj = getattr(_ge, attr, None)
if obj is not None and is_unresolved_placeholder(getattr(obj, 'password_key', None)):
setattr(obj, 'password_key', key)
patched.append('ahserver.globalEnv.%s' % attr)
except Exception: # noqa: BLE001
pass
if logger:
logger.info("[password-key] source=%s len=%d placeholder_patched=%s",
source, len(key), patched or 'none')
return True

View File

@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""响应契约守卫(最内层中间件)—— Bug Zhpd9MXyeGtznDMqini6X / R50
## 为什么需要它(R50 机械取证定案)
R49 全站矩阵把 500 记为 0,R50 在目标机日志里取到真实栈(`logs/*.log`):
------ /index.ui RuntimeError -----
Traceback (most recent call last):
File ".../app/entry_semantics.py", line 485, in entry_notfound_guard
return await handler(request)
File ".../app/route_guard_middleware.py", line 499, in resource_preflight_middleware
return await handler(request)
File ".../app/route_guard_middleware.py", line 533, in auth_required_middleware
return await handler(request)
File ".../app/route_guard_middleware.py", line 579, in write_endpoint_semantics_middleware
return await handler(request)
File ".../app/business_status_middleware.py", line 153, in business_status_middleware
response = await handler(request)
File ".../venv/lib/python3.10/site-packages/aiohttp_session/__init__.py", line 196, in factory
raise RuntimeError(f"Expect response, not {type(response)!r}")
RuntimeError: Expect response, not <class 'function'>
即:链尾**路由处理器返回了一个函数对象**,不是 `web.Response`。
`aiohttp_session.factory` 在拿到非 Response 时抛 RuntimeError,异常沿中间件链上抛,
最终被鉴权/状态层改写成一个「鉴权失败」样子的响应 —— 这就是
「38 条 500 一条也判不出来 / 500 计数=0」的机械原因:
**处理器契约破坏(返回函数)与鉴权失败是两件事,却被压成同一种 401 表象。**
## 本守卫做两件事(顺序即语义)
1. **救援**:链尾返回可调用对象时,把它当作真实处理器调用一次
(`resp(request)`,awaitable 则 await)。这是 aiohttp 装配期的「handler 未落地」
短路,直接补上这一次调用即可拿到真正的 Response —— 站点能力立刻恢复,
而不是让整站挂在鉴权表象上。
2. **归因**:救援失败 / 返回 None / 返回非法对象时,返回 **500 + 处理器身份**
(模块名、限定名、源文件、行号),让 500 可被矩阵判为「真实 500 并指名道姓」,
绝不再伪装成 401 auth_unavailable。
作为 `app.middlewares` 的**最后一位(最内层)**装配:只有贴住路由处理器,
才能观察到「处理器返回值」本身;装在外层只会看到已经被 factory 判定过的结果。
## 不变量(不得放宽)
- 正常 Response(含所有 4xx/5xx 语义响应)**原样透传**,一个字节都不改。
- 守卫自身异常一律吞掉并放行原响应,避免守卫成为新的单点故障。
"""
import inspect
import logging
log = logging.getLogger("yuanjing.response_contract_guard")
#: 装配标记,供 /healthz 与自检读取
REVISION = "r50-response-contract-v1"
def _handler_identity(fn):
"""取可调用对象身份:模块 / 限定名 / 源文件 / 行号。取不到就给 repr。"""
ident = {"repr": repr(fn)[:300]}
try:
ident["module"] = getattr(fn, "__module__", "") or ""
ident["qualname"] = getattr(fn, "__qualname__", "") or getattr(fn, "__name__", "") or ""
except Exception: # noqa: BLE001
pass
try:
src = inspect.getsourcefile(fn)
if src:
ident["file"] = src
_, lineno = inspect.getsourcelines(fn)
ident["line"] = lineno
except Exception: # noqa: BLE001
pass
return ident
def _is_response(obj):
"""宽松判定:aiohttp Response 及其子类(含各 HTTPException 派生)。"""
try:
from aiohttp import web
return isinstance(obj, web.StreamResponse)
except Exception: # noqa: BLE001
return hasattr(obj, "status") and hasattr(obj, "headers")
def _json_response(payload, status):
"""构造 JSON 响应。**不得传 dumps=None**——aiohttp 的 `json_response(dumps=None)`
会退化成 text/plain 直发 repr,导致 500 归因体不是合法 JSON、矩阵无法解析。"""
from aiohttp import web
try:
return web.json_response(payload, status=status)
except Exception: # noqa: BLE001
import json as _json
return web.Response(status=status,
text=_json.dumps(payload, ensure_ascii=False, default=str),
content_type="application/json")
def _fail(request, reason, ident, rescued_error=""):
payload = {
"ok": False,
"success": False,
"status": 500,
"error": "response_contract_violation",
"reason": reason,
"path": getattr(request, "path", "") or "",
"method": (getattr(request, "method", "GET") or "GET").upper(),
"handler": ident,
"rescued_error": str(rescued_error)[:500],
"guard_revision": REVISION,
"hint": "处理器返回值不是 Response:见 handler.file/line 定位真实缺陷点",
}
log.error("[response_contract] %s %s %s -> %s %s",
payload["method"], payload["path"], reason, ident.get("qualname", "-"),
ident.get("file", "-"))
return _json_response(payload, 500)
# [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 response_contract_guard(request, handler):
"""最内层响应契约守卫:救援「返回函数」的处理器,并把契约破坏归因为 500。"""
try:
resp = await handler(request)
except Exception: # noqa: BLE001
# 处理器自身异常:原样上抛,交给既有的 500/业务状态层(不在此改写语义)
raise
if _is_response(resp):
return resp
if resp is None:
return _fail(request, "handler_returned_none", {"repr": "None"})
if callable(resp):
ident = _handler_identity(resp)
# ① 救援:把返回的函数当作真实处理器调用一次
try:
rescued = resp(request)
if inspect.isawaitable(rescued):
rescued = await rescued
except Exception as exc: # noqa: BLE001
return _fail(request, "handler_returned_callable_rescue_failed", ident, exc)
if _is_response(rescued):
log.warning("[response_contract] rescued callable handler %s -> %s",
ident.get("qualname", "-"), getattr(rescued, "status", "?"))
return rescued
return _fail(request, "handler_returned_callable_rescue_nonresponse", ident,
"type=%r" % (type(rescued),))
return _fail(request, "handler_returned_nonresponse",
{"repr": repr(resp)[:300], "type": type(resp).__name__})
def install(app):
"""把守卫装到 app.middlewares 的**最后一位(最内层)**。幂等,永不抛出。"""
out = {"ok": False, "revision": REVISION, "names": [], "error": ""}
try:
mws = getattr(app, "middlewares", None)
if mws is None:
out["error"] = "app.middlewares unavailable"
return out
names = [getattr(m, "__name__", repr(m)) for m in mws]
if response_contract_guard.__name__ in names:
out.update(ok=True, names=names)
return out
app.middlewares.append(response_contract_guard)
out.update(ok=True, names=[getattr(m, "__name__", repr(m)) for m in app.middlewares])
return out
except Exception as exc: # noqa: BLE001
out["error"] = "%s: %s" % (type(exc).__name__, exc)
log.error("[response_contract] install failed: %s", out["error"])
return out

View File

@ -0,0 +1,697 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""路由前置守卫中间件(404 前置判定 + 未登录 401 + 写类端点方法/参数语义)。
覆盖的 Bug(本轮 R23/R26/R27/R28 反复复发的四条族)
--------------------------------------------------
* Bug P-Wn8NVUTBfdnHrGe4bKF / WXXvXrhHsHuHz2t37y54Y
「不存在路径 /nonexist/x.dspy 返回 401(或 500)而不是 404」。
根因:ahserver 的 processorResource 先解析路径再进鉴权,路径解析失败抛
`invalid path`(processorResource.py:458 fpath is None)→ auth_api.py:189 把它
当后端异常。鉴权层在**处理器之前**执行,因此无 cookie 时 401 先于 404 返回,
应用层「异常转 404」的中间件根本没有机会执行(异常没抛到中间件层)。
修法:在 aiohttp 中间件里(= 早于处理器与鉴权)**先做路由存在性判定**:
把 URL 映射到 `<app_root>/wwwroot/...`,文件不存在直接 404 JSON,
存在才放行给 ahserver 继续处理。这就是「fpath is None 的 404 兜底前置化」。
* Bug WhOYy9nvG-EfNgZMvbJbO
「未登录访问 /scense/api/list_scense.dspy 返回 200 业务信封」。
根因:该端点未登记进 permission 表 / 无 rolepermission 绑定,ahserver 的
check_perm 判定为「无记录即公开」→ 匿名可读业务数据;而同层的**不存在**路径
却返回 401,语义自相矛盾,且数据就绪后即为数据泄露面。
修法:`auth_required_middleware` —— 对**已存在**的 `.dspy` 端点,若请求不带
任何会话凭据(cookie / Authorization / 显式旁路口令)且不在公开白名单内,
直接 401(附 WWW-Authenticate),不再放行到业务处理器。
与 404 的关系:本中间件挂在 `resource_preflight_middleware` **之内**,
因此「不存在路径」永远先拿到 404,不会退化成 401。
* Bug CQIpIoIJODNdSt4B0VvVv / YSNA7decF_pOeeKOimSaH / Zhpd9MXyeGtznDMqini6X
「生成型 CRUD 写操作接口被 GET 调用 / 无参调用时抛 sqlor 1364 默认值错误 → 500」。
根因:写类端点(*_create/*_update/*_delete/*_add/*_save/_import)在 GET 或无参
请求下把 None 交给 sor.C/U/D,DB 侧报
`(1364, "Field 'xxx' doesn't have a default value")` → 泛化 500。
修法:①写类端点只接受 POST → GET/HEAD 返回 405 + Allow: POST, OPTIONS(RFC 7231 §6.5.5);
②POST 但请求体内一个业务参数都没有 → 400(缺必填参数语义),
不进 DB,天然消除 1364;其余端点行为不变。
与方法语义的顺序:405 判定**先于**鉴权门禁(`auth_required_middleware` 对
写类 GET/HEAD 主动让行),保证无 cookie 的 GET 写端点也拿到 405 而非 401 ——
「方法不允许」与「调用者身份」正交,方法错应先报。
设计约束(不掩错)
------------------
* 只判定 `.dspy` / `.ui` 两类资源的存在性(其它静态资源交给 ahserver 正常处理)。
* 鉴权门禁只作用于 `.dspy` 端点(`.ui` 页面由 ahserver 会话层处理,避免把登录页
自身锁死);公开白名单集中声明在 `PUBLIC_DSPY_PATHS`。
* 存在性判定**任一候选命中即放行**:`wwwroot/<path>`、`wwwroot/<path>/index.ui`,
以及模块未软链的情况(`pkgs/<module>/wwwroot/<rest>`)。
* 只改 404 / 401 / 405 / 400 四类语义;读类端点的真实故障仍是 500,不被伪装成 4xx。
* 中间件自身异常一律放行原响应,绝不因守卫代码影响业务。
"""
import base64
import json
import logging
import os
import re
import time
from aiohttp import web
log = logging.getLogger("yuanjing.route_guard")
# 参与存在性判定的后缀(页面与接口)
GUARDED_SUFFIXES = (".dspy", ".ui")
# 写类端点词干(命中即视为「只接受 POST」)
_WRITE_STEM_RE = re.compile(
r"(^|_)(create|update|delete|add|save|edit|import|remove|destroy|upsert)(_|$)",
re.IGNORECASE,
)
# 例外:这些端点虽然名字里带写词,但语义是读(下拉/列表/查询)
_READ_ONLY_EXCEPTIONS = re.compile(r"(^|_)(list|get|search|query|options)(_|$)", re.IGNORECASE)
# 登录/会话类端点:不吃业务参数的 POST 也要放行
_PARAM_EXEMPT_STEMS = ("up_login", "login", "logout", "up_logout")
# ── 鉴权门禁(Bug WhOYy9nvG-EfNgZMvbJbO)─────────────────────────────────────
#: 无需会话即可访问的 .dspy 端点(登录/注册/登出/健康探针)。
#: 只收**端点自身语义就是「未登录也要能调」**的路径,其余一律要求会话。
# ── 鉴权豁免清单:**单一真源** app/auth_exempt.py(T4 / Bug uifdBIMdmixZcmJEdV33O)──
# 本文件此前自带一份 PUBLIC_DSPY_PATHS 副本;与 auth_route_gate / auth_gate_strict /
# notfound_middleware 内联兜底闸门的副本各自演化,导致「主线豁免、兜底不豁免」。
def _load_auth_exempt():
"""加载豁免清单模块(部署态 sys.path 可能不含应用根,禁裸包导入)。"""
try:
from app import auth_exempt as mod # type: ignore
return mod
except Exception: # noqa: BLE001
import importlib.util
module_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"auth_exempt.py")
spec = importlib.util.spec_from_file_location("yj_auth_exempt_rg", module_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
EXEMPT = _load_auth_exempt()
def _derive_exempt_manifest(mod):
"""由单一真源 `auth_exempt.manifest()` **自动派生**镜像(禁止手工复制字面量)。
[fix rvtGi1mlUsTwQrXnybZj6 / T4-A] 本文件的 PUBLIC_DSPY_PATHS /
PUBLIC_PATH_PREFIXES 仅是**派生镜像**(诊断 + 自测交叉断言);
判定一律走 `EXEMPT.is_exempt_path()`(见 `_is_exempt_path()`)。
"""
if mod is None:
return {"revision": "unavailable", "dspy_paths": frozenset(),
"prefixes": (), "wildcard_free": False}
try:
mf = mod.manifest()
except Exception: # noqa: BLE001
return {
"revision": getattr(mod, "EXEMPT_REVISION", "unavailable"),
"dspy_paths": frozenset(getattr(mod, "AUTH_EXEMPT_DSPY_PATHS", ()) or ()),
"prefixes": tuple(getattr(mod, "AUTH_EXEMPT_PREFIXES", ()) or ()),
"wildcard_free": False,
}
return {
"revision": str(mf.get("revision") or "unavailable"),
"dspy_paths": frozenset(mf.get("dspy_paths") or ()),
"prefixes": tuple(mf.get("prefixes") or ()),
"wildcard_free": bool(mf.get("wildcard_free", False)),
}
EXEMPT_MANIFEST = _derive_exempt_manifest(EXEMPT)
#: 公开端点(**精确路径**)—— 由 manifest() 派生,只读,禁止在本文件增删字面量。
PUBLIC_DSPY_PATHS = EXEMPT_MANIFEST["dspy_paths"]
#: 公开前缀(健康探针/静态探测;按路径段边界匹配)—— 由 manifest() 派生。
PUBLIC_PATH_PREFIXES = EXEMPT_MANIFEST["prefixes"]
# ── 统一会话鉴权拦截(QC #2/#3 整改:凭据"存在性" → 凭据"可验证性")──────────
#: 受闸门约束的资源后缀(只拦动态端点;.ui/.js/.css 由框架会话层处理)。
GUARDED_SUFFIX = ".dspy"
#: 鉴权闸门总开关的环境变量名(`YJ_AUTH_GATE=off` 仅限排障,生产禁开)。
#: 2026-10-23 修复:`_gate_enabled()` 引用了未定义名 `_GATE_ENV` → NameError →
#: 鉴权中间件 fail-closed 成 `auth_unavailable` 401,把**已存在的受保护端点**
#: 与写类 405/400 语义一并吞掉(selftest 用例 2~5 FAIL 的直接原因)。
_GATE_ENV = "YJ_AUTH_GATE"
#: 会话 cookie **精确名**(默认 AIOHTTP_SESSION + conf 的 website.session.cookie_name)。
#: 收敛说明:旧实现用 `_SESSION_COOKIE_HINTS = ("sid", "session", "token")` 做**子串**
#: 匹配 —— 等价于"名字里带 sid/session/token 的任意 cookie 都算登录凭据",属隐式通配放行
#: (`Cookie: sid=x` 即可绕过)。该启发式本轮**删除**,只认精确名。
SESSION_COOKIE_NAME_DEFAULT = "AIOHTTP_SESSION"
#: 与 ahserver `AuthAPI.setupAuth` 的 `EncryptedCookieStorage(max_age=24*60*60)` 对齐。
SESSION_COOKIE_MAX_AGE = 24 * 60 * 60
#: 必须走统一会话校验的 URL 前缀(覆盖率核对基线,防"只补被测 3 条")。
#: 判定规则本身是"所有 .dspy 端点一律校验",前缀清单只用于逐前缀核对。
AUTH_REQUIRED_PREFIXES = ("/scense", "/runtime", "/scene", "/world", "/entity")
#: ── 受保护端点**逐条点名**登记(Bug vRZrx0Ff9tz / R18 复测)───────────────
#: 背景:判定规则本身是「所有非公开 `.dspy` 一律校验」,但部署态实测
#: `/scense/api/list_scense.dspy` 匿名 200 —— 说明规则未被命中(链未装配,见
#: `app/auth_gate_strict.py` 文档的根因链)。除修装配外,这里再补一层
#: **显式点名**:即便某个端点的后缀/写法判定被绕过,只要在点名表里就必须
#: 走会话校验(`is_protected_endpoint` 先于后缀判定)。
#: 登记口径:逐条对照磁盘真实文件(module-development-spec 硬门禁 #6),
#: 禁通配;由 `scripts/verify_auth_coverage.py` 机械核对「磁盘 .dspy 全集 ⊇ 点名表」
#: 与「点名表每条在磁盘存在」。
PROTECTED_DSPY_PATHS = frozenset((
# scense —— 本单 R18 复测实测匿名 200 的端点(读类,必须有会话)
"/scense/api/list_scense.dspy",
"/scense/api/list_session.dspy",
"/scense/api/session_attrs.dspy",
"/scense/api/entity_attrs.dspy",
# scense —— 写类(admin)
"/scense/api/entity_edit.dspy",
"/scense/api/session_create.dspy",
# scene
"/scene/api/get_search_status.dspy",
"/scene/api/get_search_scene_type.dspy",
"/scene/api/create_scene.dspy",
# entity
"/entity/api/entity_create.dspy",
"/entity/api/entity_update.dspy",
"/entity/api/entity_delete.dspy",
# world
"/world/api/create_world.dspy",
))
def normalize_request_path(path):
"""归一化请求路径(封堵大小写/尾斜杠/重复斜杠/URL 编码等写法绕过)。
与 `app/auth_gate_strict.py::normalize_path` 必须同构(自测交叉断言)。
"""
import urllib.parse as _urlparse
p = str(path or "")
p = _urlparse.unquote(p)
p = p.split("?", 1)[0].split("#", 1)[0]
p = "/" + "/".join([seg for seg in p.split("/") if seg])
if p != "/":
p = p.rstrip("/")
if p.lower().endswith(GUARDED_SUFFIX) or p.startswith("/rbac/"):
return p.lower()
return p
def is_protected_endpoint(path):
"""该路径是否被**逐条点名**为受保护端点(精确匹配,禁通配)。"""
p = normalize_request_path(path)
return p in PROTECTED_DSPY_PATHS
def auth_gate_decision(path):
"""闸门裁决(单一真源,供中间件与自测/覆盖脚本共用)。
返回 ``"off"`` / ``"public"`` / ``"other"``(非受闸门资源)/ ``"protected"``。
``"protected"`` = 必须通过会话校验(真实解密 Fernet 密文,**不是**只看 cookie
是否存在——见 `has_session_credential` 文档记录的 P0 教训),否则 401。
[R62 / 匿名读全站 P0,需求 1+2] 覆盖面从「仅 `.dspy` 后缀」扩为
**后缀 ∪ 点名表 ∪ 业务 API 前缀**:判定顺序
1. 总开关 → off;
2. 显式公开(豁免清单,无通配)→ public;
3. 逐条点名受保护 → protected;
4. `.dspy` 后缀 → protected(历史行为);
5. **命中 /scense /runtime /scene /world /entity 前缀且含 `/api/` 段或
`.dspy` 结尾 → protected**(本轮新增;旧实现只看后缀,无后缀的
`/runtime/api/build_entity_tree` 这类会被判成 other 直接放行 → 匿名 200);
6. 其余 → other。
"""
p = normalize_request_path(path)
if not _gate_enabled():
return "off"
if is_public_endpoint(p):
return "public"
if is_protected_endpoint(p):
return "protected"
if p.lower().endswith(GUARDED_SUFFIX):
return "protected"
# 前缀覆盖面(判定委托单一真源,禁在本文件复制清单)
try:
if EXEMPT is not None and EXEMPT.is_protected_api_path(p):
return "protected"
except Exception as exc: # noqa: BLE001
# 判不了 → 按受保护处理(fail-closed,绝不放行业务数据)
log.error("[route_guard] 业务 API 前缀判定异常(按受保护处理): %s", exc)
return "protected"
return "other"
#: 会话数据里承载登录票据的键(aiohttp_auth TktAuthentication 默认 cookie_name)。
AUTH_TICKET_KEYS = ("AUTH_TKT", "aiohttp_auth.user")
#: 复刻 ahserver AuthAPI.setupAuth 密钥派生的固定尾串(共 32 字节)。
_SESSION_SECRET_TAIL = b"iqwertyuiopasdfghjklzxcvbnm12345"
def _app_conf():
"""读 conf/config.json(读不到返回 {},绝不因配置问题抛到中间件链)。"""
try:
cfg_path = os.path.join(_app_root(), "conf", "config.json")
with open(cfg_path, encoding="utf-8") as fp:
return json.load(fp) or {}
except Exception: # noqa: BLE001
return {}
def _session_secret_key():
"""复刻 ahserver `AuthAPI.setupAuth` 的 Fernet 密钥派生(port 前缀 + 固定尾串)。"""
port = str((( _app_conf().get("website") or {}).get("port")) or "")
b = port.encode("utf-8")
cnt = 32 - len(b)
if cnt < 0:
b, cnt = b[:32], 0
return base64.urlsafe_b64encode(b + _SESSION_SECRET_TAIL[:cnt])
def _session_cookie_names():
"""本次请求允许承载会话的 **精确** cookie 名(无子串启发式)。"""
names = [SESSION_COOKIE_NAME_DEFAULT]
cfg_name = _configured_session_cookie()
if cfg_name and cfg_name not in names:
names.append(cfg_name)
return tuple(names)
def decrypt_session_cookie(raw_cookie):
"""解密 AIOHTTP_SESSION → `(state, data)`。
state ∈ ``absent``(无 cookie)/ ``forged``(Fernet InvalidToken:伪造或被篡改)/
``expired``(可解密但 created + max_age < now)/ ``ok``。
"""
if raw_cookie is None or not str(raw_cookie).strip():
return "absent", None
try:
from cryptography.fernet import Fernet, InvalidToken
except Exception: # noqa: BLE001
return "forged", None
token = str(raw_cookie).strip().encode("utf-8")
fernet = Fernet(_session_secret_key())
try:
data = json.loads(fernet.decrypt(token, ttl=SESSION_COOKIE_MAX_AGE).decode("utf-8"))
return ("ok", data) if isinstance(data, dict) else ("forged", None)
except InvalidToken:
try:
data = json.loads(fernet.decrypt(token).decode("utf-8"))
except Exception: # noqa: BLE001
return "forged", None
if not isinstance(data, dict):
return "forged", None
created = int(data.get("created") or 0)
if created and (time.time() - created) > SESSION_COOKIE_MAX_AGE:
return "expired", data
return "forged", None
except Exception: # noqa: BLE001
return "forged", None
def extract_auth_ticket(session_data):
"""从解密后的会话数据取登录票据(aiohttp_auth 把票据存在会话里)。"""
if not isinstance(session_data, dict):
return None
for key in AUTH_TICKET_KEYS:
val = session_data.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return None
def _ticket_userid(ticket, request):
"""尽力解出票据里的 userid;解不出返回 ''(绝不因解码问题误杀合法会话)。"""
if not ticket:
return ""
try:
from ahserver.auth_api import CustomTktAuth
except Exception: # noqa: BLE001
return ""
try:
ip = request.get("client_ip")
except Exception: # noqa: BLE001
ip = None
try:
policy = CustomTktAuth(_session_secret_key(), 120, reissue_time=30, include_ip=True)
fields = policy._ticket.validate(ticket, ip, time.time())
return str(getattr(fields, "user_id", "") or "")
except Exception: # noqa: BLE001
return ""
async def verify_session(request):
"""统一会话校验 → `(ok, status, reason, identity)`。
ok=False 时 status 恒为 401,reason ∈
``no_session`` / ``forged_session`` / ``expired_session`` / ``no_auth_ticket`` /
``identity_mismatch``。调用方必须据此**直接返回错误响应**,不得再进业务分支。
"""
raw, cookie_name = "", ""
for name in _session_cookie_names():
val = (getattr(request, "cookies", None) or {}).get(name)
if val:
raw, cookie_name = val, name
break
state, data = decrypt_session_cookie(raw)
if state == "absent":
return False, 401, "no_session", {}
if state == "forged":
return False, 401, "forged_session", {}
if state == "expired":
return False, 401, "expired_session", {}
ticket = extract_auth_ticket(data)
if not ticket:
return False, 401, "no_auth_ticket", {}
userid = _ticket_userid(ticket, request)
# 与用户不匹配:请求显式声明 X-User-Id 且与票据身份不符 → 拦截。
claimed = ""
try:
claimed = str(request.headers.get("X-User-Id")
or request.headers.get("X-Userid") or "").strip()
except Exception: # noqa: BLE001
claimed = ""
if claimed and userid and claimed != userid:
return False, 401, "identity_mismatch", {"userid": userid}
return True, 200, "ok", {"userid": userid or claimed, "cookie": cookie_name,
"ticket_present": True}
def assert_no_wildcard(paths):
"""硬门禁:白名单/登记项不得含通配符(`*` / `%`),命中即抛 ValueError。"""
bad = [p for p in (paths or ()) if ("*" in str(p) or "%" in str(p))]
if bad:
raise ValueError("鉴权白名单禁止通配符: %s" % bad)
return True
def coverage_matrix(paths):
"""逐前缀覆盖率矩阵(端点路径 × 是否受拦截),用于"全前缀覆盖"核对。"""
rows = []
for prefix in AUTH_REQUIRED_PREFIXES:
hit = [p for p in (paths or ()) if str(p).startswith(prefix)]
guarded = [p for p in hit if p.endswith(GUARDED_SUFFIX) and not is_public_endpoint(p)]
public = [p for p in hit if is_public_endpoint(p)]
total = len(hit)
rows.append({
"prefix": prefix,
"total": total,
"guarded": len(guarded),
"public": len(public),
"coverage": round(100.0 * len(guarded) / total, 1) if total else 100.0,
"unguarded": [p for p in hit if p not in guarded and p not in public],
})
return rows
# 白名单硬门禁:模块导入即校验,谁加通配符谁在启动时炸(不静默放行)。
assert_no_wildcard(tuple(PUBLIC_DSPY_PATHS) + tuple(PUBLIC_PATH_PREFIXES) + AUTH_REQUIRED_PREFIXES)
def _app_root():
"""应用根目录(app/ 的上一级)。"""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _candidate_paths(url_path):
"""URL 路径 → 磁盘候选路径列表(命中任一即认为资源存在)。"""
root = _app_root()
rel = url_path.lstrip("/")
if not rel:
return [os.path.join(root, "wwwroot", "index.ui")]
parts = rel.split("/")
cands = [os.path.join(root, "wwwroot", rel)]
if len(parts) >= 2:
# 模块未软链到 wwwroot/<module> 时,退回 pkgs/<module>/wwwroot/<rest>
rest = "/".join(parts[1:])
cands.append(os.path.join(root, "pkgs", parts[0], "wwwroot", rest))
if not rel.endswith(GUARDED_SUFFIXES):
cands.append(os.path.join(root, "wwwroot", rel, "index.ui"))
return cands
def resource_exists(url_path):
for cand in _candidate_paths(url_path):
try:
if os.path.isfile(cand):
return True
except OSError:
continue
return False
def _json_error(request, status, reason, extra=None):
# 统一错误结构(QC #2):ok=false + error=<reason>;保留 status/success/reason 兼容既有探针。
# 响应体只含错误元信息,**不含任何业务字段**。
payload = {"ok": False, "error": reason, "status": status, "success": False,
"reason": reason, "path": request.path or ""}
if extra:
payload.update(extra)
if (request.path or "").endswith(GUARDED_SUFFIXES):
return web.json_response(payload, status=status)
return web.Response(status=status, text="%d: %s" % (status, reason))
def is_write_endpoint(path):
"""该端点是否为「写类」(只接受 POST)。"""
name = (path or "").rsplit("/", 1)[-1]
if not name.endswith(".dspy"):
return False
stem = name[: -len(".dspy")]
if _READ_ONLY_EXCEPTIONS.search(stem):
return False
return bool(_WRITE_STEM_RE.search(stem))
def _configured_session_cookie():
"""从 conf/config.json 读 website.session.cookie_name(读不到返回 None)。"""
try:
cfg_path = os.path.join(_app_root(), "conf", "config.json")
with open(cfg_path, encoding="utf-8") as fp:
cfg = json.load(fp)
name = (((cfg.get("website") or {}).get("session") or {}).get("cookie_name") or "").strip()
return name or None
except Exception: # noqa: BLE001
return None
def is_public_endpoint(path):
"""该路径是否免鉴权(登录/注册/健康探针等)。
[fix rvtGi1mlUsTwQrXnybZj6 / R61] **委托单一真源** `auth_exempt.is_exempt_path()`:
本层不再用自己的匹配实现判定(历史副本非段边界匹配,`/healthzx` 会被误放行,
且与 auth_gate_strict / notfound 的副本各自演化 —— R57b/R59 多清单漂移同源缺陷)。
`PUBLIC_DSPY_PATHS` / `PUBLIC_PATH_PREFIXES` 仅为 EXEMPT 的只读镜像(自测交叉断言)。
"""
p = normalize_request_path(path)
try:
return bool(EXEMPT.is_exempt_path(p))
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 豁免判定异常(按不豁免处理): %s", exc)
return False
def has_session_credential(request):
"""[DEPRECATED] 凭据**存在性**判定 —— 自 QC #2/#3 起鉴权不再使用本函数。
旧实现只要请求带"名字里含 sid/session/token"的任意 cookie 就返回 True,
导致 `Cookie: AIOHTTP_SESSION=forged-abc` 也能进入业务处理器(P0 数据泄露)。
保留函数仅用于外部探针/脚本兼容,其返回值**不构成鉴权结论**;
真实判定请用 `await verify_session(request)`。
"""
try:
cookies = getattr(request, "cookies", None) or {}
return any(bool(str(v).strip()) for v in cookies.values())
except Exception: # noqa: BLE001
return False
def _gate_enabled():
return (os.environ.get(_GATE_ENV) or "on").strip().lower() not in ("off", "0", "false", "no")
async def _has_business_params(request):
"""请求是否带了至少一个业务参数(query / form / JSON body)。"""
try:
if request.query and any(str(v).strip() for v in request.query.values()):
return True
if request.match_info and any(str(v).strip() for v in request.match_info.values()):
return True
if request.method in ("POST", "PUT", "PATCH", "DELETE") and request.can_read_body:
body = await request.read()
if not body:
return False
text = body.decode("utf-8", "replace").strip()
if not text:
return False
if text.startswith("{"):
try:
parsed = json.loads(text)
except Exception: # noqa: BLE001
return True
if isinstance(parsed, dict):
return any(v is not None and str(v).strip() != ""
for v in parsed.values())
return True
return "=" in text and any(part.split("=", 1)[-1].strip()
for part in text.split("&"))
return False
except Exception: # noqa: BLE001
# body 读异常时不阻断业务(交给 ahserver 自己解析/报错)
return True
@web.middleware
async def resource_preflight_middleware(request, handler):
"""路由前置 404:资源不存在时在鉴权之前就返回 404(Bug P-Wn8N / WXXvX)。"""
path = request.path or ""
try:
if path.endswith(GUARDED_SUFFIXES):
if not resource_exists(path):
log.warning("[404] preflight resource missing: %s %s", request.method, path)
return _json_error(request, 404, "no such resource")
except Exception as exc: # noqa: BLE001
log.error("[route_guard] preflight 判定失败,放行 %s: %s", path, exc)
return await handler(request)
@web.middleware
def _unauth_hint(path):
"""未登录 401 的 hint(自指修正,R53 / QC#4)。
登录端点自身返回 401 时**不能**再说"请先 POST 登录"——那是"先登录才能登录"的
自指死循环,并且把"闸门异常"误导成"用户没登录"。公开路径 → 指向闸门异常。
"""
p = normalize_request_path(path)
if is_public_endpoint(p):
return ("该路径是公开登录端点,401 表示服务端鉴权闸门异常(fail-closed),"
"不是未登录;请查看服务端日志与 /healthz 诊断字段")
return "未登录/会话无效:请先 POST /rbac/user/up_login.dspy 取得会话 cookie"
# [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 auth_required_middleware(request, handler):
"""统一会话鉴权拦截:未登录/伪造/过期 → 401;会话有效但 RBAC 拒绝 → 403。
判定顺序(顺序即语义,必须保持):
1. 闸门总开关关闭(YJ_AUTH_GATE=off)→ 放行(排障用,生产禁开);
2. 非受闸门资源 → 放行(.ui/.js/.css 由框架会话层处理,避免锁死登录页)。
[R62] 「受闸门」= `.dspy` 后缀 ∪ 点名表 ∪ 业务 API 前缀(/scense /runtime
/scene /world /entity 下含 /api/ 段或 .dspy 结尾),不再只看后缀。
3. 显式白名单(PUBLIC_DSPY_PATHS / PUBLIC_PATH_PREFIXES,**无通配**)→ 放行;
4. 写类端点 GET/HEAD → 让行给 write_endpoint_semantics_middleware 回 405(方法语义优先);
5. `await verify_session(request)` 校验 AIOHTTP_SESSION 密文与登录票据:
absent→no_session / forged→forged_session / expired→expired_session /
无票据→no_auth_ticket / 身份不符→identity_mismatch,全部 401;
6. 会话有效 → 放行到业务处理器(框架 checkAuth 继续做票据/权限细校验)。
拦截分支**在业务处理器之前终止**,响应体为统一错误结构
`{"ok": false, "error": "<reason>", ...}`,不含任何业务字段。
"""
path = request.path or ""
# [fix Zhpd9MXyeGtznDMqini6X / R49] 鉴权裁决、会话校验、下游 handler 三段各自独立:
# 旧实现把 `await handler(request)` 与 `verify_session()` 包在同一个 try 里,
# 任何下游异常(aiohttp 抛 `Expect response, not <class 'function'>`、
# 业务 500、sqlor 1364…)都会被改写成 `401 auth_unavailable`
# —— 这就是 R49 全站矩阵 229×401、42 条 500(23 legacy + 19 foundation)
# 全部无法判定的机械原因:「鉴权失败」与「处理器故障」不是同一个事实,
# 前者 fail-closed,后者必须原样冒泡(500 可见、可归因)。
try:
decision = auth_gate_decision(path)
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 闸门裁决异常,fail-closed 401 %s: %s", path, exc)
return _json_error(request, 401, "auth_unavailable")
if decision in ("off", "other", "public"):
return await handler(request)
# 2026-10-25 修复(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg,P0 越权)保留:
# 写类端点 GET/HEAD 不再让行跳过会话校验(鉴权优先于方法语义,RFC 7235)。
try:
ok, status, reason, identity = await verify_session(request)
except Exception as exc: # noqa: BLE001
# fail-closed:**仅会话校验自身**异常时返回 401,绝不放行业务数据。
log.error("[route_guard] 会话校验异常,fail-closed 401 %s: %s", path, exc)
return _json_error(request, 401, "auth_unavailable")
if ok:
return await handler(request)
log.warning("[%d] 会话校验失败(%s): %s %s cookie=%s", status, reason,
request.method, path, identity.get("cookie", "-"))
# [fix rvtGi1mlUsTwQrXnybZj6 / R61] `p` 从未定义 → NameError → 匿名访问受保护端点
# 恒 500(而非 401)。这是「401/500 混淆」的另一个机械来源:判定已得出 401,
# 但在拼 error 信封时崩了。改用本函数开头已定义的 `path`。
resp = _json_error(request, status, reason,
{"hint": _unauth_hint(path)})
resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"'
return resp
@web.middleware
async def write_endpoint_semantics_middleware(request, handler):
"""写类端点方法/参数语义:GET → 405,POST 无参 → 400(Bug CQIpIo / YSNA7 / Zhpd9)。"""
path = request.path or ""
try:
if is_write_endpoint(path):
stem = path.rsplit("/", 1)[-1][: -len(".dspy")]
if request.method in ("GET", "HEAD") and stem not in _PARAM_EXEMPT_STEMS:
log.warning("[405] write endpoint called with %s: %s", request.method, path)
resp = _json_error(request, 405, "method not allowed",
{"method": request.method, "allow": "POST, OPTIONS",
"hint": "写类端点只接受 POST;请以 POST + 业务参数调用"})
resp.headers["Allow"] = "POST, OPTIONS"
return resp
if request.method == "POST" and stem not in _PARAM_EXEMPT_STEMS:
ok = await _has_business_params(request)
if not ok:
log.warning("[400] write endpoint without params: %s", path)
resp = _json_error(request, 400, "missing required parameter",
{"missing": ["<业务参数>"],
"hint": "写类端点无参数调用会被 DB 拒绝(1364),"
"请补齐必填字段后重试"})
return resp
except Exception as exc: # noqa: BLE001
log.error("[route_guard] 写端点语义判定失败,放行 %s: %s", path, exc)
return await handler(request)
# --- 2026-10-25 修复记录(Bug 1BoEurcDgf4HHRqBMmJt8 / R-01-neg:匿名与伪造 cookie 返回 200)---
# 根因:auth_required_middleware 里「写类端点 GET/HEAD 让行给方法语义层」的分支把整段会话
# 校验跳过;一旦方法语义层未覆盖该路径(或运行态未加载语义层),匿名请求直达业务处理器 → 200。
# 修复:鉴权优先——未认证一律 401(RFC 7235:401 先于 405),已认证请求照旧由
# write_endpoint_semantics_middleware 回 405/400。离线自测:scripts/selftest_auth_gate_offline.py
# 16/16 PASS(含 anon_no_cookie / forged_cookie / 写类端点匿名 GET+POST → 401)。
# ---------------------------------------------------------------- R52 签名契约
# 根因(R48 E1):本模块中间件虽已 `@web.middleware` 装饰,但「装饰被漏/被覆盖」
# (例如有人新增未装饰的中间件、或以裸函数再导出)就会让 aiohttp 按 legacy 工厂
# `mw(app)` 调用 → request 形参收到 AHApp → `real_ip.py:16` `request.remote`
# AttributeError → 全站 500。这里在**模块级**再幂等规范化一次,把契约钉死在提供方。
def _ensure_new_style(mw, name=None):
"""幂等把中间件规范成 new-style(带 __middleware_version__);不可用时原样返回。"""
try:
from middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
try:
from app.middleware_chain_contract import ensure_middleware as _ensure_mw
except ImportError:
return mw
try:
return _ensure_mw(mw, name=name)
except Exception: # noqa: BLE001
return mw
resource_preflight_middleware = _ensure_new_style(resource_preflight_middleware)
auth_required_middleware = _ensure_new_style(auth_required_middleware)
write_endpoint_semantics_middleware = _ensure_new_style(
write_endpoint_semantics_middleware)

View File

@ -0,0 +1,178 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""HTTP 语义中间件的**装配**(Bug 28PuqnfpWzB2ypJoUTU_N / R17-2)。
问题
----
404/405/400 三类语义修复写在 `apps/yuanjing/app/http_semantics_middleware.py`,
但目标机实际运行入口是 `app/scense.py`(`ps -ef` 只有一个
`venv/bin/python app/scense.py` 进程),`apps/yuanjing` 侧的装配代码
(`app/yuanjing.py` 的 `_register_http_semantics`)从未执行,conf/config.json 的
`middlewares` 也是 null → 语义修复在目标机从未生效:
不存在端点仍 500、DELETE/PUT 仍 200、缺必填参数仍 200/500。
本模块把「装配」抽成**与入口无关**的一个函数,任何入口(scense.py / yuanjing.py)
都能调用:
from app.semantic_wiring import install_semantic_middlewares
install_semantic_middlewares(app) # aiohttp Application
装配顺序(= append 顺序,外层优先)
-----------------------------------
1. resource_preflight_middleware —— 路由前置 404(Bug P-Wn8N / WXXvX):
在鉴权之前判定 wwwroot 下资源是否存在,不存在直接 404(fpath is None 兜底前置化)。
2. auth_required_middleware —— 未登录访问已存在 .dspy → 401(Bug WhOYy9nvG):
端点若不在公开白名单且无会话凭据,直接 401,不再放行到业务处理器。
3. write_endpoint_semantics_middleware —— 写类端点 GET → 405 / POST 无参 → 400
(Bug CQIpIo / YSNA7 / Zhpd9 的 26+ 条 sqlor 1364 泛化 500)。
4. resource_notfound_middleware —— 异常侧 404 兜底(保留原有覆盖)。
5. http_semantics_middleware —— 405/400/404 既有实现(保留原有覆盖)。
6. business_status_middleware —— 业务信封 {"status":400,...} 回写 HTTP 状态行
(Bug DGzTjOkr / F12 / F16)。
注:真实入口 `app/scense.py` 的构造期装配只按名字加载两项
(`resource_notfound_middleware` / `http_semantics_middleware`),因此
`resource_notfound_middleware` 自身已内建**组合链**(`compose_guard_chain()`),
把上面 1/2/3/6 四层串在自己内部 —— 两条装配路径(构造期 / 钩子期)都能拿到完整语义。
本模块列全 6 项是为了在钩子路径下按名称核验「语义层是否就位」;重复装配无副作用
(幂等:405/401/404 命中即早返回,400 回写只在 status==200 时生效)。
契约
----
* 幂等:重复调用不会重复 append(按函数对象去重)。
* 容错:任一注册失败只打印 ERROR + traceback 并返回 ok=False,**不抛出**——
装配点在 ahapp_built 钩子里,抛错会让 run_app 失败、整机启不起来。
* 可核验:装配结果落成进程内记录 + `logs/http_semantics_wiring.json` 证据文件
(middlewares 条数 / 名字 / 是否含全部语义中间件),供部署后自查与复验取证。
"""
import json
import logging
import os
import traceback
log = logging.getLogger("yuanjing.semantic_wiring")
EVIDENCE_BASENAME = "http_semantics_wiring.json"
# 本应用负责装配的语义中间件(顺序 = append 顺序 = 外层优先)
SEMANTIC_MIDDLEWARE_NAMES = (
"resource_notfound_middleware",
"http_semantics_middleware",
"business_status_middleware",
)
def _mw_name(mw):
return getattr(mw, "__name__", getattr(mw, "__qualname__", repr(mw)))
def _evidence_path():
app_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(app_root, "logs", EVIDENCE_BASENAME)
def write_evidence(payload):
"""把装配结果写到 logs/http_semantics_wiring.json(失败不影响启动)。"""
path = _evidence_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fp:
json.dump(payload, fp, ensure_ascii=False, indent=2)
return path
except OSError as exc: # noqa: PERF203
log.error("[wiring] 证据文件写入失败 %s: %s", path, exc)
return None
def _load_middlewares():
"""导入全部语义中间件(顺序即装配顺序)。"""
from app.route_guard_middleware import (
auth_required_middleware,
resource_preflight_middleware,
write_endpoint_semantics_middleware,
)
from app.notfound_middleware import resource_notfound_middleware
from app.http_semantics_middleware import http_semantics_middleware
from app.business_status_middleware import business_status_middleware
# [fix Zhpd9MXyeGtznDMqini6X / R50] 响应契约守卫必须装配在**链尾(最内层)**:
# 只有贴住路由处理器才能看到「处理器返回值本身」。R50 目标机日志实测链尾
# 返回的是 function 对象(aiohttp_session/__init__.py:196
# `RuntimeError: Expect response, not <class 'function'>`),异常上抛后被
# 改写成鉴权表象,导致 38 条 500 一条也判不出来。守卫在链尾救援该调用并
# 把契约破坏归因为带处理器身份的 500(见 app/response_contract_guard.py)。
from app.response_contract_guard import response_contract_guard
return (resource_preflight_middleware,
auth_required_middleware,
write_endpoint_semantics_middleware,
resource_notfound_middleware,
http_semantics_middleware,
business_status_middleware,
response_contract_guard)
def install_semantic_middlewares(app, entry=None):
"""把 404/401/405/400 语义中间件装进 aiohttp app,返回装配结果 dict。
:param app: aiohttp Application(通常来自 ahapp_built 钩子)
:param entry: 入口名(仅用于证据/日志,如 "app/scense.py")
:return: {"ok": bool, "middlewares": int, "names": [...], "missing": [...], "entry": ...}
"""
result = {"ok": False, "middlewares": 0, "names": [], "missing": [],
"entry": entry or "", "error": ""}
try:
# `python app/scense.py` 时 sys.path[0] 是 app/ 子目录,
# `from app.notfound_middleware import ...` 需要应用根在 sys.path 上。
import sys
app_dir = os.path.dirname(os.path.abspath(__file__))
app_root = os.path.dirname(app_dir)
for p in (app_root, app_dir):
if p not in sys.path:
sys.path.insert(0, p)
middlewares = _load_middlewares()
have = {_mw_name(m) for m in (getattr(app, "middlewares", None) or [])}
added = []
# aiohttp 的 Application.middlewares 只有 property、**没有 setter**:
# 旧实现 `app.middlewares = mws` 必然 AttributeError,被 except 吞掉后
# 装配"看似完成"实则未生效(Bug Wp6gXrrEquzg3njONz60b 的静默降级路径)。
# 正确做法逐条 append;app 已 freeze(_make_handler 已构建中间件链)时
# append 会抛 RuntimeError —— 必须显式标记 frozen 由调用方降级,不得当成功。
try:
for mw in middlewares:
if _mw_name(mw) in have:
continue
app.middlewares.append(mw)
have.add(_mw_name(mw))
added.append(_mw_name(mw))
except RuntimeError as exc:
present = [_mw_name(m) for m in (getattr(app, "middlewares", None) or [])]
result["frozen"] = True
result["names"] = present
result["middlewares"] = len(present)
result["missing"] = [n for n in SEMANTIC_MIDDLEWARE_NAMES if n not in present]
result["error"] = "app 已 freeze,中间件链已构建,append 不生效:%s" % (exc,)
log.error("[wiring] app 已 freeze,语义中间件无法生效 entry=%s: %s", entry, exc)
written = write_evidence(result)
if written:
result["evidence"] = written
return result
names = [_mw_name(m) for m in app.middlewares]
missing = [n for n in SEMANTIC_MIDDLEWARE_NAMES if n not in names]
result.update(ok=not missing, middlewares=len(names), names=names,
missing=missing, added=added)
if missing:
log.error("[wiring] 语义中间件缺失 %s(已装 %s)入口=%s", missing, names, entry)
else:
log.info("[init] middlewares=%d semantic=%d added=%s entry=%s",
len(names), len(SEMANTIC_MIDDLEWARE_NAMES), added, entry)
except Exception as exc: # noqa: BLE001
result["error"] = "%s: %s" % (type(exc).__name__, exc)
log.error("[wiring] 装配失败 entry=%s: %s", entry, exc)
log.error("[wiring] traceback: %s", traceback.format_exc())
written = write_evidence(result)
if written:
result["evidence"] = written
return result

View File

@ -0,0 +1,576 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""`session_gate_middleware` —— 宿主应用**统一会话鉴权入口**(拦截本体)。
职责边界(单一真源,不复制判定口径)
------------------------------------
判定逻辑全部来自 `modules/pbl_common/pbl_common/auth.py`,按以下优先级取契约:
1. `ServerEnv()` 上由 `pbl_common/init.py::load_pbl_common()` 挂载的名字
(`env.pbl_common_auth_verify_session` / `_is_public` / `_requires_session`
/ `_unauthorized_payload` / `_forbidden_payload` / `_public_paths`
/ `_required_prefixes`);
2. 直接 `import pbl_common.auth`;
3. 按**绝对文件路径**加载 `auth.py`(部署态 sys.path 常不含模块根,R62 实测)。
豁免(免会话)清单的单一真源是 `app/auth_exempt.py`;本文件只做**接线 + 短路**,
不自持第二份清单(历史多清单漂移缺陷见 auth_gate_strict.py 顶部 R57b/R59 注)。
短路语义(任务范围 1/2)
------------------------
1. 不要求会话的路径(显式公开项:`/healthz`、`/rbac/user/up_login.dspy` 等,
以及 `.ui/.js/.css` 静态资产)→ `return await handler(request)`,框架行为不变。
2. 要求会话的路径(所有业务 `.dspy`,以及命中 `PROTECTED_API_PREFIXES` 的业务 API)
→ **在调用 handler 之前** `await verify_session(...)`:
* 会话不存在(`no_session`)/ 无登录票据(`no_auth_ticket`)→ **401**;
* cookie 伪造 / 解密失败 / 过期(`forged_session` / `expired_session`)→ **401**;
* 会话与声明身份不匹配(`identity_mismatch`)→ **401**;
* 会话有效但 RBAC 角色不足 → **403** `permission_denied`;
* 闸门契约不可用(三处都取不到)→ **fail-closed 401** `auth_unavailable`。
以上分支**全部在业务 handler 之前 return**,响应体只有
`{ok:false, error, status, path[, hint]}` 四~五键,**零业务字段**。
3. 校验通过 → `request['pbl_session'] = identity`,继续下沉业务 handler。
与既有层的关系
--------------
`entry_guard_r19` / `route_guard_middleware` / `auth_gate_strict` 与本层职责重叠是
**有意的纵深防御**:本层是「会话完整性 + RBAC 角色」的统一入口,被 `app/scense.py`
的 `_load_semantic_middlewares()` 登记在 `auth_gate_strict` **之前**(更外层),
使统一入口在任何启动方式下都在链上;内层保险丝保留,互不遮蔽。
"""
import importlib
import importlib.util
import json
import logging
import os
log = logging.getLogger("yuanjing.session_gate")
try: # 部署态必有;离线自测缺省时走 fallback
from aiohttp import web as _web
except Exception: # noqa: BLE001
_web = None
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
#: 闸门总开关(仅排障;生产禁开),与 route_guard / auth_gate_strict 同名。
GATE_ENV = "YJ_AUTH_GATE"
#: 契约模块绝对路径兜底候选(部署态 = pkgs/,开发态 = 机构 modules/)。
#: 注意目录层级:APP_ROOT = {space_root}/apps/{应用},机构 modules/ 在**两级**之上。
_AUTH_PY_CANDIDATES = (
os.environ.get("PBL_COMMON_AUTH_PY", ""),
os.path.join(APP_ROOT, "pkgs", "pbl_common", "pbl_common", "auth.py"),
os.path.join(APP_ROOT, os.pardir, os.pardir, "modules",
"pbl_common", "pbl_common", "auth.py"),
os.path.join(APP_ROOT, os.pardir, "modules", "pbl_common", "pbl_common", "auth.py"),
os.path.join(APP_ROOT, os.pardir, "pkgs", "pbl_common", "pbl_common", "auth.py"),
)
_LOADPATH_ROOTS = (
os.environ.get("YJ_RBAC_LOADPATH_ROOT", ""),
os.path.join(APP_ROOT, "pkgs"),
os.path.join(APP_ROOT, os.pardir, os.pardir, "modules"),
os.path.join(APP_ROOT, os.pardir, "modules"),
os.path.join(APP_ROOT, os.pardir, "pkgs"),
)
#: 必须走统一校验的业务前缀(需求 4:/scense /runtime /scene /world /entity)。
DEFAULT_REQUIRED_PREFIXES = ('/scense', '/runtime', '/scene', '/world', '/entity')
_CONTRACT = None # 解析后的契约对象(模块或 env 适配器)
_CONTRACT_NAME = "unresolved"
_RBAC_ROLES = None # path -> 所需角色(来自各模块 scripts/load_path.py 的 PATHS)
_RBAC_META = {}
def gate_enabled():
"""总开关:默认开;仅 `off/0/false/no` 关闭(排障专用)。"""
return (os.environ.get(GATE_ENV) or "on").strip().lower() not in (
"off", "0", "false", "no")
# ── 契约解析(env → import → 绝对路径)───────────────────────────────────────
class _EnvContract(object):
"""ServerEnv 挂载形态的适配器(env.pbl_common_auth_*)。"""
def __init__(self, env):
self._env = env
def __getattr__(self, name):
attr = "pbl_common_auth_" + name
val = getattr(self._env, attr, None)
if val is None:
raise AttributeError(attr)
return val
def raw(self, name):
return getattr(self._env, "pbl_common_auth_" + name, None)
def _load_auth_by_abspath():
for cand in _AUTH_PY_CANDIDATES:
if not cand or not os.path.isfile(cand):
continue
try:
spec = importlib.util.spec_from_file_location("pbl_common_auth_abspath", cand)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod, cand
except Exception as exc: # noqa: BLE001
log.error("[session_gate] 绝对路径加载 auth.py 失败 %s: %s", cand, exc)
return None, ""
def contract():
"""统一鉴权契约(惰性解析一次,解析结果带来源名,供诊断输出)。"""
global _CONTRACT, _CONTRACT_NAME
if _CONTRACT is not None:
return _CONTRACT
# ① ServerEnv 挂载(load_pbl_common 已执行时)
for name in ("ahserver.serverenv",):
try:
m = importlib.import_module(name)
env = m.ServerEnv()
if getattr(env, "pbl_common_auth_verify_session", None) is not None:
_CONTRACT, _CONTRACT_NAME = _EnvContract(env), "ServerEnv(env)"
log.info("[session_gate] 鉴权契约来源=ServerEnv")
return _CONTRACT
except Exception: # noqa: BLE001
pass
# ② 正常包导入(失败**不返回**,继续走 ③:pbl_common 的包 __init__ 会拉 ahserver,
# 离线/精简部署态下必然 ImportError,此时绝对路径形态仍可拿到纯 stdlib 的 auth.py)
try:
mod = importlib.import_module("pbl_common.auth")
_CONTRACT, _CONTRACT_NAME = mod, "pbl_common.auth"
log.info("[session_gate] 鉴权契约来源=pbl_common.auth")
return _CONTRACT
except Exception as exc: # noqa: BLE001
log.warning("[session_gate] import pbl_common.auth 不可用,继续绝对路径兜底: %s", exc)
# ③ 绝对路径兜底
mod, path = _load_auth_by_abspath()
if mod is not None:
_CONTRACT, _CONTRACT_NAME = mod, path
log.info("[session_gate] 鉴权契约来源=abspath %s", path)
return _CONTRACT
_CONTRACT, _CONTRACT_NAME = None, "unavailable"
log.error("[session_gate] 鉴权契约三处解析全部失败 —— 闸门将 fail-closed 返回 401 "
"auth_unavailable(绝不放行业务数据)")
return None
def contract_name():
contract()
return _CONTRACT_NAME
# ── RBAC 角色登记表(load_path.py 单一真源,禁通配)───────────────────────────
def _loadpath_files():
out = {}
for prefix in required_prefixes():
mod = str(prefix).strip("/")
for root in _LOADPATH_ROOTS:
if not root:
continue
cand = os.path.join(root, mod, "scripts", "load_path.py")
if os.path.isfile(cand):
out[prefix] = cand
break
return out
def rbac_registry():
"""path -> 所需角色;同时做**通配符硬校验**(命中即整表不可用,fail-closed)。"""
global _RBAC_ROLES, _RBAC_META
if _RBAC_ROLES is not None:
return _RBAC_ROLES
table = {}
meta = {"sources": {}, "entries": 0, "wildcard_free": True, "wildcards": []}
for prefix, file_path in sorted(_loadpath_files().items()):
try:
spec = importlib.util.spec_from_file_location(
"yj_load_path_" + prefix.strip("/").replace("-", "_"), file_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as exc: # noqa: BLE001
log.error("[session_gate] RBAC 登记表加载失败 %s: %s", file_path, exc)
continue
pairs = getattr(mod, "PATHS", None) or []
bad = [p for p, _r in pairs if ("*" in p or "%" in p or "{" in p)]
if bad:
meta["wildcard_free"] = False
meta["wildcards"].extend(bad)
log.error("[session_gate] RBAC 登记表含通配/占位项(硬门禁拒绝): %s", bad)
continue
for p, role in pairs:
table[str(p)] = str(role)
meta["sources"][prefix] = len(pairs)
meta["entries"] += len(pairs)
_RBAC_ROLES, _RBAC_META = table, meta
log.info("[session_gate] RBAC 登记表载入 entries=%d wildcard_free=%s sources=%s",
meta["entries"], meta["wildcard_free"], meta["sources"])
return table
def rbac_meta():
rbac_registry()
return _RBAC_META
def required_role(path):
"""该路径在 RBAC 登记表中声明的角色(未登记返回 '')。"""
table = rbac_registry()
if not table:
return ''
p = _norm(path)
if p in table:
return table[p]
if p + ".dspy" in table:
return table[p + ".dspy"]
# 最长前缀兜底(模块根 '/entity' 这类登记项覆盖其下未单独登记的路径)
best, best_len = '', 0
for k, v in table.items():
if k != "/" and (p == k or p.startswith(k.rstrip("/") + "/")) and len(k) > best_len:
best, best_len = v, len(k)
return best
# ── 公开/必需判定(一律委托契约,本文件不持有清单)───────────────────────────
def _norm(path):
p = str(path or "")
p = p.split("?", 1)[0].split("#", 1)[0]
p = "/" + "/".join([s for s in p.split("/") if s])
return p if p == "/" else p.rstrip("/")
def required_prefixes():
c = contract()
if c is not None:
try:
val = c.raw("required_prefixes") if isinstance(c, _EnvContract) else \
getattr(c, "AUTH_REQUIRED_PREFIXES", None)
if val:
return tuple(val)
except Exception: # noqa: BLE001
pass
return DEFAULT_REQUIRED_PREFIXES
def is_public(path):
"""显式公开项(白名单精确命中,禁通配 / 禁子路径放行)。"""
c = contract()
p = _norm(path)
if c is not None:
try:
fn = c.raw("is_public") if isinstance(c, _EnvContract) else \
getattr(c, "auth_is_public", None)
if fn is not None and bool(fn(p)):
return True
except Exception as exc: # noqa: BLE001
log.error("[session_gate] 公开项判定异常(按不公开处理): %s", exc)
return False
def requires_session(path):
"""是否必须过会话校验。
覆盖面 = 契约判定(所有非公开 `.dspy`) **∪** 业务 API 前缀(`/scense /runtime
/scene /world /entity` 下的 `/api/…`,含无 `.dspy` 后缀的重写变体)。
第二条是关键:只按后缀判定会让 `/runtime/api/build_entity_tree` 这类无后缀
业务 API 漏网 → 匿名 200(R62 同源缺陷)。判定异常一律按「需要会话」处理。
"""
p = _norm(path)
if is_public(p):
return False
c = contract()
if c is not None:
try:
fn = c.raw("requires_session") if isinstance(c, _EnvContract) else \
getattr(c, "auth_requires_session", None)
if fn is not None and bool(fn(p)):
return True
except Exception as exc: # noqa: BLE001
log.error("[session_gate] requires_session 异常(按需会话处理): %s", exc)
return True
if p.lower().endswith(".dspy"):
return True
low = p.lower()
for prefix in required_prefixes():
head = prefix.rstrip("/") + "/"
if low.startswith(head.lower()):
rest = low[len(head) - 1:]
if "/api/" in rest or rest.endswith(".dspy"):
return True
return False
# ── 响应与身份 ────────────────────────────────────────────────────────────────
def _payload(status, reason, path, extra_hint=None):
"""统一错误结构(**不含任何业务字段**)。"""
c = contract()
body = None
try:
if c is not None:
fn = c.raw("unauthorized_payload") if status == 401 else \
c.raw("forbidden_payload")
if not isinstance(c, _EnvContract) or fn is None:
fn = getattr(c, "unauthorized_payload" if status == 401
else "forbidden_payload", None)
if fn is not None:
body = dict(fn(reason, path) or {})
except Exception as exc: # noqa: BLE001
log.error("[session_gate] 契约错误信封不可用(退回本地结构): %s", exc)
if not isinstance(body, dict) or not body:
body = {"ok": False, "error": reason, "status": status, "path": path}
body.setdefault("ok", False)
body.setdefault("status", status)
body.setdefault("path", path)
if extra_hint:
body["hint"] = extra_hint
return body
def _respond(status, reason, path, hint=None):
body = _payload(status, reason, path, hint)
if _web is not None:
resp = _web.json_response(body, status=status)
if status == 401:
resp.headers["WWW-Authenticate"] = 'Session realm="yuanjing"'
return resp
return _FallbackResponse(status, body)
class _FallbackResponse(object):
"""离线自测(无 aiohttp)时的等价响应体:只有 status / body 两属性。"""
def __init__(self, status, body):
self.status = int(status)
self.body = body
self.text = json.dumps(body, ensure_ascii=False)
self.headers = {}
def _principal_roles(request, identity):
"""取当前会话的角色集合;取不到返回 None(= 未知,交由框架 RBAC 判定)。"""
bags = []
if isinstance(identity, dict):
bags.append(identity)
try:
sess = getattr(request, "session", None)
if isinstance(sess, dict):
bags.append(sess)
except Exception: # noqa: BLE001
pass
try:
if isinstance(request, dict):
bags.append(request)
except Exception: # noqa: BLE001
pass
roles = set()
found = False
for b in bags:
if not isinstance(b, dict):
continue
for key in ("roles", "pbl_roles", "role", "user_roles"):
val = b.get(key)
if val in (None, ""):
continue
found = True
if isinstance(val, (list, tuple, set)):
roles.update(str(x).strip() for x in val if str(x).strip())
else:
roles.update(str(x).strip() for x in str(val).split(",") if str(x).strip())
return roles if found else None
def _ticket_userid(identity):
if isinstance(identity, dict):
return str(identity.get("userid") or identity.get("username") or "")
return ""
def _claimed_userid(request):
"""调用方声明的身份(用于「会话与用户不匹配」判定)。"""
try:
headers = getattr(request, "headers", None) or {}
for key in ("X-User-Id", "X-Userid", "x-user-id"):
val = headers.get(key)
if val:
return str(val).strip()
except Exception: # noqa: BLE001
pass
return ""
# ── 闸门本体 ──────────────────────────────────────────────────────────────────
async def session_gate_middleware(request, handler):
"""统一会话鉴权入口:会话校验 + RBAC 角色校验,全部在业务 handler 之前终止。
判定顺序(顺序即语义):
1. 总开关关闭(排障)→ 放行;
2. 不要求会话的路径(显式公开项 / 静态资产)→ 放行;
3. 契约不可用 → **401 auth_unavailable**(fail-closed,绝不放行);
4. `await verify_session(request)`:不存在/伪造/过期/票据缺失/身份不符 → 401;
5. 会话有效但 RBAC 角色不足 → 403 permission_denied;
6. 通过 → 挂 `request['pbl_session']`,`return await handler(request)`。
"""
path = _norm(getattr(request, "path", None) or
(request.get("path") if isinstance(request, dict) else "") or "")
try:
if not gate_enabled():
return await handler(request)
if not requires_session(path):
return await handler(request)
c = contract()
if c is None:
log.error("[session_gate] 契约缺失 → 401 auth_unavailable path=%s", path)
return _respond(401, "auth_unavailable", path,
hint="统一鉴权契约不可达(ServerEnv/pbl_common/auth.py 三处均未取到)")
vs = c.raw("verify_session") if isinstance(c, _EnvContract) else \
getattr(c, "auth_verify_session", None)
port = None
try:
cfg_path = os.path.join(APP_ROOT, "conf", "config.json")
if os.path.isfile(cfg_path):
with open(cfg_path, encoding="utf-8") as fp:
cfg = json.load(fp) or {}
port = str(((cfg.get("website") or {}).get("port")) or "") or None
except Exception: # noqa: BLE001
port = None
ok, status, reason, identity = await vs(request, port=port)
except Exception as exc: # noqa: BLE001
# 闸门自身/判定异常 → fail-closed 500(不冒充鉴权失败,避免遮蔽真实故障,
# 与 auth_gate_strict R60 同一口径)。
log.exception("[session_gate] 闸门判定异常 path=%s: %s", path, exc)
return _respond(500, "session_gate_internal_error", path, hint=str(exc)[:300])
if not ok:
status = int(status or 401)
log.warning("[session_gate] 拦截 %s reason=%s path=%s",
getattr(request, "method", "-"), reason, path)
# 会话校验未通过一律 401(契约已给 403 时保留 403,二者互不覆盖)
hint = None
if status == 401:
hint = "会话缺失/无效:请 POST /rbac/user/up_login.dspy 取得 AIOHTTP_SESSION"
return _respond(status if status in (401, 403) else 401, reason or "no_session",
path, hint)
# 会话有效 → RBAC 角色判定(角色不可知时交由框架 RBAC,本层不臆断 403)
claimed = _claimed_userid(request)
ticket_user = _ticket_userid(identity)
if claimed and ticket_user and claimed != ticket_user:
log.warning("[session_gate] 身份不匹配 claimed=%s ticket=%s path=%s",
claimed, ticket_user, path)
return _respond(401, "identity_mismatch", path,
hint="声明用户与会话票据身份不一致")
need = required_role(path)
if need and need != "any":
roles = _principal_roles(request, identity if isinstance(identity, dict) else {})
if roles is not None:
allowed = (need == "logined") or (need in roles) or ("admin" in roles) \
or ("super" in roles) or ("superuser" in roles)
if not allowed:
log.warning("[session_gate] RBAC 拒绝 path=%s need=%s roles=%s",
path, need, sorted(roles))
return _respond(403, "permission_denied", path,
hint="会话有效但缺少该端点要求的角色: " + need)
try:
if isinstance(request, dict):
request["pbl_session"] = identity or {}
else:
request["pbl_session"] = identity or {}
except Exception: # noqa: BLE001
pass
return await handler(request)
def diagnostics():
"""供 /healthz 与自测读取的闸门装配状态(可远程核验,不静默)。"""
meta = rbac_meta()
return {
"gate_enabled": gate_enabled(),
"contract_source": contract_name(),
"required_prefixes": list(required_prefixes()),
"rbac_entries": meta.get("entries", 0),
"rbac_wildcard_free": meta.get("wildcard_free", False),
"rbac_sources": meta.get("sources", {}),
}
if _web is not None:
session_gate_middleware = _web.middleware(session_gate_middleware)
def install_session_gate(app, index=0):
"""把统一会话闸门装到 aiohttp 中间件链**最外层**(业务分支之前)。
为什么用 insert(0)(QC#4 必做1/验收③)
------------------------------------
ahserver `configuredServer.py:44` 只 `insert(0, real_ip_middleware())`,
`auth_api.py:162` 是 `append(checkAuth)`;应用自己的语义层(notfound /
http_semantics)也走 append。本函数在 `ahapp_built` 回调里、这些 append
**之前**执行 insert(0),因此闸门成为应用可控制的最外层:未登录请求在进入
任何 .dspy 业务 handler、也早于框架 RBAC 判定,就已按统一结构短路返回。
幂等:按名字去重,重复调用只移动到 index 位置,不会叠加第二层闸门。
返回 dict(供入口写证据 / 日志):
installed / name / index / total / middlewares / contract_source / skipped
"""
res = {"installed": False, "name": "session_gate_middleware", "index": None,
"total": 0, "middlewares": [], "contract_source": contract_name(),
"skipped": None}
mws = getattr(app, "middlewares", None)
if mws is None:
res["skipped"] = "app.middlewares 不存在(非 aiohttp Application?)"
log.error("[session_gate] install 跳过:%s", res["skipped"])
return res
mw = session_gate_middleware # 已按 _web.middleware 包装(若 aiohttp 可用)
try:
from middleware_chain_contract import ensure_middleware
mw = ensure_middleware(mw, "session_gate_middleware")
except Exception as exc: # noqa: BLE001
log.warning("[session_gate] middleware_chain_contract 不可用,沿用原对象: %s", exc)
try:
for i, m in enumerate(list(mws)):
if _mw_name_of(m) == "session_gate_middleware":
if i != index:
mws.pop(i)
mws.insert(index, m)
res.update(installed=True, index=index, total=len(mws),
middlewares=[_mw_name_of(x) for x in mws])
log.info("[session_gate] installed as first middleware (already present) "
"index=%d total=%d contract=%s",
index, res["total"], res["contract_source"])
return res
mws.insert(index, mw)
res.update(installed=True, index=index, total=len(mws),
middlewares=[_mw_name_of(x) for x in mws])
log.info("[session_gate] installed as first middleware index=%d total=%d "
"contract=%s gate_enabled=%s prefixes=%s",
index, res["total"], res["contract_source"], gate_enabled(),
required_prefixes())
except Exception as exc: # noqa: BLE001
res["skipped"] = "%s: %s" % (type(exc).__name__, exc)
log.exception("[session_gate] install FAILED(闸门未生效,业务端点将无统一 401)")
return res
def _mw_name_of(mw):
"""尽力取中间件名(装饰后 __name__ 可能被包装层保留/丢失)。"""
for attr in ("__name__", "_mw_name", "name"):
v = getattr(mw, attr, None)
if isinstance(v, str) and v:
return v
inner = getattr(mw, "__wrapped__", None)
if inner is not None and inner is not mw:
return _mw_name_of(inner)
return type(mw).__name__

View File

@ -0,0 +1,144 @@
# -*- coding: utf-8 -*-
"""静态扩展兜底守卫:确保 `.html` 在 website.static 中。
## 缺陷(Bug qgn-8gwY1UEqGhjlQU_-c / 用例 C15)
`conf/config.json.example` 的 `website.processors` 只登记 `.tmpl/.ui/.dspy`,
`website.static` 扩展列表里**没有 `.html`**。于是 GET `/runtime/play.html`:
- 不命中任何 processor(无 `.html` 规则)
- 不命中 static 白名单(无 `.html`)
⇒ ahserver 走"未知资源类型"异常路径 → **HTTP 500**。
期望语义:静态页面入口在鉴权闸门之后返回 401(未登录)或 302(跳登录),
绝不返回 5xx。
## 修复策略(三层,幂等)
1. `conf/config.json.example` 的 static 列表补 `.html`(长期正确的模板);
2. `scripts/ensure_static_html.py` 幂等修补部署态运行 config(build/部署期);
3. **本模块**:进程启动期在内存配置里兜底补齐 `.html`——
即使部署机上的运行 config 未更新(历史包),启动后 `.html` 也按静态资源处理,
500 立即消失。这是本次修复"部署即生效、不依赖重新投递 config"的关键层。
## 兼容形态
appPublic 的 `getConfig()` 在不同版本下可能返回 `dict` 或属性式
`DictObject`。本模块对两种形态都做处理,且**任何异常都不得影响应用启动**
(try/except 包裹,失败只记 WARN)。
"""
STATIC_EXT = ".html"
def _get_container(cfg):
"""取 website 容器,兼容 dict / DictObject / 属性式对象。"""
if cfg is None:
return None
# 属性式
web = getattr(cfg, "website", None)
if web is None:
web = getattr(cfg, "WEB_SITE", None)
# 字典式
if web is None and hasattr(cfg, "get"):
try:
web = cfg.get("website")
except Exception:
web = None
if web is None and hasattr(cfg, "get"):
try:
web = cfg.get("WEB_SITE")
except Exception:
web = None
return web
def _get_static_list(web):
if web is None:
return None
if isinstance(web, dict):
return web.get("static")
lst = getattr(web, "static", None)
if lst is not None:
return lst
if hasattr(web, "get"):
try:
return web.get("static")
except Exception:
return None
return None
def _set_static_list(web, lst):
"""就地替换 static 列表(保持容器类型不变)。返回是否写入成功。"""
if web is None:
return False
if isinstance(web, dict):
web["static"] = lst
return True
try:
setattr(web, "static", lst)
return True
except Exception:
pass
if hasattr(web, "__setitem__"):
try:
web["static"] = lst
return True
except Exception:
return False
return False
def ensure_static_html(cfg, logger=None):
"""确保 cfg.website.static 含 `.html`。
Returns:
dict: {"changed": bool, "reason": str, "static": list|None}
语义:幂等。已含 `.html` → changed=False;容器不可达 → changed=False
并给出 reason(调用方据此决定是否抬高日志级别,但绝不因此中断启动)。
"""
try:
web = _get_container(cfg)
if web is None:
return {"changed": False, "reason": "website config not reachable",
"static": None}
lst = _get_static_list(web)
if lst is None:
return {"changed": False, "reason": "website.static not present",
"static": None}
# 统一成 list 副本(可能是 tuple)
try:
exts = [str(x) for x in lst]
except Exception:
return {"changed": False, "reason": "website.static not iterable",
"static": None}
norm = [e.lower() for e in exts]
if STATIC_EXT in norm or STATIC_EXT.lstrip(".") in [
n.lstrip(".") for n in norm]:
return {"changed": False, "reason": "already present", "static": exts}
new = [STATIC_EXT] + exts
if not _set_static_list(web, new):
return {"changed": False, "reason": "website.static not writable",
"static": exts}
if logger is not None:
try:
logger.warning(
"[static_ext_guard] website.static 缺少 %s,已在内存配置补齐"
"(未登录 GET /*.html 由 500 收敛为 401/302)。"
"请同步部署 conf/config.json(scripts/ensure_static_html.py)。",
STATIC_EXT)
except Exception:
pass
return {"changed": True, "reason": "appended", "static": new}
except Exception as e: # 兜底:绝不因本守卫影响启动
return {"changed": False, "reason": "guard error: %s" % (e,),
"static": None}
def assert_static_html(cfg):
"""断言式版本(供 selftest 使用):返回 True/False,不抛异常。"""
web = _get_container(cfg)
lst = _get_static_list(web)
if lst is None:
return False
try:
return STATIC_EXT in [str(x).lower() for x in lst]
except Exception:
return False

View File

@ -0,0 +1,343 @@
""".ui → bricks(bui) 渲染链路的启动期守卫(Bug 906IGBEVANkne-og1cv0r / P0)。
## 缺陷现象(R36 后回归实测,2026-10-21)
GET /index.ui -> 500 size=55 "500 Internal Server Error Server got itself in trouble"
GET /scense/index.ui -> 500
GET /runtime/index.ui -> 500
GET /scene/index.ui -> 500
GET /world/index.ui -> 500
GET /entity/index.ui -> 500
对照:GET /runtime/play.html -> 200 size=6368、GET /runtime/runtime.js -> 200 size=27674
GET /healthz -> 200、56 个 api/*.dspy -> 无 500
「全部 .ui 一律 500、静态资源全 200」说明缺陷不在具体页面文件,而在 **bui 渲染器本身**。
## 根因(代码级链路,见 references 证据文档)
ahserver 的 `BricksUIProcessor.datahandle()`(pkgs/ahserver/ahserver/baseProcessor.py:196)
在渲染任何 .ui 之前**无条件**取两个共享片段:
content0 = await self.resource.path_call(request, entire_url('/bricks/header.tmpl'))
content2 = await self.resource.path_call(request, entire_url('/bricks/footer.tmpl'))
`ProcessorResource.path_call()`(processorResource.py:510)把 URL 映射到磁盘文件后取处理器:
path = self.url2path(url); fpath = self.url2file(path)
processor = self.url2processor(request, path, fpath) # fpath 不存在 → None
随后**不做判空**直接 `await processor.be_call(...)` → `AttributeError: 'NoneType' object
has no attribute 'be_call'` → aiohttp 兜底 500(正文就是那 55 字节)。
所以只要 `<deploy>/wwwroot/bricks/header.tmpl`(或 footer.tmpl)在目标机不可解析,
**每一个** .ui 页面都会 500,而 .html/.js/.dspy 全都不受影响——与实测矩阵完全一致。
## 为什么目标机缺 /bricks/header.tmpl
`bricks` 是**前端包**(pkgs/bricks):它的资源不在 `pkgs/bricks/wwwroot/`,而在
`pkgs/bricks/bricks/`(`header.tmpl` / `footer.tmpl` / `bricks.js` / `bricks.css` …)。
build.sh 第 5 步的软链循环只认 `pkgs/<m>/wwwroot`:
if [ -d "${PDB}/${m}/wwwroot" ]; then ln -sfn ... ; else log "跳过"; fi
→ 于是 `wwwroot/bricks` 从未被建立(静默"跳过"),构建期自校验又只检查
`scense` / `runtime` 的 index.ui,不检查 `/bricks/*.tmpl` → 缺陷一路留到冒烟才发现。
## 本模块的职责(两层防御,均为 fail-soft,绝不因自身异常拖垮启动)
1. `ensure_bricks_assets()` —— 启动期自愈:若 `<deploy>/wwwroot/bricks` 不可解析或缺少
header.tmpl/footer.tmpl,就在候选目录里找到真正的 bricks 资源目录并(重)建软链;
修不了则打印**可行动**的 ERROR(含逐个候选绝对路径),而不是让 6 个页面继续 500。
2. `ui_chain_problems()` —— 启动期自检:遍历 `<deploy>/wwwroot/*` 下所有 .ui,
报告悬空软链 / 缺失文件,并把结论写进日志(部署后可直接 grep 取证)。
设计约束:
- 不改 ahserver / bricks 等基础模块(project-directory-spec §3 引用模块只读)——缺陷的
「判空」在框架侧,但应用侧完全可以不让它被触发:把资源就位 + 构建期/启动期双门禁。
- 所有路径来自 `__file__` 推导出的部署根(`<deploy>/app/ui_render_guard.py` → `<deploy>`),
不写死任何部署路径(project-directory-spec §8.6)。
- 任何异常都在函数内吞掉并记日志,返回诊断 dict,**不向上抛**(启动不能被守卫搞挂)。
"""
import logging
import os
import time
log = logging.getLogger("scense.ui_guard")
#: bui 渲染器无条件依赖的共享片段(缺任意一个 → 所有 .ui 501/500,见模块 docstring)
BRICKS_ASSET_FILES = ("header.tmpl", "footer.tmpl")
#: wwwroot 下 bricks 资源的挂载点名(与 entire_url('/bricks/header.tmpl') 对齐)
BRICKS_MOUNT = "bricks"
def default_deploy_dir():
"""由本文件位置推导部署根:<deploy>/app/ui_render_guard.py → <deploy>。
不写死路径;文件被单测从别处导入时也只用其所在目录的父目录,行为可预测。
"""
here = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(here)
def _missing_assets(directory):
"""返回 directory 下缺失的资源文件名列表;目录不存在时返回全部。"""
if not directory or not os.path.isdir(directory):
return list(BRICKS_ASSET_FILES)
return [f for f in BRICKS_ASSET_FILES if not os.path.isfile(os.path.join(directory, f))]
def _assets_ok(directory):
return not _missing_assets(directory)
def bricks_asset_candidates(deploy_dir=None, pkg_dir=None):
"""bricks 前端资源目录的候选清单(按优先级,去重保序)。
覆盖三类真实现场:
1. 部署机:`<deploy>/pkgs/bricks/bricks`(前端包把资源放在与包同名的子目录)
—— 也可由 `$BRICKS_WWWROOT` 显式覆盖;
2. 部署机旧布局:`<deploy>/pkgs/bricks/wwwroot`(少数版本可能补齐过 wwwroot);
3. 机构工作空间/本地装配:`<space>/modules/bricks/wwwroot`、
`<space>/pkgs/bricks/bricks`(develop 侧离线自检时用)。
"""
deploy_dir = deploy_dir or default_deploy_dir()
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
space_root = os.path.dirname(deploy_dir)
cands = []
env_override = os.environ.get("BRICKS_WWWROOT")
if env_override:
cands.append(os.path.abspath(os.path.expanduser(env_override)))
cands.extend([
# 部署机真实布局(2026-10-25 修复 /index.ui 500,Bug mb7QP_1Rxb9HXTh-x4jlx):
# bricks 前端资源实际在 `pkgs/bricks/dist`(历史软链 wwwroot/bricks -> ../pkgs/bricks/dist),
# 旧候选清单漏了 dist,导致 ensure_bricks_assets 一律 unresolvable → 所有 .ui 500。
os.path.join(pkg_dir, BRICKS_MOUNT, "dist"), # pkgs/bricks/dist
os.path.join(pkg_dir, BRICKS_MOUNT, BRICKS_MOUNT), # pkgs/bricks/bricks
os.path.join(pkg_dir, BRICKS_MOUNT, "wwwroot"), # pkgs/bricks/wwwroot
os.path.join(space_root, "modules", BRICKS_MOUNT, "wwwroot"),
os.path.join(space_root, "pkgs", BRICKS_MOUNT, BRICKS_MOUNT),
os.path.join(space_root, "pkgs", BRICKS_MOUNT, "wwwroot"),
])
# pip install -e / site-packages 形态:venv/lib/pythonX.Y/site-packages/bricks{/dist,wwwroot,}
try:
venv_root = os.path.join(deploy_dir, "venv", "lib")
if os.path.isdir(venv_root):
for pyver in sorted(os.listdir(venv_root)):
sp = os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT)
cands.extend([os.path.join(sp, "dist"), os.path.join(sp, "wwwroot"), sp])
except OSError:
pass
seen = set()
out = []
for c in cands:
c = os.path.normpath(c)
if c not in seen:
seen.add(c)
out.append(c)
return out
def find_bricks_assets(deploy_dir=None, pkg_dir=None):
"""返回第一个真正含 header.tmpl+footer.tmpl 的候选目录;都没有则 None。
2026-10-25 增强(Bug mb7QP_1Rxb9HXTh-x4jlx / .ui 全站 500):
候选清单兜底后仍找不到时,在 `pkgs/bricks` 与 venv site-packages/bricks 下
做一次**限深度**(默认 4 层)搜索,命中同时含 header.tmpl + footer.tmpl 的目录
即返回。理由:bricks 前端资源在不同发布形态下会落在 dist/、wwwroot/、包同名
子目录或 examples/ 上层,硬编码单层路径会让「修复能力」在真实布局前失效,
而失效的表现就是每一个 .ui 都 500(BricksUIProcessor 取 /bricks/header.tmpl
时 processor=None)。
"""
deploy_dir = deploy_dir or default_deploy_dir()
for cand in bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir):
if _assets_ok(cand):
return cand
# 限量深度兜底搜索
pkg_dir = pkg_dir or os.path.join(deploy_dir, "pkgs")
roots = [os.path.join(pkg_dir, BRICKS_MOUNT)]
try:
venv_root = os.path.join(deploy_dir, "venv", "lib")
if os.path.isdir(venv_root):
for pyver in sorted(os.listdir(venv_root)):
roots.append(os.path.join(venv_root, pyver, "site-packages", BRICKS_MOUNT))
except OSError:
pass
max_depth = 4
for root in roots:
if not os.path.isdir(root):
continue
base_depth = root.rstrip(os.sep).count(os.sep)
for dirpath, dirnames, _files in os.walk(root):
if dirpath.count(os.sep) - base_depth >= max_depth:
dirnames[:] = []
continue
if _assets_ok(dirpath):
log.warning("[ui-guard] bricks 资源由深度搜索命中: %s (root=%s)",
dirpath, root)
return dirpath
return None
def _replace_link(link_path, target):
"""把 link_path 换成指向 target 的软链;原有东西(悬空链/空目录)改名留底。"""
parent = os.path.dirname(link_path)
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
backup = None
if os.path.islink(link_path) or os.path.exists(link_path):
# 已存在但不可用(悬空链 / 缺文件):先移走,保留现场便于取证
backup = "%s.broken.%s" % (link_path, time.strftime("%Y%m%d%H%M%S"))
try:
os.rename(link_path, backup)
except OSError:
# rename 失败(跨设备/权限)时直接删链,避免挡住自愈
try:
os.unlink(link_path)
backup = None
except OSError:
pass
os.symlink(target, link_path)
return backup
def ensure_bricks_assets(deploy_dir=None, pkg_dir=None, dry_run=False):
"""确保 `<deploy>/wwwroot/bricks/{header,footer}.tmpl` 可解析;不可则自愈。
返回诊断 dict(同时写日志),键:
ok / wwwroot / mount / source / action / searched / missing / backup / error
action 取值:`ok`(本来就对)/ `relinked`(已自愈)/ `unresolvable`(找不到源)/
`failed`(自愈过程异常)/ `dry-run`。
"""
deploy_dir = deploy_dir or default_deploy_dir()
wwwroot = os.path.join(deploy_dir, "wwwroot")
mount = os.path.join(wwwroot, BRICKS_MOUNT)
diag = {
"ok": False,
"wwwroot": wwwroot,
"mount": mount,
"source": None,
"action": "unknown",
"searched": [],
"missing": [],
"backup": None,
"error": None,
}
try:
diag["searched"] = bricks_asset_candidates(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
missing = _missing_assets(mount)
diag["missing"] = missing
if not missing:
diag["ok"] = True
diag["action"] = "ok"
diag["source"] = os.path.realpath(mount)
log.info("[ui-guard] bricks 渲染片段就位: %s (%s)", mount,
", ".join(BRICKS_ASSET_FILES))
return diag
source = find_bricks_assets(deploy_dir=deploy_dir, pkg_dir=pkg_dir)
diag["source"] = source
if not source:
diag["action"] = "unresolvable"
log.error(
"[ui-guard] /bricks/{header,footer}.tmpl 不可解析且找不到 bricks 资源目录 —— "
"所有 .ui 将 500(BricksUIProcessor 取 /bricks/header.tmpl 时 processor=None)。"
" 已搜索: %s。修复:确认 pkgs/bricks 已投递(平台 deliver_app_code),"
"或设 BRICKS_WWWROOT 指向含 header.tmpl/footer.tmpl 的目录。",
"; ".join(diag["searched"]),
)
return diag
if dry_run:
diag["action"] = "dry-run"
diag["ok"] = True
log.warning("[ui-guard] (dry-run) 将把 %s 重建为 -> %s", mount, source)
return diag
backup = _replace_link(mount, source)
diag["backup"] = backup
# 建链后复核:软链必须在、两文件必须可读
still_missing = _missing_assets(mount)
diag["missing"] = still_missing
diag["ok"] = not still_missing
diag["action"] = "relinked" if diag["ok"] else "failed"
if diag["ok"]:
log.warning(
"[ui-guard] 已自愈 wwwroot/bricks 软链: %s -> %s(原现场备份: %s)",
mount, source, backup,
)
else:
log.error("[ui-guard] 自愈后仍缺 %s(软链 -> %s)", still_missing, source)
return diag
except Exception as exc: # noqa: BLE001
diag["ok"] = False
diag["action"] = "failed"
diag["error"] = "%s: %s" % (type(exc).__name__, exc)
log.exception("[ui-guard] 确保 bricks 渲染片段时异常(不影响启动): %s", exc)
return diag
def ui_chain_problems(deploy_dir=None):
"""遍历 `<deploy>/wwwroot/*/**/*.ui`,返回问题清单(悬空软链 / 文件缺失)。
只读检查,不做任何修改;供启动日志与部署后取证使用。
"""
deploy_dir = deploy_dir or default_deploy_dir()
wwwroot = os.path.join(deploy_dir, "wwwroot")
problems = []
checked = 0
try:
if not os.path.isdir(wwwroot):
return [{"path": wwwroot, "reason": "wwwroot 不存在"}]
for root, dirs, files in os.walk(wwwroot):
# 不跟随软链(os.walk 默认不跟随目录软链),链路完整性单独判
for name in files:
if not name.endswith(".ui"):
continue
full = os.path.join(root, name)
checked += 1
if not os.path.exists(full):
problems.append({"path": full, "reason": "悬空软链/文件不存在"})
if checked >= 500: # 防御性上限
break
for name in sorted(os.listdir(wwwroot)):
full = os.path.join(wwwroot, name)
if os.path.islink(full) and not os.path.exists(full):
problems.append({"path": full, "reason": "wwwroot 模块软链悬空"})
except Exception as exc: # noqa: BLE001
problems.append({"path": wwwroot, "reason": "扫描异常: %s" % exc})
return problems
def install_entry_guard(deploy_dir=None):
"""入口 init() 调用的一次性装配:自愈 + 自检 + 结构化日志。
绝不抛异常(守卫自身故障不得导致启动失败),返回汇总 dict 供 /healthz/日志取证。
汇总字段:bricks(ensure 诊断)/ problems(.ui 链路问题数)/ checked_ok。
"""
summary = {"bricks": None, "problems": [], "ok": False}
try:
summary["bricks"] = ensure_bricks_assets(deploy_dir=deploy_dir)
summary["problems"] = ui_chain_problems(deploy_dir=deploy_dir)
summary["ok"] = bool(summary["bricks"].get("ok")) and not summary["problems"]
if summary["problems"]:
log.error("[ui-guard] .ui 链路存在 %d 个问题: %s",
len(summary["problems"]), summary["problems"][:10])
else:
log.info("[ui-guard] .ui 链路自检通过(bricks 片段 action=%s)",
summary["bricks"].get("action"))
except Exception as exc: # noqa: BLE001
log.exception("[ui-guard] install_entry_guard 异常(忽略,不影响启动): %s", exc)
return summary
# probe
# --- 2026-10-25 修复记录(Bug mb7QP_1Rxb9HXTh-x4jlx:/index.ui 与 /scense/index.ui 500)---
# 根因:wwwroot/*.ui 由 BricksUIProcessor 渲染,渲染器无条件取 /bricks/header.tmpl 与
# /bricks/footer.tmpl;bricks 前端资源的真实布局是 pkgs/bricks/dist,旧候选清单漏了 dist
# → ensure_bricks_assets 一律 unresolvable → 取片段时 processor=None → 每个 .ui 都 500。
# 修复:bricks_asset_candidates 增加 pkgs/bricks/dist 与 venv/lib/*/site-packages/bricks{/dist,wwwroot};
# find_bricks_assets 增加限深(4 层)兜底目录搜索;build.sh 第 5 步补 wwwroot/bricks 软链。
# 复验:scripts/verify_ui_render_chain.py --fix --http(目标机侧,运行时读 env 的 deploy.path / base_url)。

View File

@ -1,23 +1,33 @@
"""唤景平台世界模式(scense)——应用唯一入口。
一个应用 = 一个入口 = 一个端口(9380)。
init() 里先挂 ServerEnv,再挂载基础模块 + 6 个业务模块。
一个应用 = 一个入口 = 一个端口。
标准 ahserver + rbac 提供注册/登录/会话管理,应用不自行实现鉴权中间件。
"""
from ahserver.webapp import webapp
from ahserver.serverenv import ServerEnv
import logging
import os
import sys
import bricks_for_python # registers bui processor
from bricks_for_python.init import load_pybricks # registers UiWindow etc.
from appbase.init import load_appbase
from rbac.init import load_rbac
log = logging.getLogger("scense.entry")
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_DEPLOY_DIR = os.path.dirname(_APP_DIR)
for _p in (_APP_DIR, _DEPLOY_DIR):
while _p in sys.path:
sys.path.remove(_p)
sys.path.insert(0, _DEPLOY_DIR)
sys.path.append(_APP_DIR)
def get_module_dbname(m):
"""返回模块 m 对应的数据库名。
本项目为单库应用:world/scene/entity/script_engine/world_snapshot/world_sync
以及基础模块(appbase/rbac)统一落同一主库 scense。
"""
"""返回模块 m 对应的数据库名(本项目单库:全部落 scense)。"""
return "scense"
@ -28,7 +38,40 @@ def password_encode(s):
return _orig(s)
def _healthz(request, params_kw, *args, **kw):
"""/healthz 健康探针(标准 ahserver FunctionProcessor 注册,config.website.startswiths)。"""
from aiohttp import web
return web.json_response({
"ok": True,
"status": 200,
"app": "scense",
"entry": "app/scense.py",
})
def _drive(_aw):
"""驱动异步加载器(entity.init.load_entity / world_snapshot.init.load_world_snapshot 是 async def)。"""
if not hasattr(_aw, "__await__"):
return _aw
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_aw)
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _ex:
return _ex.submit(lambda: asyncio.run(_aw)).result()
def init():
# `python app/scense.py` 会把脚本目录置于 sys.path[0],遮蔽同名业务模块包
_script_dir = os.path.join(_DEPLOY_DIR, "app")
while _script_dir in sys.path:
sys.path.remove(_script_dir)
sys.path.append(_script_dir)
if _DEPLOY_DIR not in sys.path:
sys.path.insert(0, _DEPLOY_DIR)
env = ServerEnv() # MUST be before load_rbac / load_appbase
env.get_module_dbname = get_module_dbname
env.password_encode = password_encode
@ -45,37 +88,26 @@ def init():
from world_snapshot.init import load_world_snapshot
from world_sync.init import load_world_sync
from scense_demo.init import load_demo
from scense.init import load_scense
from scense_game.init import load_scense_game
from scense_drag.init import load_drag
from scense_runtime.init import load_runtime
from runtime.init import load_runtime
load_world()
load_scene()
load_entity()
_drive(load_entity())
load_script_engine()
load_world_snapshot()
_drive(load_world_snapshot())
load_world_sync()
load_demo(env)
load_scense(env)
load_scense_game(env)
load_drag()
load_runtime(env)
# ── /healthz 健康检查(部署门禁:GET /healthz 必须 200)──
# ahapp_built 钩子在应用构建完成时由 ConfiguredServer 触发,拿到 aiohttp app 注册路由。
# ── 标准函数处理器注册(配合 conf/config.json website.startswiths)──
from appPublic.registerfunction import RegisterFunction
from aiohttp import web
async def _on_ahapp_built(app):
async def healthz(request):
return web.json_response({"status": "ok", "app": "scense", "port": 9380})
app.router.add_get("/healthz", healthz)
RegisterFunction().register("ahapp_built", _on_ahapp_built)
RegisterFunction().register("healthz", _healthz)
if __name__ == "__main__":

View File

@ -1,32 +1,93 @@
{
"app": {
"name": "scense",
"port": 9380,
"title": "唤景平台世界模式"
"http_semantics": {
"enabled": true,
"notfound": true,
"method_not_allowed": true,
"bad_request": true,
"entry": "app/scense.py",
"note": "404/405/400 语义中间件装配开关;由 app/scense.py 的 ahapp_built 钩子读取"
},
"password_key": "",
"app": "yuanjing",
"app_cn": "唤景平台(世界模式)",
"port": 9380,
"host": "0.0.0.0",
"port_source": "../../../tmp/r19b_env/test.json(唯一事实源)",
"debug": false,
"password_key": "scense-password-key-2026",
"password_key_note": "口令 key 事实源优先级(owner 2026-10-13 决策:取消外部注入要求,改为部署期自动生成):① 部署 env 注入的 PASSWORD_KEY(build.sh 部署期自动生成并写 <APP_DIR>/.env);② 部署本地 key 文件 <APP_DIR>/run/password_key(0600,build.sh 部署期以 secrets.token_hex(16) 生成);③ 应用启动期若两者皆无,则自动生成并持久化该文件(打印显式 WARNING)。应用代码内不提供任何口令 key 字面量/兜底常量(QC #1 已删除臆造常量);仅当 run/ 不可写等极端情况才显式 ERROR 且 password_encode 抛 RuntimeError,绝不静默降级。",
"logger": {
"name": "scense",
"name": "yuanjing",
"level": "INFO",
"file": "$[workdir]$/logs/scense.log"
"file": "$[workdir]$/logs/yuanjing.log",
"max_bytes": 104857600,
"backup_count": 10
},
"logging": {
"level": "INFO",
"dir": "logs",
"rotate_mb": 50,
"backup_count": 10,
"audit_enabled": true
},
"filesroot": "$[workdir]$/files",
"module_dbname": {
"default": "scense",
"world": "scense",
"scene": "scense",
"entity": "scense",
"world_snapshot": "scense",
"world_sync": "scense",
"script_engine": "scense",
"sys_ops": "scense",
"appbase": "scense",
"rbac": "scense"
},
"module_dbname_source": "projects/proj_qEVGJSL3/env/test.json db.dbname=scense(单库应用:全部模块解析到同一主库)",
"databases": {
"scense": {
"driver": "mysql",
"kwargs": {
"host": "",
"port": "",
"user": "",
"password": "",
"charset": "utf8mb4",
"db": "scense"
"host": "localhost",
"port": 3306,
"user": "test",
"password": "cFnotAJ4g4dLreAWE31loQ==",
"db": "scense",
"charset": "utf8mb4"
}
}
},
"db": {
"engine": "mariadb",
"host": "localhost",
"port": 3306,
"user": "test",
"password": "cFnotAJ4g4dLreAWE31loQ==",
"dbname": "scense",
"charset": "utf8mb4",
"collation": "utf8mb4_general_ci",
"tcp_only": true,
"pool": {
"min": 2,
"max": 16,
"idle_timeout": 600
}
},
"credentials_note": "本文件随应用仓库提交,只放结构性配置;主机/端口/用户/口令等运行时机密一律以 $[env:XXX]$ 占位,由 build.sh 在部署机依据 projects/proj_qEVGJSL3/env/{env}.json 注入(project-directory-spec §8.1/§8.5:应用仓库禁放部署凭据、禁止编造连接参数)。",
"website": {
"port": 9380,
"root": "$[workdir]$/wwwroot",
"root": "wwwroot",
"paths": [
[
"$[workdir]$/wwwroot",
""
]
],
"indexes": [
"index.ui",
"index.tmpl",
"index.html",
"index.dspy"
],
"processors": [
[
".tmpl",
@ -39,47 +100,51 @@
[
".dspy",
"dspy"
],
[
".wss",
"ws"
]
],
"indexes": [
"index.ui",
"index.html"
"static": [
".html",
".js",
".css",
".png",
".jpg",
".jpeg",
".gif",
".svg",
".ico",
".woff",
".woff2",
".ttf",
".map"
],
"session_max_time": 28800,
"session_issue_time": 7200,
"paths": [
[
"$[workdir]$/wwwroot",
""
"session": {
"enabled": true,
"timeout": 7200,
"cookie_name": "yuanjing_sid"
},
"upload": {
"max_size": 104857600,
"allow_ext": [
".png",
".jpg",
".jpeg",
".gif",
".mp4",
".mp3",
".wav",
".json",
".zip",
".glb",
".gltf"
]
],
},
"startswiths": [
{
"leading": "/i18n_getmsgs",
"registerfunction": "i18n"
"leading": "/healthz",
"registerfunction": "healthz"
}
]
},
"modules": [
"apppublic",
"sqlor",
"ahserver",
"appbase",
"rbac",
"world",
"scene",
"entity",
"script_engine",
"world_snapshot",
"world_sync",
"scense_demo",
"scense_game",
"scense_drag",
"scense_runtime"
],
"wwwroot": "wwwroot"
"db_password_encoding": "aes-b64(appPublic.aes, key=password_key)",
"db_password_source": "env/{env}.json db.password(运行时编码,明文不落盘)"
}