2025-10-13 15:26:36 +08:00
|
|
|
|
import asyncio
|
2026-03-13 22:57:02 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
# 解决 Windows 下 psycopg 异步模式不支持 ProactorEventLoop 的问题
|
|
|
|
|
|
# 注意:这段代码必须放在应用的极早期,最好在导入 FastAPI 或初始化数据库之前
|
|
|
|
|
|
# ==============================================================================
|
|
|
|
|
|
if sys.platform == "win32":
|
2026-03-27 02:00:22 +08:00
|
|
|
|
# 把当前文件 (main.py) 的上一级的上一级 (即根目录 Yuxi) 加入到 sys.path
|
2026-03-13 22:57:02 +08:00
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
|
|
|
2026-03-14 23:54:52 +08:00
|
|
|
|
import time
|
2025-10-13 15:26:36 +08:00
|
|
|
|
from collections import defaultdict, deque
|
|
|
|
|
|
|
2024-10-02 20:11:28 +08:00
|
|
|
|
import uvicorn
|
2025-10-13 15:26:36 +08:00
|
|
|
|
from fastapi import FastAPI, Request, status
|
2026-07-07 16:25:47 +08:00
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
2024-10-02 20:11:28 +08:00
|
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2025-10-13 15:26:36 +08:00
|
|
|
|
from fastapi.responses import JSONResponse
|
2025-05-02 23:56:59 +08:00
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
|
|
|
2026-06-20 22:17:20 +08:00
|
|
|
|
from yuxi.external_systems.exceptions import ExternalSystemError
|
2026-06-22 20:07:20 +08:00
|
|
|
|
from yuxi.scheduler.exceptions import SchedulerError
|
2026-07-07 16:25:47 +08:00
|
|
|
|
from yuxi.channels.contract.errors import Error as ChannelError
|
2025-04-04 00:16:18 +08:00
|
|
|
|
from server.routers import router
|
2026-07-07 16:25:47 +08:00
|
|
|
|
from server.utils.error_handlers import (
|
|
|
|
|
|
unified_error_handler,
|
|
|
|
|
|
unhandled_exception_handler,
|
|
|
|
|
|
request_validation_error_handler,
|
|
|
|
|
|
)
|
2025-10-23 14:08:08 +08:00
|
|
|
|
from server.utils.lifespan import lifespan
|
2025-05-05 16:31:35 +08:00
|
|
|
|
from server.utils.auth_middleware import is_public_path
|
2025-08-28 23:08:24 +08:00
|
|
|
|
from server.utils.common_utils import setup_logging
|
2025-12-17 22:55:12 +08:00
|
|
|
|
from server.utils.access_log_middleware import AccessLogMiddleware
|
2026-07-07 16:25:47 +08:00
|
|
|
|
from server.utils.trace_middleware import TraceIdMiddleware
|
|
|
|
|
|
from server.utils.inflight_tracker import InflightTrackingMiddleware
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
2025-08-28 23:08:24 +08:00
|
|
|
|
# 设置日志配置
|
|
|
|
|
|
setup_logging()
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
2025-10-13 15:26:36 +08:00
|
|
|
|
RATE_LIMIT_MAX_ATTEMPTS = 10
|
|
|
|
|
|
RATE_LIMIT_WINDOW_SECONDS = 60
|
|
|
|
|
|
RATE_LIMIT_ENDPOINTS = {("/api/auth/token", "POST")}
|
|
|
|
|
|
|
|
|
|
|
|
# In-memory login attempt tracker to reduce brute-force exposure per worker
|
|
|
|
|
|
_login_attempts: defaultdict[str, deque[float]] = defaultdict(deque)
|
|
|
|
|
|
_attempt_lock = asyncio.Lock()
|
|
|
|
|
|
|
2025-10-23 14:08:08 +08:00
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
2026-03-26 00:24:34 +08:00
|
|
|
|
# 所有业务接口统一挂载到 /api,具体分组在 server.routers 中集中注册。
|
2025-05-05 16:31:35 +08:00
|
|
|
|
app.include_router(router, prefix="/api")
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
2026-06-20 22:17:20 +08:00
|
|
|
|
|
2026-07-07 16:25:47 +08:00
|
|
|
|
# 三模块异常统一注册到同一个 handler
|
|
|
|
|
|
app.add_exception_handler(ChannelError, unified_error_handler)
|
|
|
|
|
|
app.add_exception_handler(ExternalSystemError, unified_error_handler)
|
|
|
|
|
|
app.add_exception_handler(SchedulerError, unified_error_handler)
|
|
|
|
|
|
app.add_exception_handler(RequestValidationError, request_validation_error_handler)
|
|
|
|
|
|
app.add_exception_handler(Exception, unhandled_exception_handler)
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2025-10-13 15:26:36 +08:00
|
|
|
|
def _extract_client_ip(request: Request) -> str:
|
|
|
|
|
|
forwarded_for = request.headers.get("x-forwarded-for")
|
|
|
|
|
|
if forwarded_for:
|
|
|
|
|
|
return forwarded_for.split(",")[0].strip()
|
|
|
|
|
|
if request.client:
|
|
|
|
|
|
return request.client.host
|
|
|
|
|
|
return "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LoginRateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
normalized_path = request.url.path.rstrip("/") or "/"
|
|
|
|
|
|
request_signature = (normalized_path, request.method.upper())
|
|
|
|
|
|
|
|
|
|
|
|
if request_signature in RATE_LIMIT_ENDPOINTS:
|
|
|
|
|
|
client_ip = _extract_client_ip(request)
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
|
|
|
|
|
|
|
async with _attempt_lock:
|
|
|
|
|
|
attempt_history = _login_attempts[client_ip]
|
|
|
|
|
|
|
|
|
|
|
|
while attempt_history and now - attempt_history[0] > RATE_LIMIT_WINDOW_SECONDS:
|
|
|
|
|
|
attempt_history.popleft()
|
|
|
|
|
|
|
|
|
|
|
|
if len(attempt_history) >= RATE_LIMIT_MAX_ATTEMPTS:
|
|
|
|
|
|
retry_after = int(max(1, RATE_LIMIT_WINDOW_SECONDS - (now - attempt_history[0])))
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
2026-07-07 16:25:47 +08:00
|
|
|
|
content={
|
|
|
|
|
|
"success": False,
|
|
|
|
|
|
"error": {
|
|
|
|
|
|
"code": "RATE_LIMIT",
|
|
|
|
|
|
"message": "登录尝试过于频繁,请稍后再试",
|
|
|
|
|
|
"trace_id": None,
|
|
|
|
|
|
"details": {"retry_after": retry_after},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2025-10-13 15:26:36 +08:00
|
|
|
|
headers={"Retry-After": str(retry_after)},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
attempt_history.append(now)
|
|
|
|
|
|
|
|
|
|
|
|
response = await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code < 400:
|
|
|
|
|
|
async with _attempt_lock:
|
|
|
|
|
|
_login_attempts.pop(client_ip, None)
|
|
|
|
|
|
|
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-05-02 23:56:59 +08:00
|
|
|
|
# 鉴权中间件
|
|
|
|
|
|
class AuthMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
async def dispatch(self, request: Request, call_next):
|
|
|
|
|
|
# 获取请求路径
|
|
|
|
|
|
path = request.url.path
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否为公开路径,公开路径无需身份验证
|
|
|
|
|
|
if is_public_path(path):
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
2025-05-05 16:31:35 +08:00
|
|
|
|
if not path.startswith("/api"):
|
2025-05-02 23:56:59 +08:00
|
|
|
|
# 非API路径,可能是前端路由或静态资源
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
2025-05-05 16:31:35 +08:00
|
|
|
|
# # 提取Authorization头
|
|
|
|
|
|
# auth_header = request.headers.get("Authorization")
|
|
|
|
|
|
# if not auth_header or not auth_header.startswith("Bearer "):
|
|
|
|
|
|
# return JSONResponse(
|
|
|
|
|
|
# status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
|
# content={"detail": f"请先登录。Path: {path}"},
|
|
|
|
|
|
# headers={"WWW-Authenticate": "Bearer"}
|
|
|
|
|
|
# )
|
2025-05-02 23:56:59 +08:00
|
|
|
|
|
2025-05-05 16:31:35 +08:00
|
|
|
|
# # 获取token
|
|
|
|
|
|
# token = auth_header.split("Bearer ")[1]
|
2025-05-02 23:56:59 +08:00
|
|
|
|
|
2025-05-05 16:31:35 +08:00
|
|
|
|
# # 添加token到请求状态,后续路由可以直接使用
|
|
|
|
|
|
# request.state.token = token
|
2025-05-02 23:56:59 +08:00
|
|
|
|
|
|
|
|
|
|
# 继续处理请求
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|
|
2025-09-01 22:37:03 +08:00
|
|
|
|
|
2026-07-07 16:25:47 +08:00
|
|
|
|
# 中间件注册顺序为 LIFO(后注册先执行):
|
|
|
|
|
|
# CORSMiddleware 最内层,InflightTrackingMiddleware 最外层最先执行
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
|
allow_origins=["*"],
|
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
|
allow_headers=["*"],
|
|
|
|
|
|
)
|
2025-10-13 15:26:36 +08:00
|
|
|
|
app.add_middleware(LoginRateLimitMiddleware)
|
2025-05-02 23:56:59 +08:00
|
|
|
|
app.add_middleware(AuthMiddleware)
|
2026-07-07 16:25:47 +08:00
|
|
|
|
app.add_middleware(AccessLogMiddleware)
|
|
|
|
|
|
app.add_middleware(TraceIdMiddleware)
|
|
|
|
|
|
app.add_middleware(InflightTrackingMiddleware)
|
2024-10-02 20:11:28 +08:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-03-13 22:57:02 +08:00
|
|
|
|
# uvicorn.run(app, host="0.0.0.0", port=5050, threads=10, workers=10, reload=True)
|
|
|
|
|
|
|
|
|
|
|
|
uvicorn.run(
|
|
|
|
|
|
"server.main:app",
|
|
|
|
|
|
host="0.0.0.0",
|
|
|
|
|
|
port=5050,
|
|
|
|
|
|
reload=True,
|
2026-03-26 00:24:34 +08:00
|
|
|
|
# 与 docker-compose 开发环境保持一致,避免 package 下代码变更不触发热重载。
|
|
|
|
|
|
reload_dirs=["server", "package"],
|
2026-03-20 21:18:13 +08:00
|
|
|
|
)
|