Files
sentinel/app/api/bindings.py
chy88 ab1bd90c65 feat(core): 初始化 Key-IP Sentinel 服务与部署骨架
- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构
- 实现反向代理、首用绑定、拦截告警、归档任务和管理接口
- 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
2026-03-04 00:18:33 +08:00

154 lines
5.5 KiB
Python

from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import String, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings
from app.core.ip_utils import extract_client_ip
from app.dependencies import get_binding_service, get_db_session, get_settings, require_admin
from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding
from app.schemas.binding import (
BindingActionRequest,
BindingIPUpdateRequest,
BindingItem,
BindingListResponse,
)
from app.services.binding_service import BindingService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api/bindings", tags=["bindings"], dependencies=[Depends(require_admin)])
def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> BindingItem:
return BindingItem(
id=binding.id,
token_display=binding.token_display,
bound_ip=str(binding.bound_ip),
status=binding.status,
status_label=binding_service.status_label(binding.status),
first_used_at=binding.first_used_at,
last_used_at=binding.last_used_at,
created_at=binding.created_at,
)
async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding:
binding = await session.get(TokenBinding, binding_id)
if binding is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Binding was not found.")
return binding
def log_admin_action(request: Request, settings: Settings, action: str, binding_id: int) -> None:
logger.info(
"Admin binding action.",
extra={
"client_ip": extract_client_ip(request, settings),
"action": action,
"binding_id": binding_id,
},
)
@router.get("", response_model=BindingListResponse)
async def list_bindings(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=200),
token_suffix: str | None = Query(default=None),
ip: str | None = Query(default=None),
status_filter: int | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
) -> BindingListResponse:
statement = select(TokenBinding)
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one())
bindings = (
await session.scalars(
statement.order_by(TokenBinding.last_used_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return BindingListResponse(
items=[to_binding_item(item, binding_service) for item in bindings],
total=total,
page=page,
page_size=page_size,
)
@router.post("/unbind")
async def unbind_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
token_hash = binding.token_hash
await session.delete(binding)
await session.commit()
await binding_service.invalidate_binding_cache(token_hash)
log_admin_action(request, settings, "unbind", payload.id)
return {"success": True}
@router.put("/ip")
async def update_bound_ip(
payload: BindingIPUpdateRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.bound_ip = payload.bound_ip
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "update_ip", payload.id)
return {"success": True}
@router.post("/ban")
async def ban_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_BANNED
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "ban", payload.id)
return {"success": True}
@router.post("/unban")
async def unban_token(
payload: BindingActionRequest,
request: Request,
settings: Settings = Depends(get_settings),
session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service),
):
binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_ACTIVE
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "unban", payload.id)
return {"success": True}