From 7134947c0c0efc3d2bbb16b04765dcd3a6b3a795 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 27 Jan 2026 11:30:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=85=8D=E7=BD=AE=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 platform_tool_configs 表和 ToolConfig Model - 新增工具配置 CRUD API (/api/tool-configs) - 租户详情页添加工具配置管理 Tab - 修复查看 Token 显示问题,添加专用获取接口 --- backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/tool_config.py | 21 + backend/app/routers/tenant_apps.py | 21 + backend/app/routers/tool_configs.py | 391 ++++++++++++++++++ frontend/src/views/app-config/index.vue | 11 +- frontend/src/views/tenants/detail.vue | 508 +++++++++++++++++++++--- 7 files changed, 901 insertions(+), 55 deletions(-) create mode 100644 backend/app/models/tool_config.py create mode 100644 backend/app/routers/tool_configs.py diff --git a/backend/app/main.py b/backend/app/main.py index f7b094c..3ef47c0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from .routers.wechat import router as wechat_router from .routers.alerts import router as alerts_router from .routers.cost import router as cost_router from .routers.quota import router as quota_router +from .routers.tool_configs import router as tool_configs_router from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware from .middleware.trace import setup_logging @@ -66,6 +67,7 @@ app.include_router(wechat_router, prefix="/api") app.include_router(alerts_router, prefix="/api") app.include_router(cost_router, prefix="/api") app.include_router(quota_router, prefix="/api") +app.include_router(tool_configs_router, prefix="/api") @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 63c7c17..eeb6bdc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .tenant import Tenant, Subscription, Config from .tenant_app import TenantApp from .tenant_wechat_app import TenantWechatApp +from .tool_config import ToolConfig from .app import App from .stats import AICallEvent, TenantUsageDaily from .logs import PlatformLog @@ -14,6 +15,7 @@ __all__ = [ "Config", "TenantApp", "TenantWechatApp", + "ToolConfig", "App", "AICallEvent", "TenantUsageDaily", diff --git a/backend/app/models/tool_config.py b/backend/app/models/tool_config.py new file mode 100644 index 0000000..f49cd6e --- /dev/null +++ b/backend/app/models/tool_config.py @@ -0,0 +1,21 @@ +"""租户工具配置模型""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP +from ..database import Base + + +class ToolConfig(Base): + """租户工具配置表""" + __tablename__ = "platform_tool_configs" + + id = Column(Integer, primary_key=True, autoincrement=True) + tenant_id = Column(String(50), nullable=False, comment="租户ID") + tool_code = Column(String(50), nullable=True, comment="工具代码(NULL 表示租户级共享配置)") + config_type = Column(String(30), nullable=False, comment="配置类型:datasource / jssdk / webhook / params") + config_key = Column(String(100), nullable=False, comment="配置键名") + config_value = Column(Text, comment="配置值(明文或加密)") + is_encrypted = Column(SmallInteger, default=0, comment="是否加密存储") + description = Column(String(255), comment="配置说明") + status = Column(SmallInteger, default=1) + created_at = Column(TIMESTAMP, default=datetime.now) + updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) diff --git a/backend/app/routers/tenant_apps.py b/backend/app/routers/tenant_apps.py index 7a1cc51..1bae443 100644 --- a/backend/app/routers/tenant_apps.py +++ b/backend/app/routers/tenant_apps.py @@ -164,6 +164,27 @@ async def delete_tenant_app( return {"success": True} +@router.get("/{app_id}/token") +async def get_token( + app_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """获取真实的 access_token(仅管理员可用)""" + app = db.query(TenantApp).filter(TenantApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用配置不存在") + + # 获取应用的 base_url + app_info = db.query(App).filter(App.app_code == app.app_code).first() + base_url = app_info.base_url if app_info else "" + + return { + "access_token": app.access_token, + "base_url": base_url + } + + @router.post("/{app_id}/regenerate-token") async def regenerate_token( app_id: int, diff --git a/backend/app/routers/tool_configs.py b/backend/app/routers/tool_configs.py new file mode 100644 index 0000000..8551fbd --- /dev/null +++ b/backend/app/routers/tool_configs.py @@ -0,0 +1,391 @@ +"""租户工具配置路由""" +import json +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import or_ + +from ..database import get_db +from ..models.tool_config import ToolConfig +from .auth import get_current_user +from ..models.user import User + +router = APIRouter(prefix="/tool-configs", tags=["工具配置"]) + + +# ======================================== +# Schemas +# ======================================== + +class ToolConfigCreate(BaseModel): + """创建配置""" + tenant_id: str + tool_code: Optional[str] = None # NULL 表示租户级共享配置 + config_type: str # datasource / jssdk / webhook / params + config_key: str + config_value: Optional[str] = None + is_encrypted: int = 0 + description: Optional[str] = None + + +class ToolConfigUpdate(BaseModel): + """更新配置""" + config_value: Optional[str] = None + is_encrypted: Optional[int] = None + description: Optional[str] = None + status: Optional[int] = None + + +class ToolConfigBatchCreate(BaseModel): + """批量创建配置""" + tenant_id: str + tool_code: Optional[str] = None + configs: List[Dict[str, Any]] # [{config_type, config_key, config_value, description}] + + +# ======================================== +# 工具函数 +# ======================================== + +def format_config(config: ToolConfig, mask_secret: bool = True) -> dict: + """格式化配置输出""" + value = config.config_value + # 如果需要掩码且是加密字段 + if mask_secret and config.is_encrypted and value: + value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****" + + return { + "id": config.id, + "tenant_id": config.tenant_id, + "tool_code": config.tool_code, + "config_type": config.config_type, + "config_key": config.config_key, + "config_value": value, + "is_encrypted": config.is_encrypted, + "description": config.description, + "status": config.status, + "created_at": config.created_at.isoformat() if config.created_at else None, + "updated_at": config.updated_at.isoformat() if config.updated_at else None + } + + +# ======================================== +# API Endpoints +# ======================================== + +@router.get("") +async def list_tool_configs( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + tenant_id: Optional[str] = None, + tool_code: Optional[str] = None, + config_type: Optional[str] = None, + keyword: Optional[str] = None, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取配置列表""" + query = db.query(ToolConfig).filter(ToolConfig.status == 1) + + if tenant_id: + query = query.filter(ToolConfig.tenant_id == tenant_id) + if tool_code: + if tool_code == "__shared__": + # 特殊标记:只查租户级共享配置 + query = query.filter(ToolConfig.tool_code.is_(None)) + else: + query = query.filter(ToolConfig.tool_code == tool_code) + if config_type: + query = query.filter(ToolConfig.config_type == config_type) + if keyword: + query = query.filter( + or_( + ToolConfig.config_key.like(f"%{keyword}%"), + ToolConfig.description.like(f"%{keyword}%") + ) + ) + + total = query.count() + configs = query.order_by(ToolConfig.tool_code, ToolConfig.config_type, ToolConfig.config_key)\ + .offset((page - 1) * size).limit(size).all() + + return { + "total": total, + "page": page, + "size": size, + "items": [format_config(c) for c in configs] + } + + +@router.get("/tenant/{tenant_id}") +async def get_tenant_configs( + tenant_id: str, + tool_code: Optional[str] = None, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 获取租户完整配置(合并层级) + + 返回结构: + { + "__shared__": {"config_key": "value", ...}, # 租户级共享配置 + "customer-profile": {"config_key": "value", ...}, # 工具级配置 + ... + } + """ + query = db.query(ToolConfig).filter( + ToolConfig.tenant_id == tenant_id, + ToolConfig.status == 1 + ) + + if tool_code: + # 只查指定工具 + 共享配置 + query = query.filter( + or_( + ToolConfig.tool_code == tool_code, + ToolConfig.tool_code.is_(None) + ) + ) + + configs = query.all() + + result = {"__shared__": {}} + for config in configs: + tool = config.tool_code or "__shared__" + if tool not in result: + result[tool] = {} + result[tool][config.config_key] = { + "value": config.config_value, + "type": config.config_type, + "encrypted": config.is_encrypted == 1, + "description": config.description + } + + return result + + +@router.get("/merged/{tenant_id}/{tool_code}") +async def get_merged_config( + tenant_id: str, + tool_code: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 获取合并后的工具配置(工具级 > 租户级 > 默认值) + + 返回扁平化的配置字典: + {"config_key": "value", ...} + """ + # 查询租户级配置 + shared_configs = db.query(ToolConfig).filter( + ToolConfig.tenant_id == tenant_id, + ToolConfig.tool_code.is_(None), + ToolConfig.status == 1 + ).all() + + # 查询工具级配置 + tool_configs = db.query(ToolConfig).filter( + ToolConfig.tenant_id == tenant_id, + ToolConfig.tool_code == tool_code, + ToolConfig.status == 1 + ).all() + + # 合并:工具级覆盖租户级 + result = {} + for config in shared_configs: + result[config.config_key] = config.config_value + for config in tool_configs: + result[config.config_key] = config.config_value + + return result + + +@router.get("/{config_id}") +async def get_tool_config( + config_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取配置详情""" + config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + return format_config(config, mask_secret=False) + + +@router.post("") +async def create_tool_config( + data: ToolConfigCreate, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建配置""" + # 检查是否已存在 + existing = db.query(ToolConfig).filter( + ToolConfig.tenant_id == data.tenant_id, + ToolConfig.tool_code == data.tool_code if data.tool_code else ToolConfig.tool_code.is_(None), + ToolConfig.config_key == data.config_key + ).first() + + if existing: + raise HTTPException(status_code=400, detail="配置已存在") + + config = ToolConfig( + tenant_id=data.tenant_id, + tool_code=data.tool_code, + config_type=data.config_type, + config_key=data.config_key, + config_value=data.config_value, + is_encrypted=data.is_encrypted, + description=data.description + ) + + db.add(config) + db.commit() + db.refresh(config) + + return format_config(config) + + +@router.post("/batch") +async def batch_create_configs( + data: ToolConfigBatchCreate, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """批量创建配置""" + created = [] + skipped = [] + + for item in data.configs: + config_key = item.get("config_key") + if not config_key: + continue + + # 检查是否已存在 + existing = db.query(ToolConfig).filter( + ToolConfig.tenant_id == data.tenant_id, + ToolConfig.tool_code == data.tool_code if data.tool_code else ToolConfig.tool_code.is_(None), + ToolConfig.config_key == config_key + ).first() + + if existing: + skipped.append(config_key) + continue + + config = ToolConfig( + tenant_id=data.tenant_id, + tool_code=data.tool_code, + config_type=item.get("config_type", "params"), + config_key=config_key, + config_value=item.get("config_value"), + is_encrypted=item.get("is_encrypted", 0), + description=item.get("description") + ) + db.add(config) + created.append(config_key) + + db.commit() + + return { + "created": created, + "skipped": skipped, + "message": f"成功创建 {len(created)} 条配置,跳过 {len(skipped)} 条已存在配置" + } + + +@router.put("/{config_id}") +async def update_tool_config( + config_id: int, + data: ToolConfigUpdate, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新配置""" + config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + if data.config_value is not None: + config.config_value = data.config_value + if data.is_encrypted is not None: + config.is_encrypted = data.is_encrypted + if data.description is not None: + config.description = data.description + if data.status is not None: + config.status = data.status + + db.commit() + db.refresh(config) + + return format_config(config) + + +@router.delete("/{config_id}") +async def delete_tool_config( + config_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除配置(软删除)""" + config = db.query(ToolConfig).filter(ToolConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="配置不存在") + + config.status = 0 + db.commit() + + return {"message": "删除成功"} + + +# ======================================== +# 配置类型和键名定义(供前端使用) +# ======================================== + +@router.get("/schema/types") +async def get_config_types(): + """获取支持的配置类型""" + return { + "types": [ + {"code": "datasource", "name": "数据源配置", "description": "数据库连接等"}, + {"code": "jssdk", "name": "JS-SDK 配置", "description": "企微侧边栏等"}, + {"code": "webhook", "name": "Webhook 配置", "description": "n8n 工作流地址等"}, + {"code": "params", "name": "工具参数", "description": "各工具的自定义参数"} + ] + } + + +@router.get("/schema/keys") +async def get_config_keys(): + """获取预定义的配置键(供前端下拉选择)""" + return { + "datasource": [ + {"key": "scrm_db_host", "name": "SCRM 数据库地址", "encrypted": False}, + {"key": "scrm_db_port", "name": "SCRM 数据库端口", "encrypted": False}, + {"key": "scrm_db_user", "name": "SCRM 数据库用户", "encrypted": False}, + {"key": "scrm_db_password", "name": "SCRM 数据库密码", "encrypted": True}, + {"key": "scrm_db_name", "name": "SCRM 数据库名", "encrypted": False} + ], + "jssdk": [ + {"key": "corp_id", "name": "企业ID", "encrypted": False}, + {"key": "agent_id", "name": "应用ID", "encrypted": False}, + {"key": "secret", "name": "应用密钥", "encrypted": True} + ], + "webhook": [ + {"key": "n8n_base_url", "name": "n8n 基础地址", "encrypted": False}, + {"key": "webhook_brainstorm", "name": "头脑风暴 Webhook", "encrypted": False}, + {"key": "webhook_high_eq", "name": "高情商回复 Webhook", "encrypted": False}, + {"key": "webhook_customer_profile", "name": "客户画像 Webhook", "encrypted": False}, + {"key": "webhook_consultation", "name": "面诊方案 Webhook", "encrypted": False}, + {"key": "webhook_medical_compliance", "name": "医疗合规 Webhook", "encrypted": False} + ], + "params": [ + {"key": "default_data_tenant_id", "name": "默认数据租户ID", "encrypted": False}, + {"key": "enable_deep_thinking", "name": "启用深度思考", "encrypted": False}, + {"key": "max_history_rounds", "name": "最大历史轮数", "encrypted": False} + ] + } diff --git a/frontend/src/views/app-config/index.vue b/frontend/src/views/app-config/index.vue index ec10e34..0b206f8 100644 --- a/frontend/src/views/app-config/index.vue +++ b/frontend/src/views/app-config/index.vue @@ -232,9 +232,14 @@ function handleCopyUrl() { } async function handleViewToken(row) { - // 这里需要后端返回真实 token,暂时用 placeholder - // 实际生产中可能需要单独 API 获取 - showToken(row.access_token === '******' ? '需要调用API获取' : row.access_token, row.app_code) + try { + const res = await api.get(`/api/tenant-apps/${row.id}/token`) + currentToken.value = res.data.access_token + currentAppUrl.value = res.data.base_url || '' + tokenDialogVisible.value = true + } catch (e) { + // 错误已在拦截器处理 + } } onMounted(() => { diff --git a/frontend/src/views/tenants/detail.vue b/frontend/src/views/tenants/detail.vue index 6ee042d..7442827 100644 --- a/frontend/src/views/tenants/detail.vue +++ b/frontend/src/views/tenants/detail.vue @@ -1,15 +1,22 @@