diff --git a/api/app/config/default_free_plan.py b/api/app/config/default_free_plan.py index 23a3a10e..409b4f7b 100644 --- a/api/app/config/default_free_plan.py +++ b/api/app/config/default_free_plan.py @@ -1,30 +1,77 @@ """ 社区版默认免费套餐配置 当无法从 SaaS 版获取 premium 模块时,使用此配置作为兜底 + +可通过环境变量覆盖配额配置,格式:QUOTA_ +例如:QUOTA_END_USER_QUOTA=100 """ -DEFAULT_FREE_PLAN = { - "name": "记忆体验版", - "category": "saas_personal", - "tier_level": 0, - "version": "1.0", - "status": True, - "price": 0, - "billing_cycle": "permanent_free", - "core_value": "感受永久记忆", - "tech_support": "社群交流", - "sla_compliance": "无", - "page_customization": "无", - "theme_color": "#64748B", - "quotas": { - "workspace_quota": 1, - "skill_quota": 5, - "app_quota": 2, - "knowledge_capacity_quota": 0.3, - "memory_engine_quota": 1, - "end_user_quota": 1, - "ontology_project_quota": 3, - "model_quota": 1, - "api_ops_rate_limit": 50, - }, -} +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", + "status": True, + "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, + "skill_quota": 5, + "app_quota": 2, + "knowledge_capacity_quota": 0.3, + "memory_engine_quota": 1, + "end_user_quota": 1, + "ontology_project_quota": 3, + "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() diff --git a/api/app/controllers/__init__.py b/api/app/controllers/__init__.py index 377205c4..e9417d68 100644 --- a/api/app/controllers/__init__.py +++ b/api/app/controllers/__init__.py @@ -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"] diff --git a/api/app/controllers/app_controller.py b/api/app/controllers/app_controller.py index 34449bb5..3d97f2a2 100644 --- a/api/app/controllers/app_controller.py +++ b/api/app/controllers/app_controller.py @@ -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( diff --git a/api/app/controllers/public_share_controller.py b/api/app/controllers/public_share_controller.py index ddd31071..049535b5 100644 --- a/api/app/controllers/public_share_controller.py +++ b/api/app/controllers/public_share_controller.py @@ -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), diff --git a/api/app/controllers/tenant_subscription_controller.py b/api/app/controllers/tenant_subscription_controller.py index c3fde572..141d48a8 100644 --- a/api/app/controllers/tenant_subscription_controller.py +++ b/api/app/controllers/tenant_subscription_controller.py @@ -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: - return success(data=None, msg="暂无有效套餐") + # 无订阅记录时,兜底返回免费套餐信息 + 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="获取套餐列表失败")) diff --git a/api/app/core/quota_manager.py b/api/app/core/quota_manager.py index 6c02ac7a..0e0053a0 100644 --- a/api/app/core/quota_manager.py +++ b/api/app/core/quota_manager.py @@ -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 diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index 534ab8d0..64651189 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -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 配置模板(不保存到数据库) diff --git a/api/app/services/knowledge_service.py b/api/app/services/knowledge_service.py index bac02e96..94653db8 100644 --- a/api/app/services/knowledge_service.py +++ b/api/app/services/knowledge_service.py @@ -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() @@ -60,13 +63,57 @@ def create_knowledge( db: Session, knowledge: KnowledgeCreate, current_user: User ) -> Knowledge: business_logger.info(f"Create a knowledge base: {knowledge.name}, creator: {current_user.username}") - + try: knowledge.created_by = current_user.id if knowledge.workspace_id is None: 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