Files
ToDoList/api/app/main.py
祀梦 0ab719500b feat: add WebDAV sync support and startup/shutdown scripts
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
2026-05-17 21:18:54 +08:00

204 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)