feat: initial MVP - FastAPI backend + Vue3 frontend + docker-compose
- backend: FastAPI + SQLAlchemy 2.0(async) + asyncpg + Alembic - 7 API routes: auth/me/articles/sources/bookmarks/subscriptions/admin - models: User/Source/Article/Bookmark/Subscription/ApiToken - services: RSS fetcher (feedparser) + Tencent TMT translator with quota + cache + local NLLB fallback - workers: APScheduler + asyncio pipeline (fetch -> dedupe -> insert -> translate) - seed scripts: create_user, seed_sources (5 RSS: Reuters/BBC/Al Jazeera/NHK/DW) - frontend: Vue 3 + Vite + Naive UI + Pinia + vue-router - pages: Login, Feed (24h), ArticleDetail, Sources, Bookmarks, AdminSources - deploy: docker-compose (postgres/redis/api/worker/frontend/caddy) - docs: README, DEPLOY, architecture, acceptance
This commit is contained in:
117
backend/app/main.py
Normal file
117
backend/app/main.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""FastAPI 入口。
|
||||
|
||||
- 注册路由
|
||||
- 启动 / 关闭事件:连接池、调度器
|
||||
- CORS
|
||||
- 全局异常处理
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.api import admin, articles, auth, bookmarks, me, sources, subscriptions
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.redis_client import close_redis, get_redis
|
||||
|
||||
logger = logging.getLogger("news.api")
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 启动
|
||||
logger.info("api starting, tz=%s", settings.tz)
|
||||
# 触发 redis 连接
|
||||
await get_redis().ping()
|
||||
yield
|
||||
# 关闭
|
||||
logger.info("api shutting down")
|
||||
await close_redis()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Diary News",
|
||||
description="Private news aggregator",
|
||||
version="0.1.0",
|
||||
default_response_class=JSONResponse,
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
# CORS:网页 + Android,简单放开(私有)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # MVP 放开,生产收紧
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# === 全局异常处理(RFC 7807) ===
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exc_handler(request: Request, exc: StarletteHTTPException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"type": "about:blank",
|
||||
"title": exc.detail if isinstance(exc.detail, str) else "Error",
|
||||
"status": exc.status_code,
|
||||
"instance": str(request.url),
|
||||
},
|
||||
headers=exc.headers or None,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exc_handler(request: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"type": "about:blank",
|
||||
"title": "Validation Error",
|
||||
"status": 422,
|
||||
"errors": exc.errors(),
|
||||
"instance": str(request.url),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# === 路由 ===
|
||||
API_PREFIX = "/api/v1"
|
||||
|
||||
app.include_router(auth.router, prefix=API_PREFIX)
|
||||
app.include_router(me.router, prefix=API_PREFIX)
|
||||
app.include_router(articles.router, prefix=API_PREFIX)
|
||||
app.include_router(sources.router, prefix=API_PREFIX)
|
||||
app.include_router(bookmarks.router, prefix=API_PREFIX)
|
||||
app.include_router(subscriptions.router, prefix=API_PREFIX)
|
||||
app.include_router(admin.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# === 健康检查 ===
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
async def healthz():
|
||||
try:
|
||||
await get_redis().ping()
|
||||
except Exception as e:
|
||||
return JSONResponse({"status": "degraded", "redis": str(e)}, status_code=503)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return {"name": "diary-news", "version": app.version, "docs": "/api/docs"}
|
||||
Reference in New Issue
Block a user