from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, ConfigDict from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.deps import require_staff from app.db import get_db from app.models.content import SiteSetting from app.models.security import AdminAuditLog, AdminIpBlacklist from app.services.captcha import issue_captcha from app.services.login_guard import ( LOGIN_LOCK_MINUTES_KEY, LOGIN_MAX_FAILURES_KEY, read_login_policy, ) public_router = APIRouter() router = APIRouter(dependencies=[Depends(require_staff)]) class LoginSecurityBody(BaseModel): model_config = ConfigDict(strict=True) loginMaxFailures: int loginLockMinutes: int class IpBlacklistBody(BaseModel): model_config = ConfigDict(strict=True) ip: str remark: str = "" def _naive(value: datetime) -> datetime: if value.tzinfo is None: return value return value.astimezone(timezone.utc).replace(tzinfo=None) def _iso(value: datetime | None) -> str | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") def _save_setting(db: Session, key: str, value: int) -> None: row = db.scalar(select(SiteSetting).where(SiteSetting.key == key)) text = str(value) if row is None: db.add(SiteSetting(key=key, value=text)) else: row.value = text def _ip_item(row: AdminIpBlacklist) -> dict: return {"id": row.id, "ip": row.ip, "remark": row.remark, "createdAt": _iso(row.created_at)} def _audit_item(row: AdminAuditLog) -> dict: return { "id": row.id, "userId": row.user_id, "username": row.username, "method": row.method, "path": row.path, "statusCode": row.status_code, "ip": row.ip, "createdAt": _iso(row.created_at), } @public_router.get("/auth/captcha") def get_captcha() -> dict: return issue_captcha() @router.get("/login-security") def get_login_security(db: Session = Depends(get_db)) -> dict: failures, minutes = read_login_policy(db) return {"loginMaxFailures": failures, "loginLockMinutes": minutes} @router.put("/login-security") def update_login_security(body: LoginSecurityBody, db: Session = Depends(get_db)) -> dict: if body.loginMaxFailures <= 0 or body.loginLockMinutes <= 0: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须是正整数") _save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures) _save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes) try: db.commit() except IntegrityError: db.rollback() _save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures) _save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes) db.commit() failures, minutes = read_login_policy(db) return {"loginMaxFailures": failures, "loginLockMinutes": minutes} @router.get("/ip-blacklist") def list_ip_blacklist(db: Session = Depends(get_db)) -> list[dict]: rows = db.scalars(select(AdminIpBlacklist).order_by(AdminIpBlacklist.id.asc())).all() return [_ip_item(row) for row in rows] @router.post("/ip-blacklist") def create_ip_blacklist(body: IpBlacklistBody, db: Session = Depends(get_db)) -> dict: ip = body.ip.strip() remark = body.remark.strip() if not ip or len(ip) > 64 or any(char.isspace() for char in ip): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 无效") if len(remark) > 255: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备注过长") if db.scalar(select(AdminIpBlacklist.id).where(AdminIpBlacklist.ip == ip)) is not None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在") row = AdminIpBlacklist(ip=ip, remark=remark) db.add(row) try: db.commit() except IntegrityError: db.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在") db.refresh(row) return _ip_item(row) @router.delete("/ip-blacklist/{id}") def delete_ip_blacklist(id: int, db: Session = Depends(get_db)) -> dict: row = db.get(AdminIpBlacklist, id) if row is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在") db.delete(row) db.commit() return {"id": id} @router.get("/audit-logs") def list_audit_logs( current: int = Query(1, ge=1), size: int = Query(20, ge=1, le=200), username: str | None = None, startTime: datetime | None = None, endTime: datetime | None = None, db: Session = Depends(get_db), ) -> dict: filters = [] if username and username.strip(): escaped = username.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") filters.append(AdminAuditLog.username.like(f"%{escaped}%", escape="\\")) if startTime is not None: filters.append(AdminAuditLog.created_at >= _naive(startTime)) if endTime is not None: filters.append(AdminAuditLog.created_at <= _naive(endTime)) count_stmt = select(func.count()).select_from(AdminAuditLog) list_stmt = select(AdminAuditLog) if filters: count_stmt = count_stmt.where(*filters) list_stmt = list_stmt.where(*filters) total = int(db.scalar(count_stmt) or 0) rows = db.scalars( list_stmt.order_by(AdminAuditLog.created_at.desc(), AdminAuditLog.id.desc()) .offset((current - 1) * size) .limit(size) ).all() return { "records": [_audit_item(row) for row in rows], "current": current, "size": size, "total": total, }