"""Quota 子域 Router。 外部系统限界上下文的配额管理 API,覆盖配额列表 / 详情 / 创建 / 更新 / 阈值查询 / 阈值设置 / 删除,以及扩展能力:按业务键查询 / 接近告警阈值列表 / 接近临界阈值列表 / 即将重置列表 / 统计聚合 / 重置窗口 / 批量调整 / 创建或更新 / 按系统批量删除。所有端点通过 ``create_use_cases_from_db`` 装配 use_cases,经 ``quota_service`` 端口调用用例。 Request Schema 与 Input DTO 不共享类,Router 内显式构造 DTO。 路径顺序约束:静态路径(``/thresholds`` / ``/by-key`` / ``/near-warning`` / ``/near-critical`` / ``/expiring`` / ``/stats`` / ``/batch-adjust`` / ``/upsert`` / ``/by-system/{system_id}``)必须在 ``/{quota_id}`` 前声明, 避免被路径参数捕获。 """ from __future__ import annotations from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession from yuxi.external_systems.infrastructure.container import create_use_cases_from_db from yuxi.external_systems.use_cases.dto.quota import ( BatchAdjustQuotaInput, CreateQuotaInput, DeleteQuotaInput, DeleteQuotasBySystemInput, GetQuotaByKeyInput, GetQuotaInput, GetQuotaStatsInput, GetQuotaThresholdInput, ListExpiringInput, ListNearCriticalInput, ListNearWarningInput, ListQuotasInput, QuotaAdjustmentItem, ResetQuotaInput, SetQuotaThresholdInput, UpdateQuotaInput, UpsertQuotaInput, ) from yuxi.storage.postgres.models_business import User from server.utils.auth_middleware import get_admin_user, get_db, get_required_user quota_router = APIRouter(prefix="/quotas", tags=["external-systems-quota"]) # ---------------- Request Schemas ---------------- class CreateQuotaRequest(BaseModel): """创建配额请求体。字段对齐 ``CreateQuotaInput``。 字符串字段长度约束对齐 ``ExternalSystemQuotaUsage`` ORM 列定义,在边界层 拦截非法输入;``window_start``/``window_end`` 使用 datetime 类型,由 Pydantic 解析 ISO 8601 字符串;阈值字段约束 0-100,与 ``SetQuotaThresholdRequest`` 保持一致。 """ model_config = ConfigDict(frozen=True) system_id: int quota_key: str = Field(..., min_length=1, max_length=128) quota_name: str = Field(..., min_length=1, max_length=128) quota_window: str = Field(..., min_length=1, max_length=32) env_key: str = Field(default="default", min_length=1, max_length=32) window_start: datetime | None = None window_end: datetime | None = None limit_value: int = Field(default=0, ge=0) used_value: int = Field(default=0, ge=0) unit: str = Field(default="calls", min_length=1, max_length=32) source: str = Field(default="config", min_length=1, max_length=32) source_header_name: str | None = Field(default=None, max_length=128) warning_threshold: int = Field(default=80, ge=0, le=100) critical_threshold: int = Field(default=95, ge=0, le=100) class SetQuotaThresholdRequest(BaseModel): """设置配额阈值请求体。字段对齐 ``SetQuotaThresholdInput``(不含 id)。 仅透传客户端显式设置的字段。 """ model_config = ConfigDict(frozen=True) warning_threshold: int | None = Field(default=None, ge=0, le=100) critical_threshold: int | None = Field(default=None, ge=0, le=100) class UpdateQuotaRequest(BaseModel): """更新配额请求体。字段对齐 ``UpdateQuotaInput``(不含 id)。 所有字段可选,支持部分更新;约束与 ``CreateQuotaRequest`` 保持一致, 在边界层拦截非法输入。仅透传客户端显式设置的字段。 """ model_config = ConfigDict(frozen=True) quota_name: str | None = Field(default=None, min_length=1, max_length=128) limit_value: int | None = Field(default=None, ge=0) used_value: int | None = Field(default=None, ge=0) unit: str | None = Field(default=None, min_length=1, max_length=32) source: str | None = Field(default=None, min_length=1, max_length=32) source_header_name: str | None = Field(default=None, max_length=128) warning_threshold: int | None = Field(default=None, ge=0, le=100) critical_threshold: int | None = Field(default=None, ge=0, le=100) window_start: datetime | None = None window_end: datetime | None = None class ResetQuotaRequest(BaseModel): """重置配额窗口请求体。字段对齐 ``ResetQuotaInput``(不含 id)。""" model_config = ConfigDict(frozen=True) window_start: datetime | None = None window_end: datetime | None = None used_value: int = Field(default=0, ge=0) class QuotaAdjustmentItemRequest(BaseModel): """批量调整项请求体(对齐 ``QuotaAdjustmentItem`` DTO)。""" model_config = ConfigDict(frozen=True) quota_id: int = Field(..., ge=1) limit_value: int = Field(..., ge=0) reason: str | None = Field(default=None, max_length=500) class BatchAdjustQuotaRequest(BaseModel): """批量调整配额限额请求体。""" model_config = ConfigDict(frozen=True) adjustments: list[QuotaAdjustmentItemRequest] = Field(..., min_length=1, max_length=100) class UpsertQuotaRequest(BaseModel): """创建或更新配额请求体。字段对齐 ``UpsertQuotaInput``。""" model_config = ConfigDict(frozen=True) system_id: int quota_key: str = Field(..., min_length=1, max_length=128) quota_name: str = Field(..., min_length=1, max_length=128) quota_window: str = Field(..., min_length=1, max_length=32) env_key: str = Field(default="default", min_length=1, max_length=32) window_start: datetime | None = None window_end: datetime | None = None limit_value: int = Field(default=0, ge=0) used_value: int = Field(default=0, ge=0) unit: str = Field(default="calls", min_length=1, max_length=32) source: str = Field(default="config", min_length=1, max_length=32) source_header_name: str | None = Field(default=None, max_length=128) warning_threshold: int = Field(default=80, ge=0, le=100) critical_threshold: int = Field(default=95, ge=0, le=100) # ---------------- Endpoints ---------------- # ---- 静态路径端点(必须在 /{quota_id} 之前声明) ---- @quota_router.get("/thresholds", response_model=dict) async def get_quota_threshold( system_id: int = Query(...), env_key: str | None = Query(None), quota_key: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取达到告警阈值的配额列表(含临界)。 ``system_id`` 为必填过滤维度,对齐 ``GetQuotaThresholdInput`` 契约。 """ use_cases = create_use_cases_from_db(db) input_dto = GetQuotaThresholdInput( system_id=system_id, env_key=env_key, quota_key=quota_key, ) output = await use_cases.quota_service.get_quota_threshold(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.get("/by-key", response_model=dict) async def get_quota_by_key( system_id: int = Query(...), env_key: str = Query("default"), quota_key: str = Query(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """按业务键查询配额。无数据返回 null。 探测性查询语义:配额可能尚未配置,调用方需自行处理 null。与按 ID 查询(``GET /{quota_id}``,不存在抛 404)的语义不同。 """ use_cases = create_use_cases_from_db(db) input_dto = GetQuotaByKeyInput( system_id=system_id, env_key=env_key, quota_key=quota_key, ) output = await use_cases.quota_service.get_quota_by_key(input_dto) return {"success": True, "data": output.model_dump() if output else None} @quota_router.get("/near-warning", response_model=dict) async def list_near_warning( limit: int = Query(100, ge=1, le=500, description="返回记录数上限"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """列出接近告警阈值的配额(全局,含临界)。 与 ``GET /thresholds`` 的区别:本端点为全局查询(无 ``system_id`` 过滤),用于跨系统告警大盘。 """ use_cases = create_use_cases_from_db(db) input_dto = ListNearWarningInput(limit=limit) output = await use_cases.quota_service.list_near_warning(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.get("/near-critical", response_model=dict) async def list_near_critical( limit: int = Query(100, ge=1, le=500, description="返回记录数上限"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """列出接近临界阈值的配额(全局,无 ``system_id`` 过滤)。""" use_cases = create_use_cases_from_db(db) input_dto = ListNearCriticalInput(limit=limit) output = await use_cases.quota_service.list_near_critical(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.get("/expiring", response_model=dict) async def list_expiring( before: datetime = Query(..., description="ISO8601 截止时间,返回 window_end <= before 的配额"), system_id: int | None = Query(None, description="按系统过滤(可选)"), limit: int = Query(100, ge=1, le=500, description="返回记录数上限"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """列出即将重置的配额(``window_end <= before``)。""" use_cases = create_use_cases_from_db(db) input_dto = ListExpiringInput( before=before, system_id=system_id, limit=limit, ) output = await use_cases.quota_service.list_expiring(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.get("/stats", response_model=dict) async def get_quota_stats( system_id: int | None = Query(None, description="按系统过滤(可选,不传则全局聚合)"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """配额统计聚合。 所有字段均为精确值,统一复用仓储 ``count`` 方法(支持 ``system_id`` 过滤):``total`` / ``by_window`` / ``near_warning_count`` / ``near_critical_count`` / ``expiring_count``(窗口已结束,需重置)。 """ use_cases = create_use_cases_from_db(db) input_dto = GetQuotaStatsInput(system_id=system_id) output = await use_cases.quota_service.get_quota_stats(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.post("/batch-adjust", response_model=dict) async def batch_adjust_quotas( payload: BatchAdjustQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量调整配额限额。``updated_by`` 由 ``current_user.uid`` 填充 (对齐 ``batch_enable_environments``,批量更新操作使用 ``updated_by``)。""" use_cases = create_use_cases_from_db(db) input_dto = BatchAdjustQuotaInput( adjustments=[ QuotaAdjustmentItem( quota_id=item.quota_id, limit_value=item.limit_value, reason=item.reason, ) for item in payload.adjustments ], updated_by=current_user.uid, ) output = await use_cases.quota_service.batch_adjust_quotas(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.post("/upsert", response_model=dict) async def upsert_quota( payload: UpsertQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """创建或更新配额(基于 ``system_id+env_key+quota_key`` 冲突时更新)。""" use_cases = create_use_cases_from_db(db) input_dto = UpsertQuotaInput(**payload.model_dump(), created_by=current_user.uid) output = await use_cases.quota_service.upsert_quota(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.delete("/by-system/{system_id}", response_model=dict) async def delete_quotas_by_system( system_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """按系统批量软删除配额。""" use_cases = create_use_cases_from_db(db) input_dto = DeleteQuotasBySystemInput(system_id=system_id, user=current_user.uid) output = await use_cases.quota_service.delete_quotas_by_system(input_dto) return {"success": True, "data": output.model_dump()} # ---- 根路径端点 ---- @quota_router.get("", response_model=dict) async def list_quotas( limit: int = Query(100, ge=1, le=500, description="返回记录数上限"), offset: int = Query(0, ge=0, description="偏移量(与 limit 配合分页)"), system_id: int | None = Query(None, description="按系统过滤"), env_key: str | None = Query(None, description="按环境键过滤"), quota_key: str | None = Query(None, description="按配额键过滤"), quota_window: str | None = Query(None, description="按窗口类型过滤(day/month/total 等)"), source: str | None = Query(None, description="按来源过滤(config/response_header 等)"), near_warning: bool = Query(False, description="仅返回接近告警阈值的配额"), near_critical: bool = Query(False, description="仅返回接近临界阈值的配额"), expiring_before: datetime | None = Query(None, description="仅返回 window_end <= 该时间的配额"), keyword: str | None = Query(None, description="按 quota_name / quota_key 模糊匹配"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """分页列出配额。支持多维度过滤。""" use_cases = create_use_cases_from_db(db) input_dto = ListQuotasInput( limit=limit, offset=offset, system_id=system_id, env_key=env_key, quota_key=quota_key, quota_window=quota_window, source=source, near_warning=near_warning, near_critical=near_critical, expiring_before=expiring_before, keyword=keyword, ) output = await use_cases.quota_service.list_quotas(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.post("", response_model=dict) async def create_quota( payload: CreateQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """创建配额。``created_by`` 由 ``current_user.uid`` 填充。""" use_cases = create_use_cases_from_db(db) input_dto = CreateQuotaInput(**payload.model_dump(), created_by=current_user.uid) output = await use_cases.quota_service.create_quota(input_dto) return {"success": True, "data": output.model_dump()} # ---- 动态路径端点 /{quota_id} ---- @quota_router.get("/{quota_id}", response_model=dict) async def get_quota( quota_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ) -> dict[str, Any]: """获取配额详情。""" use_cases = create_use_cases_from_db(db) input_dto = GetQuotaInput(id=quota_id) output = await use_cases.quota_service.get_quota(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.put("/{quota_id}", response_model=dict) async def update_quota( quota_id: int, payload: UpdateQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """更新配额(部分更新)。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。""" use_cases = create_use_cases_from_db(db) input_dto = UpdateQuotaInput( id=quota_id, **payload.model_dump(exclude_unset=True), updated_by=current_user.uid, ) output = await use_cases.quota_service.update_quota(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.patch("/{quota_id}/threshold", response_model=dict) async def set_quota_threshold( quota_id: int, payload: SetQuotaThresholdRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """设置配额阈值。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。""" use_cases = create_use_cases_from_db(db) input_dto = SetQuotaThresholdInput( id=quota_id, **payload.model_dump(exclude_unset=True), updated_by=current_user.uid, ) output = await use_cases.quota_service.set_quota_threshold(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.post("/{quota_id}/reset", response_model=dict) async def reset_quota( quota_id: int, payload: ResetQuotaRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """重置配额窗口。仅透传显式设置的字段。``updated_by`` 由 ``current_user.uid`` 填充。""" use_cases = create_use_cases_from_db(db) input_dto = ResetQuotaInput( id=quota_id, **payload.model_dump(exclude_unset=True), updated_by=current_user.uid, ) output = await use_cases.quota_service.reset_quota(input_dto) return {"success": True, "data": output.model_dump()} @quota_router.delete("/{quota_id}", response_model=dict) async def delete_quota( quota_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """删除配额。``user`` 由 ``current_user.uid`` 填充。""" use_cases = create_use_cases_from_db(db) input_dto = DeleteQuotaInput(id=quota_id, user=current_user.uid) output = await use_cases.quota_service.delete_quota(input_dto) return {"success": True, "data": output.model_dump()}