Merge pull request #920 from wanxunyang/feat/quota-check-decorator

feat(tenant): add public subscription plan list endpoint and enhance plan information
This commit is contained in:
山程漫悟
2026-04-17 11:47:34 +08:00
committed by GitHub
8 changed files with 268 additions and 29 deletions

View File

@@ -1,10 +1,44 @@
"""
社区版默认免费套餐配置
当无法从 SaaS 版获取 premium 模块时,使用此配置作为兜底
可通过环境变量覆盖配额配置格式QUOTA_<QUOTA_NAME>
例如QUOTA_END_USER_QUOTA=100
"""
DEFAULT_FREE_PLAN = {
import os
def _get_quota_from_env():
"""从环境变量获取配额配置"""
quota_keys = [
"workspace_quota",
"skill_quota",
"app_quota",
"knowledge_capacity_quota",
"memory_engine_quota",
"end_user_quota",
"ontology_project_quota",
"model_quota",
"api_ops_rate_limit",
]
quotas = {}
for key in quota_keys:
env_key = f"QUOTA_{key.upper()}"
env_value = os.getenv(env_key)
if env_value is not None:
try:
quotas[key] = float(env_value) if '.' in env_value else int(env_value)
except ValueError:
pass
return quotas
def _build_default_free_plan():
"""构建默认免费套餐配置"""
base = {
"name": "记忆体验版",
"name_en": "Memory Experience",
"category": "saas_personal",
"tier_level": 0,
"version": "1.0",
@@ -12,9 +46,13 @@ DEFAULT_FREE_PLAN = {
"price": 0,
"billing_cycle": "permanent_free",
"core_value": "感受永久记忆",
"core_value_en": "Experience Permanent Memory",
"tech_support": "社群交流",
"tech_support_en": "Community Support",
"sla_compliance": "",
"sla_compliance_en": "None",
"page_customization": "",
"page_customization_en": "None",
"theme_color": "#64748B",
"quotas": {
"workspace_quota": 1,
@@ -27,4 +65,13 @@ DEFAULT_FREE_PLAN = {
"model_quota": 1,
"api_ops_rate_limit": 50,
},
}
}
env_quotas = _get_quota_from_env()
if env_quotas:
base["quotas"].update(env_quotas)
return base
DEFAULT_FREE_PLAN = _build_default_free_plan()

View File

@@ -100,5 +100,6 @@ manager_router.include_router(ontology_controller.router)
manager_router.include_router(skill_controller.router)
manager_router.include_router(i18n_controller.router)
manager_router.include_router(tenant_subscription_controller.router)
manager_router.include_router(tenant_subscription_controller.public_router)
__all__ = ["manager_router"]

View File

@@ -271,6 +271,19 @@ def update_agent_config(
return success(data=app_schema.AgentConfig.model_validate(cfg))
@router.get("/{app_id}/model/parameters/default", summary="获取 Agent 模型参数默认配置")
@cur_workspace_access_guard()
def get_agent_model_parameters(
app_id: uuid.UUID,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
workspace_id = current_user.current_workspace_id
service = AppService(db)
model_parameters = service.get_default_model_parameters(app_id=app_id)
return success(data=model_parameters, msg="获取 Agent 模型参数默认配置")
@router.get("/{app_id}/config", summary="获取 Agent 配置")
@cur_workspace_access_guard()
def get_agent_config(

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
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_manager import check_end_user_quota
from app.core.response_utils import success, fail
from app.db import get_db, get_db_read
from app.dependencies import get_share_user_id, ShareTokenData
@@ -308,6 +309,7 @@ def get_conversation(
"/chat",
summary="发送消息(支持流式和非流式)"
)
@check_end_user_quota
async def chat(
payload: conversation_schema.ChatRequest,
share_data: ShareTokenData = Depends(get_share_user_id),

View File

@@ -2,7 +2,7 @@
租户套餐查询接口(普通用户可访问)
"""
import datetime
from typing import Callable
from typing import Callable, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
@@ -19,6 +19,7 @@ from app.schemas.response_schema import ApiResponse
logger = get_api_logger()
router = APIRouter(prefix="/tenant", tags=["Tenant"])
public_router = APIRouter(tags=["Tenant"])
@router.get("/subscription", response_model=ApiResponse, summary="获取当前用户所属租户的套餐信息")
@@ -42,7 +43,41 @@ async def get_my_tenant_subscription(
sub = svc.get_subscription(tenant_id)
if not sub:
# 无订阅记录时,兜底返回免费套餐信息
free_plan = svc.plan_repo.get_free_plan()
if not free_plan:
return success(data=None, msg="暂无有效套餐")
return success(data={
"subscription_id": None,
"tenant_id": str(tenant_id),
"package_plan_id": str(free_plan.id),
"package_version": free_plan.version,
"package_plan": {
"id": str(free_plan.id),
"name": free_plan.name,
"name_en": free_plan.name_en,
"version": free_plan.version,
"category": free_plan.category,
"tier_level": free_plan.tier_level,
"price": float(free_plan.price) if free_plan.price is not None else 0.0,
"billing_cycle": free_plan.billing_cycle,
"core_value": free_plan.core_value,
"core_value_en": free_plan.core_value_en,
"tech_support": free_plan.tech_support,
"tech_support_en": free_plan.tech_support_en,
"sla_compliance": free_plan.sla_compliance,
"sla_compliance_en": free_plan.sla_compliance_en,
"page_customization": free_plan.page_customization,
"page_customization_en": free_plan.page_customization_en,
"theme_color": free_plan.theme_color,
},
"started_at": None,
"expired_at": None,
"status": "active",
"quota": free_plan.quotas or {},
"created_at": int(datetime.datetime.utcnow().timestamp() * 1000),
"updated_at": int(datetime.datetime.utcnow().timestamp() * 1000),
}, msg="免费套餐")
return success(data=svc.build_response(sub))
@@ -62,11 +97,21 @@ async def get_my_tenant_subscription(
"package_plan": {
"id": None,
"name": plan["name"],
"name_en": plan.get("name_en"),
"version": plan["version"],
"category": plan["category"],
"tier_level": plan["tier_level"],
"price": float(plan["price"]),
"billing_cycle": plan["billing_cycle"],
"core_value": plan.get("core_value"),
"core_value_en": plan.get("core_value_en"),
"tech_support": plan.get("tech_support"),
"tech_support_en": plan.get("tech_support_en"),
"sla_compliance": plan.get("sla_compliance"),
"sla_compliance_en": plan.get("sla_compliance_en"),
"page_customization": plan.get("page_customization"),
"page_customization_en": plan.get("page_customization_en"),
"theme_color": plan.get("theme_color"),
},
"started_at": None,
"expired_at": None,
@@ -80,3 +125,49 @@ async def get_my_tenant_subscription(
except Exception as e:
logger.error(f"获取租户套餐信息失败: {e}", exc_info=True)
return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐信息失败"))
@public_router.get("/package-plans", response_model=ApiResponse, summary="获取套餐列表(公开)")
async def list_package_plans_public(
category: Optional[str] = None,
status: Optional[bool] = None,
search: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
公开接口,无需鉴权。
SaaS 版从数据库读取套餐列表;社区版降级返回 default_free_plan.py 中的免费套餐。
"""
try:
from premium.platform_admin.package_plan_service import PackagePlanService
from premium.platform_admin.package_plan_schema import PackagePlanResponse
svc = PackagePlanService(db)
result = svc.get_list(page=1, size=9999, category=category, status=status, search=search)
return success(data=[PackagePlanResponse.model_validate(p).model_dump(mode="json") for p in result["items"]])
except ModuleNotFoundError:
from app.config.default_free_plan import DEFAULT_FREE_PLAN
plan = DEFAULT_FREE_PLAN
return success(data=[{
"id": None,
"name": plan["name"],
"name_en": plan.get("name_en"),
"version": plan["version"],
"category": plan["category"],
"tier_level": plan["tier_level"],
"price": float(plan["price"]),
"billing_cycle": plan["billing_cycle"],
"core_value": plan.get("core_value"),
"core_value_en": plan.get("core_value_en"),
"tech_support": plan.get("tech_support"),
"tech_support_en": plan.get("tech_support_en"),
"sla_compliance": plan.get("sla_compliance"),
"sla_compliance_en": plan.get("sla_compliance_en"),
"page_customization": plan.get("page_customization"),
"page_customization_en": plan.get("page_customization_en"),
"theme_color": plan.get("theme_color"),
"status": plan.get("status", True),
"quota": plan["quotas"],
}])
except Exception as e:
logger.error(f"获取套餐列表失败: {e}", exc_info=True)
return JSONResponse(status_code=500, content=fail(code=500, msg="获取套餐列表失败"))

View File

@@ -55,6 +55,18 @@ def _get_tenant_id_from_kwargs(db: Session, kwargs: dict):
if workspace:
return workspace.tenant_id
share_data = kwargs.get("share_data")
if share_data and hasattr(share_data, 'share_token'):
from app.models.workspace_model import Workspace
from app.models.app_model import App
share_token = share_data.share_token
from app.models.release_share_model import ReleaseShare
share_record = db.query(ReleaseShare).filter(ReleaseShare.share_token == share_token).first()
if share_record:
app = db.query(App).filter(App.id == share_record.app_id, App.is_active.is_(True)).first()
if app:
return app.workspace.tenant_id
return None

View File

@@ -1452,6 +1452,32 @@ class AppService:
logger.debug("配置不存在,返回默认模板", extra={"app_id": str(app_id)})
return self._create_default_agent_config(app_id)
def get_default_model_parameters(
self,
*,
app_id: uuid.UUID,
) -> "ModelParameters":
"""获取 Agent 默认模型参数(不修改数据库)
Args:
app_id: 应用ID
Returns:
ModelParameters: 默认模型参数
"""
logger.info("获取 Agent 默认模型参数", extra={"app_id": str(app_id)})
app = self._get_app_or_404(app_id)
if app.type != "agent":
raise BusinessException("只有 Agent 类型应用支持 Agent 配置", BizCode.APP_TYPE_NOT_SUPPORTED)
from app.schemas.app_schema import ModelParameters
default_model_parameters = ModelParameters()
logger.info("获取 Agent 默认模型参数成功", extra={"app_id": str(app_id)})
return default_model_parameters
def _create_default_agent_config(self, app_id: uuid.UUID) -> AgentConfig:
"""创建默认的 Agent 配置模板(不保存到数据库)

View File

@@ -2,11 +2,14 @@ import uuid
from sqlalchemy.orm import Session
from app.models.user_model import User
from app.models.knowledge_model import Knowledge
from app.models.workspace_model import Workspace
from app.models.models_model import ModelConfig
from app.schemas.knowledge_schema import KnowledgeCreate, KnowledgeUpdate
from app.repositories import knowledge_repository
from app.core.logging_config import get_business_logger
from app.repositories.model_repository import ModelConfigRepository
from app.models.models_model import ModelType
# Obtain a dedicated logger for business logic
business_logger = get_business_logger()
@@ -67,6 +70,50 @@ def create_knowledge(
knowledge.workspace_id = current_user.current_workspace_id
if knowledge.parent_id is None:
knowledge.parent_id = knowledge.workspace_id
workspace = db.query(Workspace).filter(Workspace.id == knowledge.workspace_id).first()
if not workspace:
raise Exception(f"Workspace {knowledge.workspace_id} not found")
tenant_id = workspace.tenant_id
if not knowledge.embedding_id:
embedding_models = ModelConfigRepository.get_by_type(
db=db, model_types=[ModelType.EMBEDDING], tenant_id=tenant_id, is_active=True
)
if embedding_models:
knowledge.embedding_id = embedding_models[0].id
business_logger.debug(f"Auto-bind embedding model: {embedding_models[0].id}")
if not knowledge.reranker_id:
rerank_models = ModelConfigRepository.get_by_type(
db=db, model_types=[ModelType.RERANK], tenant_id=tenant_id, is_active=True
)
if rerank_models:
knowledge.reranker_id = rerank_models[0].id
business_logger.debug(f"Auto-bind rerank model: {rerank_models[0].id}")
if not knowledge.llm_id:
llm_models = ModelConfigRepository.get_by_type(
db=db, model_types=[ModelType.LLM, ModelType.CHAT], tenant_id=tenant_id, is_active=True
)
if llm_models:
knowledge.llm_id = llm_models[0].id
business_logger.debug(f"Auto-bind llm model: {llm_models[0].id}")
if not knowledge.image2text_id:
image2text_models = db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.type.in_([ModelType.CHAT.value]),
ModelConfig.capability.contains(["vision"]),
ModelConfig.is_active == True,
ModelConfig.is_composite == False
).order_by(ModelConfig.created_at.desc()).all()
if not image2text_models:
raise Exception("租户下没有可用的视觉模型,创建知识库失败")
knowledge.image2text_id = image2text_models[0].id
business_logger.debug(f"Auto-bind image2text model: {image2text_models[0].id}")
business_logger.debug(f"Start creating the knowledge base: {knowledge.name}")
db_knowledge = knowledge_repository.create_knowledge(
db=db, knowledge=knowledge