from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi import File, UploadFile from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession from pathlib import Path import uuid from app.core.config import settings from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.deps import get_current_user, get_db_session from app.crud import user as user_crud from app.models.user import UserRole, UserStatus from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from fastapi.responses import FileResponse class LoginRequest(BaseModel): email: EmailStr password: str = Field(min_length=1) class ExtendResponse(BaseModel): accessToken: str expiresAt: datetime class UnlockRequest(BaseModel): email: EmailStr password: str = Field(min_length=1) class UnlockResponse(BaseModel): accessToken: str expiresAt: datetime router = APIRouter() AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars" AVATAR_ROOT.mkdir(parents=True, exist_ok=True) @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( payload: UserRegisterRequest, db: AsyncSession = Depends(get_db_session), ): if payload.role == UserRole.ADMIN.value: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许注册管理员账号") existing = await user_crud.get_by_email(db, payload.email) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="邮箱已注册") await user_crud.create_pending_user(db, payload) return {"message": "注册成功,等待管理员审核"} @router.post("/login", response_model=Token) async def login_for_access_token( payload: LoginRequest, db: AsyncSession = Depends(get_db_session) ) -> Token: db_user = await user_crud.get_by_email(db, payload.email) if not db_user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在", ) if not verify_password(payload.password, db_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误", ) if db_user.status != UserStatus.ACTIVE: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号未审核或不可用", ) session_start = datetime.now(timezone.utc) access_token = create_access_token( user_id=str(db_user.id), role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) return Token(access_token=access_token, token_type="bearer") @router.get("/me", response_model=UserRead) async def read_me(current_user=Depends(get_current_user)) -> UserRead: return current_user @router.post("/extend", response_model=ExtendResponse) async def extend_access_token( token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db_session), ) -> ExtendResponse: payload = decode_token_allow_expired(token) exp = payload.get("exp") if not exp: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") now_ts = int(datetime.now(timezone.utc).timestamp()) if now_ts > int(exp) + settings.JWT_EXTEND_GRACE_SECONDS: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无法验证登录凭据") db_user = await user_crud.get_by_id(db, uuid.UUID(str(user_id))) if not db_user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用") if db_user.status != UserStatus.ACTIVE: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") session_start_ts = payload.get("orig_iat") or payload.get("iat") if session_start_ts: max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600 if now_ts - int(session_start_ts) > max_seconds: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录") session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc) else: session_start = datetime.now(timezone.utc) new_token = create_access_token( user_id=str(db_user.id), role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) return ExtendResponse(accessToken=new_token, expiresAt=expires_at) @router.post("/unlock", response_model=UnlockResponse) async def unlock_session( payload: UnlockRequest, db: AsyncSession = Depends(get_db_session), ) -> UnlockResponse: db_user = await user_crud.get_by_email(db, payload.email) if not db_user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在") if not verify_password(payload.password, db_user.password_hash): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误") if db_user.status != UserStatus.ACTIVE: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") session_start = datetime.now(timezone.utc) access_token = create_access_token( user_id=str(db_user.id), role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, expires_minutes=None, session_start=session_start, ) expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) return UnlockResponse(accessToken=access_token, expiresAt=expires_at) @router.patch("/me", response_model=UserRead) async def update_me( payload: UserSelfUpdate, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ) -> UserRead: if payload.password: if not payload.current_password or not verify_password(payload.current_password, current_user.password_hash): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确") update_data = { "full_name": payload.full_name if payload.full_name is not None else current_user.full_name, "department": payload.department if payload.department is not None else current_user.department, "password": payload.password if payload.password else None, "avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url, } updated = await user_crud.update_user(db, current_user, UserUpdate(**update_data)) return updated @router.post("/me/avatar", response_model=UserRead) async def upload_avatar( file: UploadFile = File(...), current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ) -> UserRead: AVATAR_ROOT.mkdir(parents=True, exist_ok=True) user_dir = AVATAR_ROOT / str(current_user.id) user_dir.mkdir(parents=True, exist_ok=True) ext = Path(file.filename).suffix or ".png" filename = f"{uuid.uuid4()}{ext}" dest = user_dir / filename content = await file.read() dest.write_bytes(content) # Optionally clean old files for old in user_dir.iterdir(): if old.name != filename: try: old.unlink() except OSError: pass rel_url = f"/api/v1/auth/avatar/{current_user.id}/{filename}" updated = await user_crud.update_user( db, current_user, UserUpdate(avatar_url=rel_url), ) return updated @router.get("/avatar/{user_id}/{filename}") async def get_avatar(user_id: str, filename: str): file_path = AVATAR_ROOT / user_id / filename if not file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="头像不存在") return FileResponse(file_path)