feat: Add quota check decorator and implement tenant-level API rate limiting

- Add quota check decorator module quota_stub.py, providing community edition stub implementation
- Add quota check decorators to multiple controllers
- Implement tenant-level API call rate limiting
- Remove redundant plan fields from tenant_model.py
- Optimize user permission check logic with added error handling
This commit is contained in:
wxy
2026-04-13 11:58:14 +08:00
parent 807dee8460
commit 72be9f75f9
15 changed files with 140 additions and 10 deletions

View File

@@ -28,6 +28,7 @@ from app.services.app_statistics_service import AppStatisticsService
from app.services.workflow_import_service import WorkflowImportService
from app.services.workflow_service import WorkflowService, get_workflow_service
from app.services.app_dsl_service import AppDslService
from app.core.quota_stub import check_app_quota
router = APIRouter(prefix="/apps", tags=["Apps"])
logger = get_business_logger()
@@ -35,6 +36,7 @@ logger = get_business_logger()
@router.post("", summary="创建应用(可选创建 Agent 配置)")
@cur_workspace_access_guard()
@check_app_quota
def create_app(
payload: app_schema.AppCreate,
db: Session = Depends(get_db),

View File

@@ -19,6 +19,7 @@ from app.models.user_model import User
from app.schemas import file_schema, document_schema
from app.schemas.response_schema import ApiResponse
from app.services import file_service, document_service
from app.core.quota_stub import check_knowledge_capacity_quota
# Obtain a dedicated API logger
@@ -131,6 +132,7 @@ async def create_folder(
@router.post("/file", response_model=ApiResponse)
@check_knowledge_capacity_quota
async def upload_file(
kb_id: uuid.UUID,
parent_id: uuid.UUID,

View File

@@ -27,6 +27,7 @@ from app.schemas import knowledge_schema
from app.schemas.response_schema import ApiResponse
from app.services import knowledge_service, document_service
from app.services.model_service import ModelConfigService
from app.core.quota_stub import check_knowledge_capacity_quota
# Obtain a dedicated API logger
api_logger = get_api_logger()
@@ -179,6 +180,7 @@ async def get_knowledges(
@router.post("/knowledge", response_model=ApiResponse)
@check_knowledge_capacity_quota
async def create_knowledge(
create_data: knowledge_schema.KnowledgeCreate,
db: Session = Depends(get_db),

View File

@@ -34,6 +34,7 @@ from app.services.memory_storage_service import (
search_entity,
search_statement,
)
from app.core.quota_stub import check_memory_engine_quota
from fastapi import APIRouter, Depends, Header
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -76,6 +77,7 @@ async def get_storage_info(
@router.post("/create_config", response_model=ApiResponse) # 创建配置文件,其他参数默认
@check_memory_engine_quota
def create_config(
payload: ConfigParamsCreate,
current_user: User = Depends(get_current_user),

View File

@@ -15,6 +15,7 @@ from app.core.response_utils import success
from app.schemas.response_schema import ApiResponse, PageData
from app.services.model_service import ModelConfigService, ModelApiKeyService, ModelBaseService
from app.core.logging_config import get_api_logger
from app.core.quota_stub import check_model_quota, check_model_activation_quota
# 获取API专用日志器
api_logger = get_api_logger()
@@ -236,6 +237,7 @@ def delete_model_base(
@router.post("/model_plaza/{model_base_id}/add", response_model=ApiResponse)
@check_model_quota
def add_model_from_plaza(
model_base_id: uuid.UUID,
db: Session = Depends(get_db),
@@ -273,6 +275,7 @@ def get_model_by_id(
@router.post("", response_model=ApiResponse)
@check_model_quota
async def create_model(
model_data: model_schema.ModelConfigCreate,
db: Session = Depends(get_db),
@@ -303,6 +306,7 @@ async def create_model(
@router.post("/composite", response_model=ApiResponse)
@check_model_quota
async def create_composite_model(
model_data: model_schema.CompositeModelCreate,
db: Session = Depends(get_db),
@@ -329,6 +333,7 @@ async def create_composite_model(
@router.put("/composite/{model_id}", response_model=ApiResponse)
@check_model_activation_quota
async def update_composite_model(
model_id: uuid.UUID,
model_data: model_schema.CompositeModelCreate,
@@ -370,6 +375,7 @@ def delete_composite_model(
@router.put("/{model_id}", response_model=ApiResponse)
@check_model_activation_quota
def update_model(
model_id: uuid.UUID,
model_data: model_schema.ModelConfigUpdate,

View File

@@ -28,6 +28,8 @@ from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form, H
from fastapi.responses import StreamingResponse, JSONResponse
from sqlalchemy.orm import Session
from app.core.quota_stub import check_ontology_project_quota
from app.core.config import settings
from app.core.error_codes import BizCode
from app.core.language_utils import get_language_from_header
@@ -287,6 +289,7 @@ async def extract_ontology(
# ==================== 本体场景管理接口 ====================
@router.post("/scene", response_model=ApiResponse)
@check_ontology_project_quota
async def create_scene(
request: SceneCreateRequest,
db: Session = Depends(get_db),

View File

@@ -9,6 +9,7 @@ from app.core.api_key_auth import require_api_key
from app.core.error_codes import BizCode
from app.core.exceptions import BusinessException
from app.core.logging_config import get_business_logger
from app.core.quota_stub import check_end_user_quota
from app.core.response_utils import success
from app.db import get_db
from app.repositories.end_user_repository import EndUserRepository
@@ -22,6 +23,7 @@ logger = get_business_logger()
@router.post("/create")
@require_api_key(scopes=["memory"])
@check_end_user_quota
async def create_end_user(
request: Request,
api_key_auth: ApiKeyAuth = None,

View File

@@ -2,6 +2,7 @@
from app.core.api_key_auth import require_api_key
from app.core.logging_config import get_business_logger
from app.core.quota_stub import check_end_user_quota
from app.core.response_utils import success
from app.db import get_db
from app.schemas.api_key_schema import ApiKeyAuth
@@ -119,6 +120,7 @@ async def list_memory_configs(
@router.post("/end_users")
@require_api_key(scopes=["memory"])
@check_end_user_quota
async def create_end_user(
request: Request,
api_key_auth: ApiKeyAuth = None,

View File

@@ -11,11 +11,13 @@ from app.schemas import skill_schema
from app.schemas.response_schema import PageData, PageMeta
from app.services.skill_service import SkillService
from app.core.response_utils import success
from app.core.quota_stub import check_skill_quota
router = APIRouter(prefix="/skills", tags=["Skills"])
@router.post("", summary="创建技能")
@check_skill_quota
def create_skill(
data: skill_schema.SkillCreate,
db: Session = Depends(get_db),

View File

@@ -114,11 +114,14 @@ def get_current_user_info(
# 设置权限:如果用户来自 SSO Source则使用该 Source 的 permissions否则返回 "all" 表示拥有所有权限
if current_user.external_source:
from premium.sso.models import SSOSource
source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first()
if source and source.permissions:
result_schema.permissions = source.permissions
else:
try:
from premium.sso.models import SSOSource
source = db.query(SSOSource).filter(SSOSource.source_code == current_user.external_source).first()
if source and source.permissions:
result_schema.permissions = source.permissions
else:
result_schema.permissions = []
except ModuleNotFoundError:
result_schema.permissions = []
else:
result_schema.permissions = ["all"]

View File

@@ -35,6 +35,7 @@ from app.schemas.workspace_schema import (
WorkspaceUpdate,
)
from app.services import workspace_service
from app.core.quota_stub import check_workspace_quota
# 获取API专用日志器
api_logger = get_api_logger()
@@ -106,6 +107,7 @@ def get_workspaces(
@router.post("", response_model=ApiResponse)
@check_workspace_quota
def create_workspace(
workspace: WorkspaceCreate,
language_type: str = Header(default="zh", alias="X-Language-Type"),

View File

@@ -96,6 +96,38 @@ def require_api_key(
resource_id=api_key_obj.resource_id,
)
# ── Tenant 级别限速(来自套餐配额 api_ops_rate_limit──────────
try:
from app.models.workspace_model import Workspace
from premium.platform_admin.package_plan_service import TenantSubscriptionService
workspace = db.query(Workspace).filter(
Workspace.id == api_key_obj.workspace_id
).first()
if workspace:
quota = TenantSubscriptionService(db).get_effective_quota(workspace.tenant_id)
tenant_qps_limit = quota.get("api_ops_rate_limit") if quota else None
if tenant_qps_limit:
rate_limiter = RateLimiterService()
tenant_ok, tenant_info = await rate_limiter.check_tenant_rate_limit(
workspace.tenant_id, tenant_qps_limit
)
if not tenant_ok:
raise RateLimitException(
"租户 API 调用速率超限",
BizCode.API_KEY_QPS_LIMIT_EXCEEDED,
rate_headers={
"X-RateLimit-Tenant-Limit": str(tenant_info["limit"]),
"X-RateLimit-Tenant-Remaining": str(tenant_info["remaining"]),
"X-RateLimit-Tenant-Reset": str(tenant_info["reset"]),
}
)
except RateLimitException:
raise
except Exception as e:
logger.warning(f"Tenant 限速检查异常,跳过: {e}")
# ─────────────────────────────────────────────────────────────
rate_limiter = RateLimiterService()
is_allowed, error_msg, rate_headers = await rate_limiter.check_all_limits(api_key_obj)
if not is_allowed:

View File

@@ -0,0 +1,44 @@
"""
配额检查 stub - 社区版使用,所有检查直接放行。
企业版通过 premium.platform_admin.quota_decorator 提供真实实现。
"""
from functools import wraps
from typing import Callable
def _noop_decorator(func: Callable) -> Callable:
"""空装饰器,直接放行"""
return func
def _noop_check(*args, **kwargs):
"""空检查函数,直接放行"""
pass
try:
from premium.platform_admin.quota_decorator import (
check_workspace_quota,
check_skill_quota,
check_app_quota,
check_knowledge_capacity_quota,
check_memory_engine_quota,
check_end_user_quota,
check_ontology_project_quota,
check_model_quota,
check_model_activation_quota,
get_quota_usage,
_check_quota,
)
except ModuleNotFoundError:
check_workspace_quota = _noop_decorator
check_skill_quota = _noop_decorator
check_app_quota = _noop_decorator
check_knowledge_capacity_quota = _noop_decorator
check_memory_engine_quota = _noop_decorator
check_end_user_quota = _noop_decorator
check_ontology_project_quota = _noop_decorator
check_model_quota = _noop_decorator
check_model_activation_quota = _noop_decorator
get_quota_usage = lambda db, tenant_id: {}
_check_quota = _noop_check

View File

@@ -29,11 +29,8 @@ class Tenants(Base):
contact_email = Column(String(255), nullable=True) # 联系人邮箱
contact_phone = Column(String(50), nullable=True) # 联系人电话
# 租户套餐信息
plan = Column(String(50), nullable=True) # 套餐类型
plan_expired_at = Column(DateTime, nullable=True) # 套餐到期时间
api_ops_rate_limit = Column(String(100), nullable=True) # API 调用频率限制
status = Column(String(50), nullable=True, default='active') # 租户状态
# 租户套餐信息(只读,从 tenant_subscriptions 动态获取)
status = Column(String(50), nullable=True, default='active', server_default='active') # 租户状态
# Relationship to users - one tenant has many users
users = relationship("User", back_populates="tenant")

View File

@@ -248,6 +248,35 @@ class RateLimiterService:
def __init__(self):
self.redis = aio_redis
async def check_tenant_rate_limit(self, tenant_id: uuid.UUID, limit: int) -> Tuple[bool, dict]:
"""
按 tenant_id 做 1 秒滑动窗口限速,限制值来自套餐配额 api_ops_rate_limit
"""
now = time.time()
window_start = now - 1 # 1 秒窗口
key = f"rate_limit:tenant_qps:{tenant_id}"
async with self.redis.pipeline() as pipe:
# 清理 1 秒前的旧记录
pipe.zremrangebyscore(key, 0, window_start)
# 加入当前请求score=时间戳member=时间戳+随机数保证唯一)
pipe.zadd(key, {f"{now}:{uuid.uuid4().hex}": now})
# 统计窗口内请求数
pipe.zcard(key)
# 设置 key 过期2 秒后自动清理)
pipe.expire(key, 2)
results = await pipe.execute()
current = results[2]
remaining = max(0, limit - current)
reset_time = int(now) + 1
return current <= limit, {
"limit": limit,
"remaining": remaining,
"reset": reset_time,
}
async def check_qps(self, api_key_id: uuid.UUID, limit: int) -> Tuple[bool, dict]:
"""
检查QPS限制