From 39f33d7ac52d679544d31308c11faa671aa139e7 Mon Sep 17 00:00:00 2001 From: 111 Date: Fri, 23 Jan 2026 18:22:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E7=94=9F=E6=88=90=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 platform_apps 表和 App 模型 - 新增应用管理页面 /apps - 应用配置页面添加"生成链接"功能 - 支持一键生成带签名的访问 URL --- backend/app/main.py | 2 + backend/app/models/__init__.py | 4 + backend/app/models/app.py | 23 ++ backend/app/routers/apps.py | 281 ++++++++++++++++++++++ frontend/src/router/index.js | 8 +- frontend/src/views/app-config/index.vue | 228 ++++++++++++++++-- frontend/src/views/apps/index.vue | 302 ++++++++++++++++++++++++ 7 files changed, 831 insertions(+), 17 deletions(-) create mode 100644 backend/app/models/app.py create mode 100644 backend/app/routers/apps.py create mode 100644 frontend/src/views/apps/index.vue diff --git a/backend/app/main.py b/backend/app/main.py index 640bb1e..90f6aa6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from .routers import stats_router, logs_router, config_router, health_router from .routers.auth import router as auth_router from .routers.tenants import router as tenants_router from .routers.tenant_apps import router as tenant_apps_router +from .routers.apps import router as apps_router settings = get_settings() @@ -30,6 +31,7 @@ app.include_router(health_router) app.include_router(auth_router, prefix="/api") app.include_router(tenants_router, prefix="/api") app.include_router(tenant_apps_router, prefix="/api") +app.include_router(apps_router, prefix="/api") app.include_router(stats_router, prefix="/api") app.include_router(logs_router, prefix="/api") app.include_router(config_router, prefix="/api") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e01eeba..33be540 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,7 @@ """数据模型""" from .tenant import Tenant, Subscription, Config +from .tenant_app import TenantApp +from .app import App from .stats import AICallEvent, TenantUsageDaily from .logs import PlatformLog @@ -7,6 +9,8 @@ __all__ = [ "Tenant", "Subscription", "Config", + "TenantApp", + "App", "AICallEvent", "TenantUsageDaily", "PlatformLog" diff --git a/backend/app/models/app.py b/backend/app/models/app.py new file mode 100644 index 0000000..54da32f --- /dev/null +++ b/backend/app/models/app.py @@ -0,0 +1,23 @@ +"""应用定义模型""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP +from ..database import Base + + +class App(Base): + """应用定义表 - 定义可供租户使用的应用""" + __tablename__ = "platform_apps" + + id = Column(Integer, primary_key=True, autoincrement=True) + app_code = Column(String(50), nullable=False, unique=True) # 唯一标识,如 tools + app_name = Column(String(100), nullable=False) # 显示名称 + base_url = Column(String(500)) # 基础URL,如 https://tools.test.ai.ireborn.com.cn + description = Column(Text) # 应用描述 + + # 应用下的工具/功能列表(JSON 数组) + # [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...] + tools = Column(Text) + + status = Column(SmallInteger, default=1) # 0-禁用 1-启用 + created_at = Column(TIMESTAMP, default=datetime.now) + updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now) diff --git a/backend/app/routers/apps.py b/backend/app/routers/apps.py new file mode 100644 index 0000000..b391655 --- /dev/null +++ b/backend/app/routers/apps.py @@ -0,0 +1,281 @@ +"""应用管理路由""" +import json +import hmac +import hashlib +import time +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, List +from sqlalchemy.orm import Session + +from ..database import get_db +from ..models.app import App +from ..models.tenant_app import TenantApp +from .auth import get_current_user, require_operator +from ..models.user import User + +router = APIRouter(prefix="/apps", tags=["应用管理"]) + + +# ============ Schemas ============ + +class ToolItem(BaseModel): + """工具项""" + code: str + name: str + path: str + + +class AppCreate(BaseModel): + """创建应用""" + app_code: str + app_name: str + base_url: Optional[str] = None + description: Optional[str] = None + tools: Optional[List[ToolItem]] = None + + +class AppUpdate(BaseModel): + """更新应用""" + app_name: Optional[str] = None + base_url: Optional[str] = None + description: Optional[str] = None + tools: Optional[List[ToolItem]] = None + status: Optional[int] = None + + +class GenerateUrlRequest(BaseModel): + """生成链接请求""" + tenant_id: str + app_code: str + tool_code: Optional[str] = None # 不传则生成应用首页链接 + + +# ============ API Endpoints ============ + +@router.get("") +async def list_apps( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + status: Optional[int] = None, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取应用列表""" + query = db.query(App) + + if status is not None: + query = query.filter(App.status == status) + + total = query.count() + apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all() + + return { + "total": total, + "page": page, + "size": size, + "items": [format_app(app) for app in apps] + } + + +@router.get("/all") +async def list_all_apps( + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取所有启用的应用(用于下拉选择)""" + apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all() + return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps] + + +@router.get("/{app_id}") +async def get_app( + app_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取应用详情""" + app = db.query(App).filter(App.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + return format_app(app) + + +@router.post("") +async def create_app( + data: AppCreate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """创建应用""" + # 检查 app_code 是否重复 + exists = db.query(App).filter(App.app_code == data.app_code).first() + if exists: + raise HTTPException(status_code=400, detail="应用代码已存在") + + app = App( + app_code=data.app_code, + app_name=data.app_name, + base_url=data.base_url, + description=data.description, + tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None, + status=1 + ) + db.add(app) + db.commit() + db.refresh(app) + + return {"success": True, "id": app.id} + + +@router.put("/{app_id}") +async def update_app( + app_id: int, + data: AppUpdate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """更新应用""" + app = db.query(App).filter(App.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + update_data = data.model_dump(exclude_unset=True) + + # 处理 tools JSON + if 'tools' in update_data: + if update_data['tools']: + update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False) + else: + update_data['tools'] = None + + for key, value in update_data.items(): + setattr(app, key, value) + + db.commit() + return {"success": True} + + +@router.delete("/{app_id}") +async def delete_app( + app_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """删除应用""" + app = db.query(App).filter(App.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + # 检查是否有租户在使用 + tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count() + if tenant_count > 0: + raise HTTPException(status_code=400, detail=f"有 {tenant_count} 个租户正在使用此应用,无法删除") + + db.delete(app) + db.commit() + + return {"success": True} + + +@router.post("/generate-url") +async def generate_signed_url( + data: GenerateUrlRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + 生成带签名的访问链接 + + 返回完整的可直接使用的 URL + """ + # 获取应用信息 + app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在或已禁用") + + if not app.base_url: + raise HTTPException(status_code=400, detail="应用未配置基础URL") + + # 获取租户配置 + tenant_app = db.query(TenantApp).filter( + TenantApp.tenant_id == data.tenant_id, + TenantApp.app_code == data.app_code, + TenantApp.status == 1 + ).first() + + if not tenant_app: + raise HTTPException(status_code=404, detail="租户未配置此应用") + + # 构建基础 URL + base_url = app.base_url.rstrip('/') + if data.tool_code: + # 查找工具路径 + tools = json.loads(app.tools) if app.tools else [] + tool = next((t for t in tools if t.get('code') == data.tool_code), None) + if tool: + base_url = f"{base_url}{tool.get('path', '')}" + else: + base_url = f"{base_url}/{data.tool_code}" + + # 构建参数 + params = { + "tid": data.tenant_id, + "aid": data.app_code + } + + # 如果需要签名 + if tenant_app.token_required and tenant_app.token_secret: + ts = str(int(time.time())) + message = f"{data.tenant_id}{data.app_code}{ts}" + sign = hmac.new( + tenant_app.token_secret.encode(), + message.encode(), + hashlib.sha256 + ).hexdigest() + params["ts"] = ts + params["sign"] = sign + + # 组装 URL + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + full_url = f"{base_url}?{query_string}" + + return { + "success": True, + "url": full_url, + "params": params, + "token_required": bool(tenant_app.token_required), + "expires_in": 300 if tenant_app.token_required else None, # 签名5分钟有效 + "note": "签名链接5分钟内有效,过期需重新生成" if tenant_app.token_required else "免签名链接,长期有效" + } + + +@router.get("/{app_code}/tools") +async def get_app_tools( + app_code: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取应用的工具列表(用于配置权限时选择)""" + app = db.query(App).filter(App.app_code == app_code).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + tools = json.loads(app.tools) if app.tools else [] + return tools + + +def format_app(app: App) -> dict: + """格式化应用数据""" + return { + "id": app.id, + "app_code": app.app_code, + "app_name": app.app_name, + "base_url": app.base_url, + "description": app.description, + "tools": json.loads(app.tools) if app.tools else [], + "status": app.status, + "created_at": app.created_at, + "updated_at": app.updated_at + } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e928747..5c453a5 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -31,11 +31,17 @@ const routes = [ component: () => import('@/views/tenants/detail.vue'), meta: { title: '租户详情', hidden: true } }, + { + path: 'apps', + name: 'Apps', + component: () => import('@/views/apps/index.vue'), + meta: { title: '应用管理', icon: 'Grid' } + }, { path: 'app-config', name: 'AppConfig', component: () => import('@/views/app-config/index.vue'), - meta: { title: '应用配置', icon: 'Setting' } + meta: { title: '租户应用配置', icon: 'Setting' } }, { path: 'stats', diff --git a/frontend/src/views/app-config/index.vue b/frontend/src/views/app-config/index.vue index 641bf88..54d0424 100644 --- a/frontend/src/views/app-config/index.vue +++ b/frontend/src/views/app-config/index.vue @@ -1,5 +1,5 @@ @@ -227,11 +345,12 @@ onMounted(() => { ... - + @@ -254,11 +373,14 @@ onMounted(() => { - - + + + + + - - + + 企业微信配置 @@ -290,5 +412,79 @@ onMounted(() => { 确定 + + + +
+ + {{ currentRow.tenant_id }} + {{ currentRow.app_code }} + + + {{ currentRow.token_required ? '需要签名' : '免签名' }} + + + + {{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }} + + + + + + + + + + + + 生成链接 + + + + +
+ 生成结果 + + + +
+ + + + 复制链接 + +
+
+
+ +
+ + diff --git a/frontend/src/views/apps/index.vue b/frontend/src/views/apps/index.vue new file mode 100644 index 0000000..5b47a5e --- /dev/null +++ b/frontend/src/views/apps/index.vue @@ -0,0 +1,302 @@ + + + + +