scense/app/_archive_disabled_20261028_151505/business_status_middleware.py
ymq 702f27407b refactor(auth): 移除全部自造鉴权/会话/语义中间件,回归标准 ahserver+rbac
根因:元景迭代中为通过 QC 门禁反复叠加了 24 个自定义中间件
(auth_gate_strict / session_gate / login_param_source / route_guard /
 entry_semantics / http_semantics / notfound / get_only_405 / ...),
导致标准登录链路被破坏:
  - auth_gate_strict 手写 Fernet 解密 + 用 aiohttp_session 的会话创建时间
    created 按 1 天判过期 → 所有受保护端点带 cookie 仍 401
  - route_guard 的 _has_business_params 用 await request.read() 消费 POST body
    → up_login 收到 params_kw={} → password_encode(None) 崩 500

改动:
  - app/scense.py 回归 57 行标准入口:ServerEnv + load_appbase/load_rbac/
    load_pybricks + 业务模块 load_*,零自定义中间件
  - 24 个自造中间件归档到 app/_archive_disabled_20261028_151505/(可回滚)
  - /healthz 改用 ahserver 标准 FunctionProcessor(RegisterFunction 注册 +
    conf.website.startswiths 映射),不再用中间件拦截

实测闭环(127.0.0.1:9380 + https://gbak.opencomputing.cn):
  healthz 200 | 匿名 401 | admin 登录 200+cookie | 带cookie 200 |
  伪造cookie 401 | 注册 200+自动登录 | 新用户登录 200+cookie |
  新用户访问 403(已认证无权限,RBAC 正确行为)| 登出 200 |
  错误密码 200 业务信封(不崩 500)

register/login/logout/session 全部由 rbac 模块 up_login.dspy /
register.dspy / logout.dspy + ahserver session 中间件提供。
2026-10-28 15:19:43 +08:00

214 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""业务错误码 → HTTP 状态行 回写(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)