Skip to content

Commit 2632407

Browse files
committed
refactor(webapp): 拆分 main.py 横切逻辑(535 → 293 行)
main.py 把三类互不相关的横切逻辑和业务装配挤在一起:请求体上限中间件、 12 个异常处理器、Host 白名单、SPA 静态资源、provider/引擎装配。改安全 策略和改 provider 接线是不同理由的变更(Divergent Change)。 拆成 src/webapp/,main.py 只留接线与生命周期: - limits.py 请求体上限 ASGI 中间件(登录 8KB / 其余 16MB) - security.py Host 白名单 + 安全响应头 - handlers.py 异常 → HTTP 响应(稳定错误码,TECHNICAL §6.5) - static.py 前端产物定位 + SPA catch-all main.py 保留 _host_allowed / _body_limit / _api_not_found / _frontend_dist 等历史名字的再导出,外部 import 不破。 拆分中发现的真实陷阱(已修 + 已加防线): 测试用 `monkeypatch.setattr("src.main._frontend_dist", ...)` 打补丁,把函数 移到 webapp 后 patch 仍会“成功”——只是再也影响不到 SPA 路由的调用, 9 个静态资源测试全部静默测空(表现为 200 vs 503 断言失败)。已把补丁目标 改到真实定义(src.webapp.static.*),并新增两条断言:main 的别名必须与 webapp 定义为同一对象、patch 目标必须持有函数及其依赖常量。已验证该 断言能抓住「别名包一层」这种写法。 测试:826 passed,覆盖率 100%(main 155 stmts + webapp 四个模块全部 100%)。
1 parent 90204a2 commit 2632407

10 files changed

Lines changed: 438 additions & 296 deletions

File tree

‎TECHNICAL.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,13 @@ PROPOSAL.md 定方向,本文档定实现。每个模块标注来源决策(Q
2626
coding2api/
2727
├── pyproject.toml # uv 项目;[tool.pytest.ini_options] 设 coverage 目标
2828
├── src/
29-
│ ├── main.py # FastAPI 组装、lifespan、路由挂载
29+
│ ├── main.py # FastAPI 组装、lifespan、路由挂载(只做接线)
3030
│ ├── config.py # pydantic-settings:README「配置」全部 env
31+
│ ├── webapp/ # HTTP 边缘层(横切关注点,与业务装配分开)
32+
│ │ ├── limits.py # 请求体上限 ASGI 中间件(登录 8KB / 其余 16MB)
33+
│ │ ├── security.py # Host 白名单 + 安全响应头(CSP/nosniff)
34+
│ │ ├── handlers.py # 异常 → HTTP 响应(稳定错误码,TECHNICAL §6.5)
35+
│ │ └── static.py # 前端产物定位 + SPA catch-all
3136
│ ├── db/
3237
│ │ ├── schema.sql # DDL 定稿
3338
│ │ ├── conn.py # 连接管理(线程本地 + WAL + busy_timeout)

‎src/main.py‎

Lines changed: 39 additions & 281 deletions
Large diffs are not rendered by default.

‎src/webapp/__init__.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""HTTP 边缘层:请求体上限、Host 白名单、异常处理器、前端静态资源。
2+
3+
这些模块都是「注册到 app 上」的横切关注点,与业务装配无关:
4+
- limits.py 请求体上限(纯 ASGI 中间件,登录 8KB / 其余 16MB)
5+
- security.py Host 白名单 + 安全响应头
6+
- handlers.py 异常 → HTTP 响应映射(稳定错误码,见 TECHNICAL §6.5)
7+
- static.py 前端产物定位与 SPA catch-all 路由
8+
9+
装配顺序有约束:见 src/main.py 的 build_app 注释(BodySizeLimitMiddleware
10+
必须最后 add,才会落在最外层)。
11+
"""

‎src/webapp/handlers.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""异常 → HTTP 响应映射(TECHNICAL §6.5:稳定的机器可读错误码)。
2+
3+
管理端点返回的失败原因必须是稳定枚举,不能是 Python 异常类名——
4+
类名是实现细节,用户既判断不出问题也不知道下一步做什么,重构时还会漂移。
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
11+
import httpx
12+
from fastapi import FastAPI
13+
from fastapi.responses import JSONResponse
14+
15+
from ..auth.csrf import CsrfRejectedError
16+
from ..auth.rbac import ForbiddenError, UnauthorizedError
17+
from ..auth.throttle import ThrottledError
18+
from ..compat.openai.errors import error_payload
19+
from ..compat.openai.request import InvalidRequest
20+
from ..db.crypto import CredentialDecryptError
21+
from ..engine.executor import NoHealthyCredential, NoProviderForModel
22+
from ..engine.model_resolver import UnknownModelError
23+
from ..provider.codebuddy.events import (
24+
UpstreamProtocolViolation as CodeBuddyProtocolViolation,
25+
)
26+
from ..provider.trae.events import UpstreamProtocolViolation
27+
28+
logger = logging.getLogger(__name__)
29+
30+
# 上游连接/超时失败:502(调用方可重试),与凭证健康度无关。
31+
UPSTREAM_ERROR_STATUS = 502
32+
33+
34+
def register_exception_handlers(app: FastAPI) -> None:
35+
"""把全部异常处理器注册到 app(顺序无关,FastAPI 按类型匹配)。"""
36+
37+
@app.exception_handler(UnauthorizedError)
38+
async def _unauthorized(_request, _error: UnauthorizedError):
39+
return JSONResponse(status_code=401,
40+
content=error_payload("invalid authentication credentials",
41+
"invalid_api_key", 401))
42+
43+
@app.exception_handler(InvalidRequest)
44+
async def _invalid(_request, error: InvalidRequest):
45+
return JSONResponse(status_code=400,
46+
content=error_payload(str(error), "invalid_request", 400))
47+
48+
@app.exception_handler(UnknownModelError)
49+
async def _unknown_model(_request, error: UnknownModelError):
50+
return JSONResponse(status_code=400,
51+
content=error_payload(str(error), "invalid_request", 400))
52+
53+
@app.exception_handler(UpstreamProtocolViolation)
54+
async def _bad_credential(_request, error: UpstreamProtocolViolation):
55+
return JSONResponse(status_code=400,
56+
content=error_payload(str(error), "invalid_credential", 400))
57+
58+
@app.exception_handler(CodeBuddyProtocolViolation)
59+
async def _bad_codebuddy_credential(_request, error: CodeBuddyProtocolViolation):
60+
return JSONResponse(status_code=400,
61+
content=error_payload(str(error), "invalid_credential", 400))
62+
63+
@app.exception_handler(NoHealthyCredential)
64+
async def _no_health(_request, error: NoHealthyCredential):
65+
return JSONResponse(status_code=503,
66+
content=error_payload(str(error), "no_healthy_credential", 503))
67+
68+
@app.exception_handler(NoProviderForModel)
69+
async def _no_provider(_request, error: NoProviderForModel):
70+
return JSONResponse(status_code=400,
71+
content=error_payload(str(error), "invalid_request", 400))
72+
73+
@app.exception_handler(ForbiddenError)
74+
async def _forbidden(_request, _error: ForbiddenError):
75+
return JSONResponse(status_code=403,
76+
content=error_payload("admin only", "forbidden", 403))
77+
78+
@app.exception_handler(CsrfRejectedError)
79+
async def _csrf_rejected(_request, _error: CsrfRejectedError):
80+
return JSONResponse(status_code=403,
81+
content=error_payload("cross-origin write rejected",
82+
"forbidden", 403))
83+
84+
@app.exception_handler(CredentialDecryptError)
85+
async def _decrypt_failed(_request, _error: CredentialDecryptError):
86+
"""APP_SECRET 变更或密文损坏:必须给出可行动提示,而不是 500。"""
87+
logger.error("凭证解密失败:APP_SECRET 是否被更换过?")
88+
return JSONResponse(status_code=500,
89+
content=error_payload(
90+
"credential decryption failed; APP_SECRET may have changed",
91+
"credential_decrypt_failed", 500))
92+
93+
@app.exception_handler(httpx.TransportError)
94+
async def _transport_error(_request, error: httpx.TransportError):
95+
"""上游连接/超时失败:502 而不是 500(调用方可重试)。
96+
97+
httpx 的 TimeoutException/ConnectError 在引擎里不被 _classify 认识
98+
(没有 kind()),会直接冒泡——以前表现成 500,语义错误。
99+
"""
100+
logger.warning("上游传输层失败: %s: %s", type(error).__name__, error)
101+
return JSONResponse(
102+
status_code=UPSTREAM_ERROR_STATUS,
103+
content=error_payload(f"upstream transport failed: {type(error).__name__}",
104+
"upstream_unavailable", UPSTREAM_ERROR_STATUS))
105+
106+
@app.exception_handler(ThrottledError)
107+
async def _throttled(_request, _error: ThrottledError):
108+
response = JSONResponse(status_code=429,
109+
content=error_payload(
110+
"too many login attempts, slow down",
111+
"rate_limited", 429))
112+
# OpenAI 客户端按 Retry-After 退避;缺失会立即重试加剧限流
113+
response.headers.setdefault("Retry-After", "60")
114+
return response

‎src/webapp/limits.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""请求体上限(纯 ASGI 中间件,PROPOSAL §8)。
2+
3+
登录接口 8KB(PBKDF2 是 CPU 密集操作,超大 body 无意义),其余 16MB
4+
(聊天请求可能带图片 base64)。必须挂在最外层:放在内层时下游会先读完
5+
body,限制就失去意义。
6+
"""
7+
8+
from __future__ import annotations
9+
10+
# 请求体上限:登录 8KB(PBKDF2 是 CPU 密集操作,超大 body 无意义);
11+
# 其余 16MB(聊天请求可能带图片 base64)。
12+
LOGIN_BODY_LIMIT = 8 * 1024
13+
DEFAULT_BODY_LIMIT = 16 * 1024 * 1024
14+
15+
16+
def _body_limit(path: str) -> int:
17+
# rstrip 处理尾斜杠:/api/auth/login/ 同样按登录上限(8KB),否则会先被
18+
# 按 16MB 读完再 307 重定向,绕过登录限流一次
19+
return LOGIN_BODY_LIMIT if path.rstrip("/") == "/api/auth/login" else DEFAULT_BODY_LIMIT
20+
21+
22+
async def _send_too_large(send) -> None:
23+
await send({"type": "http.response.start", "status": 413,
24+
"headers": [(b"content-type", b"application/json")]})
25+
await send({"type": "http.response.body",
26+
"body": b'{"error":{"message":"request body too large",'
27+
b'"type":"api_error","code":"invalid_request","status":413}}'})
28+
29+
30+
class BodySizeLimitMiddleware:
31+
"""请求体上限(纯 ASGI):content-length 与实际分块计数双管。
32+
33+
只看 content-length 头会被 `Transfer-Encoding: chunked` 绕过——
34+
分块请求根本不带这个头。这里在 receive 层累计字节数,
35+
超限立即换成 413 响应并截断下游消费。
36+
"""
37+
38+
def __init__(self, app) -> None:
39+
self.app = app
40+
41+
async def __call__(self, scope, receive, send) -> None:
42+
if scope["type"] != "http":
43+
await self.app(scope, receive, send)
44+
return
45+
limit = _body_limit(scope.get("path", ""))
46+
# content-length 已超限时直接拒,不必读 body
47+
for name, value in scope.get("headers", ()):
48+
if name == b"content-length":
49+
try:
50+
if int(value) > limit:
51+
await _send_too_large(send)
52+
return
53+
except ValueError:
54+
break
55+
56+
received = 0
57+
exceeded = False
58+
replaced = False
59+
60+
async def limited_receive():
61+
nonlocal received, exceeded
62+
message = await receive()
63+
if message["type"] == "http.request":
64+
received += len(message.get("body", b""))
65+
if received > limit:
66+
exceeded = True
67+
# 截断:让下游读到 EOF,避免继续消费攻击流量
68+
return {"type": "http.request", "body": b"", "more_body": False}
69+
return message
70+
71+
async def guarded_send(message):
72+
"""超限后丢弃下游的全部响应,只发出我们自己的 413。"""
73+
nonlocal replaced
74+
if not exceeded:
75+
await send(message)
76+
return
77+
if message["type"] != "http.response.start":
78+
return # 丢弃下游 body
79+
if not replaced:
80+
replaced = True
81+
await _send_too_large(send)
82+
83+
await self.app(scope, limited_receive, guarded_send)

‎src/webapp/security.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Host 白名单与安全响应头(PROPOSAL §8)。
2+
3+
拆出来是为了让 build_app 只负责装配;这两个策略本身与业务无关,
4+
改动理由也不同(安全策略调整 vs 服务组装)。
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from fastapi import Request
10+
from fastapi.responses import JSONResponse
11+
12+
from ..compat.openai.errors import error_payload
13+
from ..config import Settings
14+
15+
16+
def host_allowed(host_header: str, settings: Settings) -> bool:
17+
"""Host 白名单(防 DNS rebinding)。
18+
19+
ALLOWED_HOSTS 配置优先(逗号分隔);未配置时放行本地回环、PUBLIC_BASE_URL
20+
的主机与 testserver(FastAPI TestClient 默认 Host,仅测试场景)。
21+
只比较主机名,忽略端口。
22+
"""
23+
hostname = host_header.split(":", 1)[0].strip().lower().strip("[]")
24+
if settings.allowed_hosts:
25+
allowed = {h.split(":", 1)[0].strip().lower().strip("[]")
26+
for h in settings.allowed_hosts.split(",") if h.strip()}
27+
return hostname in allowed
28+
allowed = {"localhost", "127.0.0.1", "::1", "testserver"}
29+
base = settings.public_base_url
30+
if base.startswith(("http://", "https://")):
31+
base = base.split("://", 1)[1]
32+
path_host = base.split("/", 1)[0].split(":", 1)[0].strip().lower().strip("[]")
33+
if path_host:
34+
allowed.add(path_host)
35+
return hostname in allowed
36+
37+
38+
async def security_middleware(request: Request, call_next):
39+
"""Host 校验 + 安全响应头。请求体上限由 BodySizeLimitMiddleware 处理。
40+
41+
注册方式(app.middleware("http") 装饰器)留在 build_app,因为那是
42+
FastAPI 的接线细节;返回什么由这里决定。
43+
"""
44+
host_header = request.headers.get("host", "")
45+
if not host_allowed(host_header, request.app.state.settings):
46+
return JSONResponse(status_code=400,
47+
content=error_payload("invalid host header",
48+
"invalid_request", 400))
49+
# 纯读 content-length 会被 chunked 请求绕过,因此上限在 ASGI 层做
50+
response = await call_next(request)
51+
response.headers.setdefault("X-Content-Type-Options", "nosniff")
52+
response.headers.setdefault("X-Frame-Options", "DENY")
53+
response.headers.setdefault("Content-Security-Policy", "frame-ancestors 'none'")
54+
return response

‎src/webapp/static.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""前端静态资源:产物定位 + SPA catch-all 路由。
2+
3+
模块级状态(_PROJECT_ROOT / _CONTAINER_DIST / _frontend_dist)被测试直接
4+
patch,因此这里保持为普通模块属性,不做成类或闭包。
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
from fastapi import FastAPI
12+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13+
14+
from ..compat.openai.errors import error_payload
15+
16+
# 每个路径前缀对应的 API 错误形状:/v1 走 OpenAI 兼容体,其余走管理台形状
17+
_API_PREFIXES = ("api/", "v1/")
18+
19+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
20+
_CONTAINER_DIST = Path("/app/web/dist")
21+
22+
_FRONTEND_MISSING_HTML = """<!doctype html>
23+
<html lang="zh-CN"><head><meta charset="utf-8"><title>Coding2API</title>
24+
<style>body{font-family:ui-sans-serif,system-ui,sans-serif;max-width:44rem;margin:4rem auto;
25+
padding:0 1.5rem;line-height:1.7;color:#1f2937}
26+
code{background:#f3f4f6;padding:.15rem .4rem;border-radius:.25rem;font-size:.9em}
27+
pre{background:#f3f4f6;padding:1rem;border-radius:.5rem;overflow-x:auto}
28+
h1{font-size:1.25rem}</style></head>
29+
<body><h1>管理台前端尚未构建</h1>
30+
<p>后端已经在运行,但找不到前端产物,因此无法显示管理界面。</p>
31+
<p>在项目根目录执行:</p>
32+
<pre>cd web &amp;&amp; pnpm install &amp;&amp; pnpm build</pre>
33+
<p>构建完成后刷新本页即可。API 端点(<code>/v1/*</code>、<code>/api/*</code>)
34+
不受影响,现在就可以用。</p>
35+
</body></html>"""
36+
37+
38+
def api_not_found(path: str) -> JSONResponse:
39+
"""未匹配的 /api、/v1 路径返回 JSON 404。
40+
41+
静态资源是 catch-all 路由(返回 index.html),不排除 API 前缀的话,
42+
客户端拼错端点会拿到 200 + HTML,看起来“调用成功”,极难排查。
43+
"""
44+
return JSONResponse(status_code=404,
45+
content=error_payload(f"no such endpoint: /{path}",
46+
"invalid_request", 404))
47+
48+
49+
def frontend_dist() -> Path | None:
50+
"""定位前端产物目录;不存在时返回 None。"""
51+
candidates = (
52+
_PROJECT_ROOT / "web" / "dist", # 源码运行
53+
_CONTAINER_DIST, # 容器内固定路径
54+
Path.cwd() / "web" / "dist", # 兜底:从仓库根启动
55+
)
56+
for candidate in candidates:
57+
if (candidate / "index.html").is_file():
58+
return candidate
59+
return None
60+
61+
62+
def register_spa_routes(app: FastAPI) -> None:
63+
"""注册 SPA catch-all。
64+
65+
必须在所有 API 路由之后注册——它是通配路由,先注册会吞掉一切。
66+
路径锚定到项目根(而不是当前工作目录),否则从其他目录启动服务时
67+
会找不到前端产物。找不到时给出可执行的下一步,而不是一句
68+
「frontend build not found」。
69+
"""
70+
71+
@app.get("/{path:path}", include_in_schema=False)
72+
async def spa(path: str):
73+
"""生产模式服务前端产物;开发模式由 Vite 代理,不经过此路由。"""
74+
if path.startswith(_API_PREFIXES) or path in ("api", "v1"):
75+
return api_not_found(path)
76+
dist = frontend_dist()
77+
if dist is None:
78+
return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503)
79+
candidate = (dist / path).resolve()
80+
if path and candidate.is_file() and dist.resolve() in candidate.parents:
81+
return FileResponse(candidate)
82+
index = dist / "index.html"
83+
if not index.is_file():
84+
return HTMLResponse(_FRONTEND_MISSING_HTML, status_code=503)
85+
return FileResponse(index)

‎tests/test_hardening.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,12 +524,12 @@ def test_frontend_routes_are_not_treated_as_api_404(client, tmp_path, monkeypatc
524524
dist 产物是 gitignored 的(CI 上不存在),因此这里把 dist 指到临时目录,
525525
同时断言「服务了 index.html」而不是「依赖本地构建产物」。
526526
"""
527-
from src import main
527+
from src.webapp import static
528528

529529
dist = tmp_path / "dist"
530530
dist.mkdir()
531531
(dist / "index.html").write_text("<html>spa</html>", encoding="utf-8")
532-
monkeypatch.setattr(main, "_frontend_dist", lambda: dist)
532+
monkeypatch.setattr(static, "frontend_dist", lambda: dist)
533533

534534
response = client.get("/credentials")
535535
assert response.status_code == 200

0 commit comments

Comments
 (0)