refactor(backend): 错误码收敛为单一事实来源,统一异常写法

错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
  映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
  降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
  的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
  路由装饰器 201 白名单显式放行并断言白名单非空

异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
  @app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
  需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
  并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
  不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
  status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)

验证:pytest 97 passed / ruff 全绿;AST 审计残留 0;护栏红绿测试通过;
线上实测验证码过期仍 400 并保留业务文案、无令牌 401。
This commit is contained in:
zhangtao
2026-10-04 05:44:21 +08:00
parent c73daa7eae
commit 644d262cad
31 changed files with 824 additions and 363 deletions
@@ -0,0 +1,260 @@
"""错误码单一事实来源(t16)回归测试。
设计约定:
- HTTP 语义只由 ``RET.code → HTTP status``(``core/exceptions.py`` 的映射表)决定;
- 业务代码只传 ``code``;意外异常不在业务代码里包装,交给全局处理器映射为 5xx;
- 除 ``core/exceptions.py``(映射表与逃生口)外,``app/`` 下不得再出现 ``status_code=`` 传参。
本文件既锁语义(400/401/403/404/500),也加静态护栏防回潮。
"""
import ast
import json
import pathlib
import re
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.common.enums import RET
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.logger import logger
_APP_DIR = pathlib.Path(__file__).resolve().parent.parent / "app"
_EXCEPTIONS_FILE = _APP_DIR / "core" / "exceptions.py"
_STATUS_KWARG = "status_code="
# 唯一允许的例外:FastAPI 路由装饰器的框架参数(如 @Router.post(..., status_code=201)),
# 与「业务错误码 → HTTP 语义」无关;去掉它会改变创建类接口的 HTTP 语义(201→200),故白名单放行。
_ROUTE_DECORATOR_LINE = re.compile(r"^\s*@\w+\.(get|post|put|patch|delete|head|options)\(")
# ── 1) 静态护栏:业务代码不得显式传 status_code ───────────────────────────────
def test_no_explicit_status_code_in_business_code() -> None:
"""app/ 下(除 core/exceptions.py)不得出现 status_code= 传参。"""
violations: list[str] = []
allowed: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _STATUS_KWARG not in line:
continue
record = f"{path.relative_to(_APP_DIR.parent)}:{lineno}: {line.strip()[:120]}"
if _ROUTE_DECORATOR_LINE.match(line):
allowed.append(record) # FastAPI 路由装饰器参数(框架语义,非业务错误码)
continue
violations.append(record)
assert violations == [], f"业务代码出现显式 status_code 传参(应改为 code=... 或 CustomException.internal):{violations}"
assert allowed, "白名单为空:若路由装饰器写法变化请同步更新本测试"
def test_no_custom_exception_passes_status_code() -> None:
"""更精确的护栏:CustomException 调用点一律不传 status_code(除 exceptions.py 自身)。"""
offenders: list[str] = []
call_re = re.compile(r"CustomException(?:\.\w+)?\(")
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
text = path.read_text(encoding="utf-8")
for match in call_re.finditer(text):
depth, index = 0, match.end() - 1
while index < len(text):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
break
index += 1
call = text[match.start() : index + 1]
if _STATUS_KWARG in call:
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{text[: match.start()].count(chr(10)) + 1}")
assert offenders == [], f"CustomException 仍显式传 status_code:{offenders}"
# ── 2) 映射表是唯一事实来源 ──────────────────────────────────────────────────
def test_mapping_table_is_explicit_for_default_business_code() -> None:
"""默认业务码 RET.EXCEPTION(-1) 显式登记为 400(不再依赖隐式兜底)。"""
assert resolve_http_status(RET.EXCEPTION.code) == 400
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
def test_mapping_covers_http_like_codes() -> None:
"""凡是「数值本身就是 HTTP 状态码」的 RET 成员,映射结果必须与自身一致。"""
for member in RET:
if 400 <= member.code <= 599:
assert resolve_http_status(member.code) == member.code, f"{member.name}({member.code}) 映射错位"
def test_no_internal_entry_and_unexpected_errors_use_global_handler() -> None:
"""t19:internal() 语义入口已删除;意外异常交给全局处理器给 5xx + 通用文案。"""
assert not hasattr(CustomException, "internal")
assert resolve_http_status(RET.SERVERERR.code) == 500 # 码仍在映射表内,供显式声明服务端故障的业务码使用
app = FastAPI()
handle_exception(app)
@app.get("/unexpected")
async def _unexpected() -> None:
raise RuntimeError("统计失败: 连接断开 connection lost")
response = TestClient(app, raise_server_exceptions=False).get("/unexpected")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "服务器内部错误"
assert "connection lost" not in json.dumps(body, ensure_ascii=False)
def test_unmapped_code_falls_back_to_client_error_net() -> None:
"""未登记 code 的安全网仍是 400(仅兜底,不应作为正常路径)。"""
assert resolve_http_status(999999) == 400
assert resolve_http_status(None) == 400
# ── 3) 语义不变(HTTP 层实测) ───────────────────────────────────────────────
def _semantic_probe_app() -> FastAPI:
app = FastAPI()
handle_exception(app)
@app.get("/captcha-expired")
async def _captcha() -> None:
raise CustomException(msg="验证码已过期,请刷新")
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
@app.get("/token-expired")
async def _token() -> None:
raise CustomException(msg="认证已失效", code=RET.TOKEN_EXPIRED.code)
@app.get("/not-found")
async def _not_found() -> None:
raise CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code)
@app.get("/internal")
async def _internal() -> None:
# 意外异常:不包装,直接冒泡给全局处理器(t19)
raise RuntimeError("统计失败: 模拟底层驱动故障")
return app
@pytest.mark.parametrize(
("path", "expected_status", "expected_code"),
[
("/captcha-expired", 400, RET.EXCEPTION.code),
("/forbidden", 403, RET.NO_PERMISSION.code),
("/token-expired", 401, RET.TOKEN_EXPIRED.code),
("/not-found", 404, RET.NOT_FOUND.code),
("/internal", 500, RET.ERROR.code),
],
)
def test_semantics_unchanged_via_mapping(path: str, expected_status: int, expected_code: int) -> None:
"""验证码/参数→400、无权限→403、令牌→401、资源不存在→404、内部故障→500,且响应体结构不变。"""
client = TestClient(_semantic_probe_app(), raise_server_exceptions=False)
response = client.get(path)
assert response.status_code == expected_status, response.text
body = response.json()
assert set(body) >= {"code", "msg", "data", "status_code", "success"}
assert body["code"] == expected_code
assert body["status_code"] == expected_status
assert body["msg"] # 诊断信息不丢
assert body["success"] is False
# ── 4) t19:业务异常透传 + 意外异常冒泡(AST 护栏) ───────────────────────────
def test_internal_faults_are_not_wrapped_locally() -> None:
"""132 处局部包装已清除:app/ 下不再出现 internal() 语义入口或 status_code 传参。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8")
if "CustomException.internal" in text or "def internal(" in text:
offenders.append(str(path.relative_to(_APP_DIR.parent)))
assert offenders == [], f"仍存在局部包装入口:{offenders}"
def test_business_error_passes_through_try_and_stays_4xx() -> None:
"""try 内抛出的业务 400 穿过「日志 + raise」的宽泛 except 后仍是 400。"""
app = FastAPI()
handle_exception(app)
async def _service() -> None:
raise CustomException(msg="请求参数错误")
@app.get("/passthrough")
async def _passthrough() -> None:
try:
await _service()
except CustomException:
raise
except Exception as exc:
logger.exception("处理请求失败")
raise exc
response = TestClient(app, raise_server_exceptions=False).get("/passthrough")
assert response.status_code == 400, response.text
body = response.json()
assert body["msg"] == "请求参数错误"
assert body["code"] == RET.EXCEPTION.code
# 宽泛 except 若「构造新的 CustomException」抛出,必须先有 `except CustomException: raise`
_BROAD = {"Exception", "BaseException"}
def _is_custom_exception_name(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
return node.id == "CustomException"
if isinstance(node, ast.Attribute):
return node.attr == "CustomException"
if isinstance(node, ast.Tuple):
return any(_is_custom_exception_name(e) for e in node.elts)
return False
def _is_broad_handler(handler: ast.ExceptHandler) -> bool:
t = handler.type
if t is None:
return True
if isinstance(t, ast.Name):
return t.id in _BROAD
if isinstance(t, ast.Tuple):
return any(isinstance(e, ast.Name) and e.id in _BROAD for e in t.elts)
return False
def test_ast_guard_broad_except_requires_custom_exception_passthrough() -> None:
"""AST 护栏:宽泛 except 若转换异常,必须前置 `except CustomException: raise`(防语义反转回潮)。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
guarded = any(
h.type is not None
and _is_custom_exception_name(h.type)
and any(isinstance(stmt, ast.Raise) and stmt.exc is None for stmt in h.body)
for h in node.handlers
)
for handler in node.handlers:
if not _is_broad_handler(handler) or guarded:
continue
for inner in ast.walk(handler):
if (
isinstance(inner, ast.Raise)
and isinstance(inner.exc, ast.Call)
and getattr(inner.exc.func, "id", None) == "CustomException"
):
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{inner.lineno}")
assert offenders == [], f"宽泛 except 转换业务异常却缺守卫(应加 except CustomException: raise):{offenders}"