Backend: - Add uuid, sync_version, is_deleted fields to all syncable models - Add SyncSettings model for WebDAV configuration (AES-256-GCM encrypted passwords) - Add crypto.py: AES-256-GCM encryption derived from JWT_SECRET via PBKDF2 - Add sync_lock.py: thread-level sync lock with 503 middleware for write blocking - Add webdav.py: WebDAV client using requests (PUT/GET/MKCOL/DELETE) - Add sync_service.py: push/pull/bidirectional merge with LWW conflict resolution - Add sync router with 8 endpoints: config, test, push, pull, sync, status, remote delete - Add UUID backfill for existing records in init_db() - Add SQLAlchemy before_update event to auto-increment sync_version - Register sync middleware to block writes during sync (503) Frontend: - Add sync API client (WebUI/src/api/sync.ts) - Add useSyncStore with config, test, push/pull/sync operations - Add WebDAV config + sync UI in SettingsView - Add 503 status code handling in axios interceptor - Add uuid field to all TypeScript type definitions Scripts: - Add scripts/start.bat and scripts/stop.bat for project management Design doc: docs/plan/webdav-sync-design.md
204 lines
6.4 KiB
Python
204 lines
6.4 KiB
Python
from contextlib import asynccontextmanager
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, FileResponse
|
||
import os
|
||
import time
|
||
import json
|
||
|
||
from app.config import CORS_ORIGINS, WEBUI_PATH, HOST, PORT
|
||
from app.database import init_db, SessionLocal
|
||
from app.models.user_settings import UserSettings
|
||
from app.routers import api_router
|
||
from app.utils.logger import logger
|
||
from app.utils.auth import decode_access_token, get_cached_token_version, set_cached_token_version
|
||
from app.utils.sync_lock import is_syncing
|
||
from jose import JWTError
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
"""应用生命周期管理"""
|
||
# 启动时
|
||
logger.info("应用启动中...")
|
||
init_db()
|
||
logger.info("数据库初始化完成")
|
||
yield
|
||
# 关闭时
|
||
logger.info("应用关闭中...")
|
||
|
||
|
||
# 创建 FastAPI 应用
|
||
app = FastAPI(
|
||
title="爱莉希雅待办事项 API",
|
||
description="Elysia ToDo - 个人信息管理应用",
|
||
version="1.0.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# 配置 CORS
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=CORS_ORIGINS,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
# 请求日志中间件
|
||
@app.middleware("http")
|
||
async def log_requests(request: Request, call_next):
|
||
start_time = time.time()
|
||
|
||
# 记录请求信息
|
||
request_method = request.method
|
||
request_path = request.url.path
|
||
query_params = dict(request.query_params) if request.query_params else None
|
||
|
||
# 构建日志信息
|
||
log_parts = [f"请求开始 -> {request_method} {request_path}"]
|
||
if query_params:
|
||
log_parts.append(f"Query参数: {json.dumps(query_params, ensure_ascii=False)}")
|
||
|
||
# 尝试读取请求体(仅对有 body 的方法)
|
||
body_info = None
|
||
if request.method in ["POST", "PUT", "PATCH"]:
|
||
try:
|
||
body_bytes = await request.body()
|
||
if body_bytes:
|
||
try:
|
||
body_json = json.loads(body_bytes)
|
||
body_info = json.dumps(body_json, ensure_ascii=False)
|
||
except json.JSONDecodeError:
|
||
body_info = body_bytes.decode('utf-8', errors='replace')[:200]
|
||
log_parts.append(f"Body: {body_info}")
|
||
except Exception:
|
||
pass
|
||
|
||
logger.info(" | ".join(log_parts))
|
||
|
||
# 执行请求
|
||
response = await call_next(request)
|
||
|
||
# 计算耗时
|
||
process_time = (time.time() - start_time) * 1000
|
||
|
||
# 记录响应信息
|
||
logger.info(
|
||
f"请求完成 <- {request_method} {request_path} | "
|
||
f"状态码: {response.status_code} | 耗时: {process_time:.2f}ms"
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
# 认证中间件(保护 /api/*,仅放行 /health 和 /api/auth/login、/api/auth/logout)
|
||
@app.middleware("http")
|
||
async def auth_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
|
||
# 不拦截:健康检查、静态文件、公开的 auth 端点
|
||
public_paths = {"/health", "/api/auth/login", "/api/auth/logout", "/api/auth/status", "/api/auth/setup"}
|
||
if path in public_paths or not path.startswith("/api/"):
|
||
return await call_next(request)
|
||
|
||
token = request.cookies.get("access_token", "")
|
||
if not token:
|
||
return JSONResponse(status_code=401, content={"detail": "未登录"})
|
||
|
||
try:
|
||
payload = decode_access_token(token)
|
||
except JWTError:
|
||
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
|
||
|
||
user_id = payload.get("sub", "")
|
||
token_tv = payload.get("tv")
|
||
|
||
if token_tv is not None and user_id:
|
||
cached_tv = get_cached_token_version(user_id)
|
||
if cached_tv is None:
|
||
db = SessionLocal()
|
||
try:
|
||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||
cached_tv = settings.token_version if settings else 0
|
||
set_cached_token_version(user_id, cached_tv)
|
||
finally:
|
||
db.close()
|
||
if token_tv != cached_tv:
|
||
return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"})
|
||
|
||
request.state.user = payload
|
||
|
||
return await call_next(request)
|
||
|
||
|
||
# 同步锁中间件(同步期间禁止写操作)
|
||
@app.middleware("http")
|
||
async def sync_lock_middleware(request: Request, call_next):
|
||
path = request.url.path
|
||
|
||
if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||
if not path.startswith("/api/sync"):
|
||
return JSONResponse(status_code=503, content={"detail": "正在同步数据,请稍后再试"})
|
||
|
||
return await call_next(request)
|
||
|
||
|
||
# 全局异常处理器
|
||
@app.exception_handler(Exception)
|
||
async def global_exception_handler(request: Request, exc: Exception):
|
||
# 使用 exc_info=True 记录完整堆栈信息
|
||
logger.error(
|
||
f"全局异常: {request.method} {request.url.path} | 错误: {str(exc)}",
|
||
exc_info=True
|
||
)
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={
|
||
"success": False,
|
||
"message": "服务器内部错误",
|
||
"error_code": "INTERNAL_ERROR"
|
||
}
|
||
)
|
||
|
||
|
||
# 注册路由
|
||
app.include_router(api_router)
|
||
|
||
|
||
# 健康检查(必须在 static mount 之前注册,否则会被静态文件拦截)
|
||
@app.get("/health")
|
||
async def health_check():
|
||
"""健康检查"""
|
||
return {"status": "ok", "message": "服务运行正常"}
|
||
|
||
|
||
# SPA 静态文件回退路由(支持前端 History 模式路由)
|
||
if os.path.exists(WEBUI_PATH):
|
||
|
||
@app.get("/")
|
||
async def serve_index():
|
||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||
|
||
@app.get("/{full_path:path}")
|
||
async def spa_fallback(request: Request, full_path: str):
|
||
"""SPA 回退:先尝试提供真实文件,找不到则返回 index.html"""
|
||
# 规范化路径并防止路径穿越攻击
|
||
safe_path = os.path.normpath(os.path.join(WEBUI_PATH, full_path))
|
||
if not safe_path.startswith(os.path.normpath(WEBUI_PATH)):
|
||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||
if os.path.isfile(safe_path):
|
||
return FileResponse(safe_path)
|
||
return FileResponse(os.path.join(WEBUI_PATH, "index.html"))
|
||
|
||
logger.info(f"SPA 静态文件服务已配置: {WEBUI_PATH}")
|
||
else:
|
||
logger.warning(f"WebUI 目录不存在: {WEBUI_PATH}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
logger.info(f"启动服务: {HOST}:{PORT}")
|
||
uvicorn.run(app, host=HOST, port=PORT)
|