根因:元景迭代中为通过 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 中间件提供。
214 lines
9.9 KiB
Python
214 lines
9.9 KiB
Python
#!/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)
|