from __future__ import annotations import asyncio import os import secrets from contextlib import asynccontextmanager from datetime import date, datetime, time, timedelta from pathlib import Path from typing import Annotated from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .auth import SessionManager from .layouts import load_layout from .models import ChartInput, ChartUpdate, DailyRecordInput, LoginInput, PurgeInput, ReorderInput, ScheduleCompleteInput, ScheduleInput, ScheduleUpdate, TaskInput, TaskUpdate from .repository import ConflictError, JsonRepository, NotFoundError, SHANGHAI, ValidationError PROJECT_ROOT = Path(__file__).resolve().parents[1] SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} def require_agent_token(request: Request): token = os.getenv("PPM_API_TOKEN", "").strip() if not token: raise HTTPException(status_code=503, detail="agent API 未启用") auth = request.headers.get("Authorization", "") if not secrets.compare_digest(auth, f"Bearer {token}"): raise HTTPException(status_code=401, detail="token 无效") def _cookie_secure() -> bool: return os.getenv("PPM_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"} def _set_session_cookie(response: JSONResponse, session_id: str) -> None: response.set_cookie( "ppm_session", session_id, httponly=True, secure=_cookie_secure(), samesite="strict", max_age=60 * 60 * 24 * 30, ) def _set_csrf_cookie(response: JSONResponse, token: str) -> None: response.set_cookie( "ppm_csrf", token, httponly=False, secure=_cookie_secure(), samesite="strict", max_age=60 * 60 * 24 * 30, ) async def _archive_worker(repository: JsonRepository) -> None: while True: now = datetime.now(SHANGHAI) tomorrow = now.date() + timedelta(days=1) next_run = datetime.combine(tomorrow, time(hour=0, minute=0, second=2), SHANGHAI) await asyncio.sleep(max((next_run - now).total_seconds(), 1)) repository.close_missed_days() def create_app( data_dir: Path | None = None, layout_dir: Path | None = None, sessions: SessionManager | None = None, ) -> FastAPI: repository = JsonRepository(data_dir or Path(os.getenv("PPM_DATA_DIR", PROJECT_ROOT / "data"))) layouts = layout_dir or Path(os.getenv("PPM_LAYOUT_DIR", PROJECT_ROOT / "config" / "layouts")) session_manager = sessions or SessionManager.from_environment() @asynccontextmanager async def lifespan(_: FastAPI): repository.close_missed_days() worker = asyncio.create_task(_archive_worker(repository)) yield worker.cancel() try: await worker except asyncio.CancelledError: pass app = FastAPI(title="个人进度监视系统", version="0.1.0", lifespan=lifespan) app.state.repository = repository app.state.sessions = session_manager app.mount("/static", StaticFiles(directory=PROJECT_ROOT / "app" / "static"), name="static") @app.exception_handler(NotFoundError) async def handle_not_found(_: Request, exc: NotFoundError) -> JSONResponse: return JSONResponse({"detail": str(exc)}, status_code=404) @app.exception_handler(ConflictError) async def handle_conflict(_: Request, exc: ConflictError) -> JSONResponse: return JSONResponse({"detail": str(exc)}, status_code=409) @app.exception_handler(ValidationError) async def handle_validation(_: Request, exc: ValidationError) -> JSONResponse: return JSONResponse({"detail": str(exc)}, status_code=422) @app.middleware("http") async def security_middleware(request: Request, call_next): path = request.url.path public = path in {"/api/health", "/api/auth/status", "/api/auth/login"} or path.startswith("/api/agent/") if path.startswith("/api/") and not public: if session_manager.enabled and not session_manager.get(request.cookies.get("ppm_session")): return JSONResponse({"detail": "需要登录"}, status_code=401) if path.startswith("/api/") and request.method not in SAFE_METHODS and path != "/api/auth/login" and not path.startswith("/api/agent/"): submitted = request.headers.get("X-CSRF-Token", "") if session_manager.enabled: session = session_manager.get(request.cookies.get("ppm_session")) expected = session.csrf if session else "" else: expected = "" if session_manager.enabled and (not expected or not secrets.compare_digest(submitted, expected)): return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403) return await call_next(request) @app.get("/", include_in_schema=False) async def index() -> FileResponse: return FileResponse(PROJECT_ROOT / "app" / "static" / "index.html") @app.get("/api/health") async def health() -> dict: return {"status": "ok", "singleWorker": True} @app.get("/api/auth/status") async def auth_status(request: Request) -> JSONResponse: if session_manager.enabled: session = session_manager.get(request.cookies.get("ppm_session")) body = {"enabled": True, "authenticated": bool(session), "csrfToken": session.csrf if session else None} return JSONResponse(body) token = request.cookies.get("ppm_csrf") or secrets.token_urlsafe(32) response = JSONResponse({"enabled": False, "authenticated": True, "csrfToken": token}) _set_csrf_cookie(response, token) return response @app.post("/api/auth/login") async def login(payload: LoginInput, request: Request) -> JSONResponse: client_key = request.client.host if request.client else "" if session_manager.is_login_locked(client_key): return JSONResponse({"detail": "尝试次数过多,请稍后再试"}, status_code=429) result = session_manager.login(payload.password, client_key) if result is None: return JSONResponse({"detail": "密码错误"}, status_code=401) session_id, session = result response = JSONResponse({"authenticated": True, "csrfToken": session.csrf}) _set_session_cookie(response, session_id) _set_csrf_cookie(response, session.csrf) return response @app.post("/api/auth/logout") async def logout(request: Request) -> JSONResponse: session_manager.logout(request.cookies.get("ppm_session")) response = JSONResponse({"authenticated": False}) response.delete_cookie("ppm_session") response.delete_cookie("ppm_csrf") return response @app.get("/api/layouts/{page}") async def get_layout(page: str) -> dict: if page not in {"home", "task-detail", "review"}: raise NotFoundError("布局不存在") return load_layout(layouts, page) @app.get("/api/tasks") async def list_tasks(includeDeleted: bool = False) -> dict: return repository.list_tasks(includeDeleted) @app.post("/api/tasks", status_code=201) async def create_task(payload: TaskInput) -> dict: return repository.create_task(payload) @app.get("/api/tasks/{task_id}") async def get_task(task_id: str, includeDeleted: bool = False) -> dict: return repository.get_task(task_id, includeDeleted) @app.put("/api/tasks/{task_id}") async def update_task(task_id: str, payload: TaskUpdate) -> dict: task_input = TaskInput.model_validate(payload.model_dump(exclude={"expectedVersion"})) return repository.update_task(task_id, task_input, payload.expectedVersion) @app.delete("/api/tasks/{task_id}") async def delete_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict: return repository.soft_delete_task(task_id, expectedVersion) @app.post("/api/tasks/{task_id}/restore") async def restore_task(task_id: str, expectedVersion: int = Query(ge=1)) -> dict: return repository.restore_task(task_id, expectedVersion) @app.post("/api/tasks/{task_id}/purge") async def purge_task(task_id: str, payload: PurgeInput) -> dict: repository.purge_task(task_id, payload.confirmation) return {"purged": True, "taskId": task_id} @app.get("/api/schedules") async def list_schedules(includeDeleted: bool = False) -> dict: return repository.list_schedules(includeDeleted) @app.post("/api/schedules", status_code=201) async def create_schedule(payload: ScheduleInput) -> dict: return repository.create_schedule(payload) @app.get("/api/schedules/calendar") async def schedule_calendar(month: Annotated[str | None, Query()] = None) -> dict: value = month or datetime.now(SHANGHAI).strftime("%Y-%m") try: year, month_number = (int(part) for part in value.split("-")) if len(value) != 7 or value[4] != "-" or not 1 <= month_number <= 12: raise ValueError except ValueError: raise ValidationError("月份必须是 YYYY-MM 格式") return repository.calendar_month(year, month_number) @app.get("/api/schedules/today") async def schedules_today(date_: Annotated[date | None, Query(alias="date")] = None) -> dict: day = date_ or datetime.now(SHANGHAI).date() return {"date": day.isoformat(), "items": repository.schedules_for_date(day)} @app.get("/api/schedules/{schedule_id}") async def get_schedule(schedule_id: str, includeDeleted: bool = False) -> dict: return repository.get_schedule(schedule_id, includeDeleted) @app.put("/api/schedules/{schedule_id}") async def update_schedule(schedule_id: str, payload: ScheduleUpdate) -> dict: schedule_input = ScheduleInput.model_validate(payload.model_dump(exclude={"expectedVersion"})) return repository.update_schedule(schedule_id, schedule_input, payload.expectedVersion) @app.delete("/api/schedules/{schedule_id}") async def delete_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict: return repository.soft_delete_schedule(schedule_id, expectedVersion) @app.post("/api/schedules/{schedule_id}/restore") async def restore_schedule(schedule_id: str, expectedVersion: int = Query(ge=1)) -> dict: return repository.restore_schedule(schedule_id, expectedVersion) @app.post("/api/schedules/{schedule_id}/purge") async def purge_schedule(schedule_id: str, payload: PurgeInput) -> dict: repository.purge_schedule(schedule_id, payload.confirmation) return {"purged": True, "scheduleId": schedule_id} @app.post("/api/schedules/{schedule_id}/complete") async def complete_schedule(schedule_id: str, payload: ScheduleCompleteInput) -> dict: return repository.complete_schedule(schedule_id, payload.date, payload.completed, payload.expectedVersion) @app.get("/api/agent/overview") async def agent_overview( date_: Annotated[date | None, Query(alias="date")] = None, _: None = Depends(require_agent_token), ) -> dict: return repository.agent_overview(date_ or datetime.now(SHANGHAI).date()) @app.post("/api/agent/schedules", status_code=201) async def agent_create_schedule(payload: ScheduleInput, _: None = Depends(require_agent_token)) -> dict: return repository.create_schedule(payload) @app.put("/api/tasks/reorder/all") async def reorder_tasks(payload: ReorderInput) -> dict: return repository.reorder(payload.taskIds, payload.expectedVersion) @app.get("/api/tasks/{task_id}/daily/{logical_date}") async def get_daily(task_id: str, logical_date: date) -> dict: return repository.get_daily(task_id, logical_date) @app.put("/api/tasks/{task_id}/daily/{logical_date}") async def upsert_daily(task_id: str, logical_date: date, payload: DailyRecordInput) -> dict: return repository.upsert_daily(task_id, logical_date, payload) @app.get("/api/summary") async def summary(date_: Annotated[date | None, Query(alias="date")] = None) -> dict: return repository.summarize(date_) @app.get("/api/tasks/{task_id}/summary") async def task_summary(task_id: str, date_: Annotated[date | None, Query(alias="date")] = None) -> dict: return repository.summarize(date_, task_id) @app.get("/api/charts") async def list_charts() -> dict: return repository.list_charts() @app.post("/api/charts", status_code=201) async def create_chart(payload: ChartInput) -> dict: return repository.create_chart(payload) @app.get("/api/charts/{chart_id}/data") async def chart_data(chart_id: str, ref: Annotated[date | None, Query()] = None) -> dict: return repository.chart_data(chart_id, ref) @app.get("/api/charts/{chart_id}") async def get_chart(chart_id: str) -> dict: return repository.get_chart(chart_id) @app.put("/api/charts/{chart_id}") async def update_chart(chart_id: str, payload: ChartUpdate) -> dict: chart_input = ChartInput.model_validate(payload.model_dump(exclude={"expectedVersion"})) return repository.update_chart(chart_id, chart_input, payload.expectedVersion) @app.delete("/api/charts/{chart_id}") async def delete_chart(chart_id: str) -> dict: return repository.delete_chart(chart_id) @app.get("/api/review/notes") async def review_notes( start: Annotated[date | None, Query()] = None, end: Annotated[date | None, Query()] = None, ) -> dict: logical_today = datetime.now(SHANGHAI).date() review_end = end or logical_today review_start = start or review_end - timedelta(days=29) return repository.review_notes(review_start, review_end) @app.get("/api/tasks/{task_id}/history") async def history( task_id: str, start: Annotated[date | None, Query()] = None, end: Annotated[date | None, Query()] = None, ) -> dict: logical_today = datetime.now(SHANGHAI).date() history_end = end or logical_today history_start = start or history_end - timedelta(days=364) return repository.history(task_id, history_start, history_end) @app.get("/api/export") async def export_data() -> JSONResponse: return JSONResponse( repository.export_all(), headers={"Content-Disposition": "attachment; filename=progress-export.json"}, ) @app.post("/api/admin/archive") async def run_archive() -> dict: return {"closedDays": repository.close_missed_days()} @app.get("/api/admin/stats") async def admin_stats() -> dict: return repository.stats() @app.post("/api/admin/import") async def admin_import(payload: dict) -> dict: count = repository.import_all(payload) return {"imported": count} return app app = create_app()