#!/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 '{}' /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//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。"
"修复:确保 /pkgs/bricks/bricks 已投递并软链为 "
"/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()