168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""应用入口。
|
||||
|
|
|
|||
|
|
启动方式:
|
|||
|
|
uvicorn app.main:app --reload # 开发
|
|||
|
|
python -m app.main # 等价写法(会先自动建表)
|
|||
|
|
|
|||
|
|
接口文档:
|
|||
|
|
http://127.0.0.1:8000/docs # Swagger UI
|
|||
|
|
http://127.0.0.1:8000/redoc # ReDoc
|
|||
|
|
http://127.0.0.1:8000/ # 前端页面
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from contextlib import asynccontextmanager
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from fastapi import FastAPI, Request
|
|||
|
|
from fastapi.exceptions import RequestValidationError
|
|||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|||
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|||
|
|
from fastapi.staticfiles import StaticFiles
|
|||
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|||
|
|
|
|||
|
|
# --- 让「直接运行本文件」也能工作 -------------------------------------------
|
|||
|
|
# `python app/main.py`(或 PyCharm 里右键 Run)时,Python 把 sys.path[0]
|
|||
|
|
# 设成**脚本所在目录** app/,于是下面那行 `from app.api import ...`
|
|||
|
|
# 找不到 app 包,报 ModuleNotFoundError: No module named 'app'。
|
|||
|
|
# 把项目根补进 sys.path,这条路才通。
|
|||
|
|
# 用 `python -m app.main` / `uvicorn app.main:app` / 项目根的 run.py 启动时,
|
|||
|
|
# 项目根本来就在 sys.path 里,这几行是无害的。
|
|||
|
|
_ROOT = str(Path(__file__).resolve().parent.parent)
|
|||
|
|
if _ROOT not in sys.path:
|
|||
|
|
sys.path.insert(0, _ROOT)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
from app.api import api_router
|
|||
|
|
from app.core.config import settings
|
|||
|
|
from app.core.database import engine, ensure_database_exists
|
|||
|
|
from app.core.exceptions import BusinessError
|
|||
|
|
from app.model import Base
|
|||
|
|
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
|||
|
|
datefmt="%H:%M:%S",
|
|||
|
|
)
|
|||
|
|
logger = logging.getLogger("wolin")
|
|||
|
|
|
|||
|
|
STATIC_DIR = Path(__file__).parent / "static"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@asynccontextmanager
|
|||
|
|
async def lifespan(app: FastAPI):
|
|||
|
|
ensure_database_exists()
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
logger.info("数据库就绪:%s", settings.DB_NAME)
|
|||
|
|
logger.info("接口文档:http://127.0.0.1:8000/docs")
|
|||
|
|
yield
|
|||
|
|
engine.dispose()
|
|||
|
|
|
|||
|
|
|
|||
|
|
app = FastAPI(
|
|||
|
|
title=settings.APP_NAME,
|
|||
|
|
description=(
|
|||
|
|
f"{settings.APP_DESC}\n\n"
|
|||
|
|
"**统一响应格式**:`{code, msg, data}`,`code=0` 表示成功。\n\n"
|
|||
|
|
"**鉴权**:除 `/auth/login` 外都需要在请求头带 `Authorization: Bearer <token>`。\n\n"
|
|||
|
|
"**分层**:api(路由)/ service(业务)/ dao(数据访问)/ model(ORM)/ schema(校验)。"
|
|||
|
|
),
|
|||
|
|
version=settings.VERSION,
|
|||
|
|
lifespan=lifespan,
|
|||
|
|
docs_url="/docs",
|
|||
|
|
redoc_url="/redoc",
|
|||
|
|
openapi_url="/openapi.json",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
app.add_middleware(
|
|||
|
|
CORSMiddleware,
|
|||
|
|
allow_origins=["*"],
|
|||
|
|
allow_credentials=True,
|
|||
|
|
allow_methods=["*"],
|
|||
|
|
allow_headers=["*"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================================================================== 异常处理
|
|||
|
|
@app.exception_handler(BusinessError)
|
|||
|
|
async def business_error_handler(request: Request, exc: BusinessError):
|
|||
|
|
return JSONResponse(status_code=200, content={"code": exc.code, "msg": exc.msg, "data": exc.data})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.exception_handler(RequestValidationError)
|
|||
|
|
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
|||
|
|
"""把 Pydantic 的报错翻译成人话,前端可以直接弹。"""
|
|||
|
|
problems = []
|
|||
|
|
for error in exc.errors():
|
|||
|
|
location = " → ".join(str(x) for x in error.get("loc", []) if x != "body")
|
|||
|
|
problems.append(f"{location}: {error.get('msg')}")
|
|||
|
|
return JSONResponse(
|
|||
|
|
status_code=200,
|
|||
|
|
content={"code": 422, "msg": "参数校验未通过:" + ";".join(problems), "data": {"errors": problems}},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.exception_handler(IntegrityError)
|
|||
|
|
async def integrity_error_handler(request: Request, exc: IntegrityError):
|
|||
|
|
logger.warning("数据库约束冲突:%s", exc)
|
|||
|
|
return JSONResponse(status_code=200, content={"code": 409, "msg": "数据冲突(唯一键重复或外键不合法)", "data": None})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.exception_handler(SQLAlchemyError)
|
|||
|
|
async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
|
|||
|
|
logger.exception("数据库错误")
|
|||
|
|
return JSONResponse(status_code=200, content={"code": 500, "msg": f"数据库操作失败:{exc}", "data": None})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.exception_handler(Exception)
|
|||
|
|
async def unhandled_error_handler(request: Request, exc: Exception):
|
|||
|
|
logger.exception("未处理异常:%s %s", request.method, request.url.path)
|
|||
|
|
return JSONResponse(status_code=200, content={"code": 500, "msg": f"服务器内部错误:{exc}", "data": None})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================================================================== 中间件
|
|||
|
|
@app.middleware("http")
|
|||
|
|
async def add_process_time(request: Request, call_next):
|
|||
|
|
start = time.perf_counter()
|
|||
|
|
response = await call_next(request)
|
|||
|
|
response.headers["X-Process-Time-ms"] = f"{(time.perf_counter() - start) * 1000:.1f}"
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================================================================== 路由
|
|||
|
|
app.include_router(api_router)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/health", tags=["系统"], summary="健康检查")
|
|||
|
|
def health():
|
|||
|
|
return {
|
|||
|
|
"code": 0,
|
|||
|
|
"msg": "ok",
|
|||
|
|
"data": {
|
|||
|
|
"app": settings.APP_NAME,
|
|||
|
|
"version": settings.VERSION,
|
|||
|
|
"database": settings.DB_NAME,
|
|||
|
|
"dialect": engine.dialect.name,
|
|||
|
|
"auth_enabled": settings.AUTH_ENABLED,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================================================================== 前端
|
|||
|
|
if STATIC_DIR.exists():
|
|||
|
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|||
|
|
|
|||
|
|
@app.get("/", include_in_schema=False)
|
|||
|
|
def index():
|
|||
|
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
import uvicorn
|
|||
|
|
|
|||
|
|
uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=False)
|