#!/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)