From c4bd7c8251d25d85635747751ffef649b5d1a8e8 Mon Sep 17 00:00:00 2001 From: 111 Date: Fri, 23 Jan 2026 19:05:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E7=BA=A7=E4=BC=81?= =?UTF-8?q?=E5=BE=AE=E9=85=8D=E7=BD=AE=E6=94=B9=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 platform_tenant_wechat_apps 表(租户企微应用配置) - platform_apps 增加 require_jssdk 字段 - platform_tenant_apps 增加 wechat_app_id 关联字段 - 新增企微应用管理 API 和页面 - 应用管理页面增加 JS-SDK 开关 - 应用配置页面增加企微应用选择 --- README.md | 129 ++++ backend/app/main.py | 94 +-- backend/app/models/__init__.py | 36 +- backend/app/models/app.py | 49 +- backend/app/models/tenant_app.py | 6 +- backend/app/models/tenant_wechat_app.py | 23 + backend/app/routers/apps.py | 8 + backend/app/routers/tenant_apps.py | 66 +- backend/app/routers/tenant_wechat_apps.py | 198 ++++++ frontend/src/router/index.js | 190 +++--- frontend/src/views/app-config/index.vue | 123 ++-- frontend/src/views/apps/index.vue | 618 +++++++++--------- .../src/views/tenant-wechat-apps/index.vue | 238 +++++++ 13 files changed, 1198 insertions(+), 580 deletions(-) create mode 100644 README.md create mode 100644 backend/app/models/tenant_wechat_app.py create mode 100644 backend/app/routers/tenant_wechat_apps.py create mode 100644 frontend/src/views/tenant-wechat-apps/index.vue diff --git a/README.md b/README.md new file mode 100644 index 0000000..8878625 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# AI 对话启动指南 + +> 本文档用于快速让新 AI 了解项目背景和当前状态 + +--- + +## 项目概述 + +这是一个多租户 AI 应用平台,包含: + +| 项目 | 说明 | 技术栈 | +|------|------|--------| +| 000-platform | 统一管理平台(Admin UI) | Python FastAPI + Vue 3 | +| 001-tools | PHP+n8n 工具迁移 | Python FastAPI + Vue 3 | +| 011-ai-interview | AI 面试应用 | Python FastAPI + Vue 3 | + +--- + +## 当前状态 + +### 已完成 +- DevOps: Gitea + Drone CI/CD + Docker 自动部署 +- 多租户鉴权: URL 参数签名验证 (tid, aid, ts, sign) +- 应用管理: Admin UI 可配置应用和生成签名链接 +- 5 个工具迁移: 头脑风暴、高情商回复、面诊方案、客户画像、医疗合规 +- AI 调用统计: 记录到 platform_ai_call_events + +### 待完成 +- 企业微信 JS-SDK 集成 +- n8n 返回 token 统计 +- 生产环境部署 + +--- + +## 关键文件位置 + +``` +AgentWD/ +├── _shared/ # 共享文档 +│ ├── AI对话启动指南.md # 本文件 +│ ├── 项目进度/ # 进度文档 +│ │ ├── 整体进度汇总.md +│ │ ├── 000-platform进度.md +│ │ └── 001-tools迁移进度.md +│ └── 数据库/ +│ └── 测试环境配置.md # 服务器/数据库信息 +├── 框架/ +│ └── CICD配置.md # CI/CD 说明 +├── projects/ +│ ├── 000-platform/ # 管理平台 +│ ├── 001-tools/ # 工具集 +│ └── 011-ai-interview/ # AI 面试 +└── scripts/ # 数据库脚本 +``` + +--- + +## 服务器信息 + +| 服务 | 地址 | 说明 | +|------|------|------| +| 测试服务器 | 47.107.172.23 | root / Nj861021 | +| MySQL | 47.107.71.55 | scrm_reader / ScrmReader2024Pass | +| n8n | https://n8n.ireborn.com.cn | 工作流引擎 | +| Gitea | https://git.ai.ireborn.com.cn | 代码托管 | +| Drone CI | https://ci.ai.ireborn.com.cn | 自动部署 | + +--- + +## 测试地址 + +| 服务 | 测试环境 | +|------|----------| +| 管理平台 | https://platform.test.ai.ireborn.com.cn/admin | +| 工具集 | https://tools.test.ai.ireborn.com.cn | +| AI 面试 | https://interview.test.ai.ireborn.com.cn | + +--- + +## 鉴权机制 + +### 001-tools 租户鉴权 +``` +URL: https://tools.test.ai.ireborn.com.cn/brainstorm?tid=test&aid=tools +``` + +- `tid`: 租户ID(必须) +- `aid`: 应用代码(必须,默认 tools) +- `ts`: 时间戳(需签名的租户必须) +- `sign`: HMAC-SHA256(tid+aid+ts, token_secret) + +### 测试租户 +- `test`: 免签名,直接 `?tid=test&aid=tools` +- `qiqi`: 需签名,通过 Admin UI 生成链接 + +--- + +## 开发规范 + +1. **分支策略**: develop → 测试环境, main → 生产环境 +2. **代码提交**: 推送后自动触发 Drone CI/CD +3. **样式保留**: 001-tools 各工具保持原 PHP 样式,不统一 +4. **数据库表**: 统一使用 `platform_` 前缀 + +--- + +## 常用命令 + +```bash +# 检查构建状态 +curl "https://ci.ai.ireborn.com.cn/api/repos/admin/000-platform/builds?per_page=1" + +# SSH 到服务器 +ssh root@47.107.172.23 # 密码: Nj861021 + +# 执行 MySQL +mysql -h 47.107.71.55 -u scrm_reader -pScrmReader2024Pass new_qiqi +``` + +--- + +## 详细进度 + +请阅读以下文件获取更多信息: + +1. `_shared/项目进度/整体进度汇总.md` - 整体架构和状态 +2. `_shared/项目进度/001-tools迁移进度.md` - 工具迁移详情 +3. `_shared/项目进度/000-platform进度.md` - 平台服务详情 +4. `_shared/数据库/测试环境配置.md` - 环境配置详情 diff --git a/backend/app/main.py b/backend/app/main.py index 90f6aa6..d8cebd5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,46 +1,48 @@ -"""平台服务入口""" -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from .config import get_settings -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() - -app = FastAPI( - title=settings.APP_NAME, - version=settings.APP_VERSION, - description="平台基础设施服务 - 统计/日志/配置管理" -) - -# CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 注册路由 -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") - - -@app.get("/") -async def root(): - return { - "service": settings.APP_NAME, - "version": settings.APP_VERSION, - "docs": "/docs" - } +"""平台服务入口""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import get_settings +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.tenant_wechat_apps import router as tenant_wechat_apps_router +from .routers.apps import router as apps_router + +settings = get_settings() + +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="平台基础设施服务 - 统计/日志/配置管理" +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +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(tenant_wechat_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") + + +@app.get("/") +async def root(): + return { + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + "docs": "/docs" + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 33be540..f610327 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,17 +1,19 @@ -"""数据模型""" -from .tenant import Tenant, Subscription, Config -from .tenant_app import TenantApp -from .app import App -from .stats import AICallEvent, TenantUsageDaily -from .logs import PlatformLog - -__all__ = [ - "Tenant", - "Subscription", - "Config", - "TenantApp", - "App", - "AICallEvent", - "TenantUsageDaily", - "PlatformLog" -] +"""数据模型""" +from .tenant import Tenant, Subscription, Config +from .tenant_app import TenantApp +from .tenant_wechat_app import TenantWechatApp +from .app import App +from .stats import AICallEvent, TenantUsageDaily +from .logs import PlatformLog + +__all__ = [ + "Tenant", + "Subscription", + "Config", + "TenantApp", + "TenantWechatApp", + "App", + "AICallEvent", + "TenantUsageDaily", + "PlatformLog" +] diff --git a/backend/app/models/app.py b/backend/app/models/app.py index 54da32f..f388851 100644 --- a/backend/app/models/app.py +++ b/backend/app/models/app.py @@ -1,23 +1,26 @@ -"""应用定义模型""" -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) +"""应用定义模型""" +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) + + # 是否需要企微JS-SDK + require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要 + + 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/models/tenant_app.py b/backend/app/models/tenant_app.py index 2f04d64..1e77a81 100644 --- a/backend/app/models/tenant_app.py +++ b/backend/app/models/tenant_app.py @@ -13,10 +13,8 @@ class TenantApp(Base): app_code = Column(String(50), nullable=False, default='tools') app_name = Column(String(100)) - # 企业微信配置 - wechat_corp_id = Column(String(100)) - wechat_agent_id = Column(String(50)) - wechat_secret_encrypted = Column(Text) + # 企业微信配置(关联 platform_tenant_wechat_apps) + wechat_app_id = Column(Integer) # 关联的企微应用ID # 鉴权配置 access_token = Column(String(64)) # 访问令牌(长期有效) diff --git a/backend/app/models/tenant_wechat_app.py b/backend/app/models/tenant_wechat_app.py new file mode 100644 index 0000000..37d0562 --- /dev/null +++ b/backend/app/models/tenant_wechat_app.py @@ -0,0 +1,23 @@ +"""租户企业微信应用配置模型""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP +from ..database import Base + + +class TenantWechatApp(Base): + """租户企业微信应用配置表 + + 一个租户可以配置多个企微应用,供不同的平台应用关联使用 + """ + __tablename__ = "platform_tenant_wechat_apps" + + id = Column(Integer, primary_key=True, autoincrement=True) + tenant_id = Column(String(50), nullable=False, index=True) + name = Column(String(100), nullable=False) # 应用名称,如"工具集应用" + corp_id = Column(String(100), nullable=False) # 企业ID + agent_id = Column(String(50), nullable=False) # 应用AgentId + secret_encrypted = Column(Text) # 加密的Secret + + 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 index 3cfea82..7fa0ed3 100644 --- a/backend/app/routers/apps.py +++ b/backend/app/routers/apps.py @@ -30,6 +30,7 @@ class AppCreate(BaseModel): base_url: Optional[str] = None description: Optional[str] = None tools: Optional[List[ToolItem]] = None + require_jssdk: bool = False class AppUpdate(BaseModel): @@ -38,6 +39,7 @@ class AppUpdate(BaseModel): base_url: Optional[str] = None description: Optional[str] = None tools: Optional[List[ToolItem]] = None + require_jssdk: Optional[bool] = None status: Optional[int] = None @@ -117,6 +119,7 @@ async def create_app( 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, + require_jssdk=1 if data.require_jssdk else 0, status=1 ) db.add(app) @@ -147,6 +150,10 @@ async def update_app( else: update_data['tools'] = None + # 处理 require_jssdk + if 'require_jssdk' in update_data: + update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0 + for key, value in update_data.items(): setattr(app, key, value) @@ -261,6 +268,7 @@ def format_app(app: App) -> dict: "base_url": app.base_url, "description": app.description, "tools": json.loads(app.tools) if app.tools else [], + "require_jssdk": bool(app.require_jssdk), "status": app.status, "created_at": app.created_at, "updated_at": app.updated_at diff --git a/backend/app/routers/tenant_apps.py b/backend/app/routers/tenant_apps.py index fc29704..c41cc7b 100644 --- a/backend/app/routers/tenant_apps.py +++ b/backend/app/routers/tenant_apps.py @@ -10,7 +10,6 @@ from ..database import get_db from ..models.tenant_app import TenantApp from .auth import get_current_user, require_operator from ..models.user import User -from ..services.crypto import encrypt_config, decrypt_config router = APIRouter(prefix="/tenant-apps", tags=["应用配置"]) @@ -21,9 +20,7 @@ class TenantAppCreate(BaseModel): tenant_id: str app_code: str = "tools" app_name: Optional[str] = None - wechat_corp_id: Optional[str] = None - wechat_agent_id: Optional[str] = None - wechat_secret: Optional[str] = None # 明文,存储时加密 + wechat_app_id: Optional[int] = None # 关联的企微应用ID access_token: Optional[str] = None # 如果不传则自动生成 allowed_origins: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None @@ -31,9 +28,7 @@ class TenantAppCreate(BaseModel): class TenantAppUpdate(BaseModel): app_name: Optional[str] = None - wechat_corp_id: Optional[str] = None - wechat_agent_id: Optional[str] = None - wechat_secret: Optional[str] = None + wechat_app_id: Optional[int] = None # 关联的企微应用ID access_token: Optional[str] = None allowed_origins: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None @@ -66,7 +61,7 @@ async def list_tenant_apps( "total": total, "page": page, "size": size, - "items": [format_tenant_app(app, mask_secret=True) for app in apps] + "items": [format_tenant_app(app, mask_secret=True, db=db) for app in apps] } @@ -81,7 +76,7 @@ async def get_tenant_app( if not app: raise HTTPException(status_code=404, detail="应用配置不存在") - return format_tenant_app(app, mask_secret=True) + return format_tenant_app(app, mask_secret=True, db=db) @router.post("") @@ -102,18 +97,11 @@ async def create_tenant_app( # 自动生成 access_token access_token = data.access_token or secrets.token_hex(32) - # 加密 wechat_secret - wechat_secret_encrypted = None - if data.wechat_secret: - wechat_secret_encrypted = encrypt_config(data.wechat_secret) - app = TenantApp( tenant_id=data.tenant_id, app_code=data.app_code, app_name=data.app_name, - wechat_corp_id=data.wechat_corp_id, - wechat_agent_id=data.wechat_agent_id, - wechat_secret_encrypted=wechat_secret_encrypted, + wechat_app_id=data.wechat_app_id, access_token=access_token, allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None, allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None, @@ -140,12 +128,6 @@ async def update_tenant_app( update_data = data.model_dump(exclude_unset=True) - # 处理 wechat_secret - if 'wechat_secret' in update_data: - if update_data['wechat_secret']: - app.wechat_secret_encrypted = encrypt_config(update_data['wechat_secret']) - del update_data['wechat_secret'] - # 处理 JSON 字段 if 'allowed_origins' in update_data: update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None @@ -194,34 +176,28 @@ async def regenerate_token( return {"success": True, "access_token": new_token} -@router.get("/{app_id}/wechat-secret") -async def get_wechat_secret( - app_id: int, - user: User = Depends(require_operator), - db: Session = Depends(get_db) -): - """获取解密的 wechat_secret(仅操作员以上)""" - app = db.query(TenantApp).filter(TenantApp.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="应用配置不存在") - - secret = None - if app.wechat_secret_encrypted: - secret = decrypt_config(app.wechat_secret_encrypted) - - return {"wechat_secret": secret} - - -def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict: +def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = None) -> dict: """格式化应用配置""" + # 获取关联的企微应用信息 + wechat_app_info = None + if app.wechat_app_id and db: + from ..models.tenant_wechat_app import TenantWechatApp + wechat_app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app.wechat_app_id).first() + if wechat_app: + wechat_app_info = { + "id": wechat_app.id, + "name": wechat_app.name, + "corp_id": wechat_app.corp_id, + "agent_id": wechat_app.agent_id + } + result = { "id": app.id, "tenant_id": app.tenant_id, "app_code": app.app_code, "app_name": app.app_name, - "wechat_corp_id": app.wechat_corp_id, - "wechat_agent_id": app.wechat_agent_id, - "has_wechat_secret": bool(app.wechat_secret_encrypted), + "wechat_app_id": app.wechat_app_id, + "wechat_app": wechat_app_info, "access_token": "******" if mask_secret and app.access_token else app.access_token, "allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [], "allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [], diff --git a/backend/app/routers/tenant_wechat_apps.py b/backend/app/routers/tenant_wechat_apps.py new file mode 100644 index 0000000..dbcec13 --- /dev/null +++ b/backend/app/routers/tenant_wechat_apps.py @@ -0,0 +1,198 @@ +"""租户企业微信应用配置路由""" +import json +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.tenant_wechat_app import TenantWechatApp +from .auth import get_current_user, require_operator +from ..models.user import User +from ..services.crypto import encrypt_config, decrypt_config + +router = APIRouter(prefix="/tenant-wechat-apps", tags=["租户企微应用"]) + + +# Schemas + +class TenantWechatAppCreate(BaseModel): + tenant_id: str + name: str + corp_id: str + agent_id: str + secret: Optional[str] = None # 明文,存储时加密 + + +class TenantWechatAppUpdate(BaseModel): + name: Optional[str] = None + corp_id: Optional[str] = None + agent_id: Optional[str] = None + secret: Optional[str] = None + status: Optional[int] = None + + +# API Endpoints + +@router.get("") +async def list_tenant_wechat_apps( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + tenant_id: Optional[str] = None, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取租户企微应用列表""" + query = db.query(TenantWechatApp) + + if tenant_id: + query = query.filter(TenantWechatApp.tenant_id == tenant_id) + + total = query.count() + apps = query.order_by(TenantWechatApp.id.desc()).offset((page - 1) * size).limit(size).all() + + return { + "total": total, + "page": page, + "size": size, + "items": [format_wechat_app(app) for app in apps] + } + + +@router.get("/by-tenant/{tenant_id}") +async def list_by_tenant( + tenant_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定租户的所有企微应用(用于下拉选择)""" + apps = db.query(TenantWechatApp).filter( + TenantWechatApp.tenant_id == tenant_id, + TenantWechatApp.status == 1 + ).order_by(TenantWechatApp.id.asc()).all() + + return [{"id": app.id, "name": app.name, "corp_id": app.corp_id, "agent_id": app.agent_id} for app in apps] + + +@router.get("/{app_id}") +async def get_tenant_wechat_app( + app_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取企微应用详情""" + app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="企微应用不存在") + + return format_wechat_app(app) + + +@router.post("") +async def create_tenant_wechat_app( + data: TenantWechatAppCreate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """创建企微应用""" + # 加密 secret + secret_encrypted = None + if data.secret: + secret_encrypted = encrypt_config(data.secret) + + app = TenantWechatApp( + tenant_id=data.tenant_id, + name=data.name, + corp_id=data.corp_id, + agent_id=data.agent_id, + secret_encrypted=secret_encrypted, + status=1 + ) + db.add(app) + db.commit() + db.refresh(app) + + return {"success": True, "id": app.id} + + +@router.put("/{app_id}") +async def update_tenant_wechat_app( + app_id: int, + data: TenantWechatAppUpdate, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """更新企微应用""" + app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="企微应用不存在") + + update_data = data.model_dump(exclude_unset=True) + + # 处理 secret 加密 + if 'secret' in update_data: + if update_data['secret']: + app.secret_encrypted = encrypt_config(update_data['secret']) + del update_data['secret'] + + for key, value in update_data.items(): + setattr(app, key, value) + + db.commit() + return {"success": True} + + +@router.delete("/{app_id}") +async def delete_tenant_wechat_app( + app_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """删除企微应用""" + app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="企微应用不存在") + + # 检查是否有租户应用在使用 + from ..models.tenant_app import TenantApp + usage_count = db.query(TenantApp).filter(TenantApp.wechat_app_id == app_id).count() + if usage_count > 0: + raise HTTPException(status_code=400, detail=f"有 {usage_count} 个应用配置正在使用此企微应用,无法删除") + + db.delete(app) + db.commit() + + return {"success": True} + + +@router.get("/{app_id}/secret") +async def get_wechat_secret( + app_id: int, + user: User = Depends(require_operator), + db: Session = Depends(get_db) +): + """获取解密的 secret(仅操作员以上)""" + app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="企微应用不存在") + + secret = None + if app.secret_encrypted: + secret = decrypt_config(app.secret_encrypted) + + return {"secret": secret} + + +def format_wechat_app(app: TenantWechatApp) -> dict: + """格式化企微应用数据""" + return { + "id": app.id, + "tenant_id": app.tenant_id, + "name": app.name, + "corp_id": app.corp_id, + "agent_id": app.agent_id, + "has_secret": bool(app.secret_encrypted), + "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 5c453a5..fad7e4a 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,92 +1,98 @@ -import { createRouter, createWebHistory } from 'vue-router' -import { useAuthStore } from '@/stores/auth' - -const routes = [ - { - path: '/login', - name: 'Login', - component: () => import('@/views/login/index.vue'), - meta: { title: '登录', public: true } - }, - { - path: '/', - component: () => import('@/components/Layout.vue'), - redirect: '/dashboard', - children: [ - { - path: 'dashboard', - name: 'Dashboard', - component: () => import('@/views/dashboard/index.vue'), - meta: { title: '仪表盘', icon: 'Odometer' } - }, - { - path: 'tenants', - name: 'Tenants', - component: () => import('@/views/tenants/index.vue'), - meta: { title: '租户管理', icon: 'OfficeBuilding' } - }, - { - path: 'tenants/:id', - name: 'TenantDetail', - 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' } - }, - { - path: 'stats', - name: 'Stats', - component: () => import('@/views/stats/index.vue'), - meta: { title: '统计分析', icon: 'TrendCharts' } - }, - { - path: 'logs', - name: 'Logs', - component: () => import('@/views/logs/index.vue'), - meta: { title: '日志查看', icon: 'Document' } - }, - { - path: 'users', - name: 'Users', - component: () => import('@/views/users/index.vue'), - meta: { title: '用户管理', icon: 'User', role: 'admin' } - } - ] - } -] - -const router = createRouter({ - history: createWebHistory(), - routes -}) - -// 路由守卫 -router.beforeEach((to, from, next) => { - // 设置页面标题 - document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理' - - // 检查登录状态 - const authStore = useAuthStore() - - if (to.meta.public) { - next() - } else if (!authStore.isLoggedIn) { - next('/login') - } else if (to.meta.role && authStore.user?.role !== to.meta.role) { - next('/dashboard') - } else { - next() - } -}) - -export default router +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录', public: true } + }, + { + path: '/', + component: () => import('@/components/Layout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', icon: 'Odometer' } + }, + { + path: 'tenants', + name: 'Tenants', + component: () => import('@/views/tenants/index.vue'), + meta: { title: '租户管理', icon: 'OfficeBuilding' } + }, + { + path: 'tenants/:id', + name: 'TenantDetail', + 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: 'tenant-wechat-apps', + name: 'TenantWechatApps', + component: () => import('@/views/tenant-wechat-apps/index.vue'), + meta: { title: '企微应用', icon: 'ChatDotRound' } + }, + { + path: 'app-config', + name: 'AppConfig', + component: () => import('@/views/app-config/index.vue'), + meta: { title: '租户应用配置', icon: 'Setting' } + }, + { + path: 'stats', + name: 'Stats', + component: () => import('@/views/stats/index.vue'), + meta: { title: '统计分析', icon: 'TrendCharts' } + }, + { + path: 'logs', + name: 'Logs', + component: () => import('@/views/logs/index.vue'), + meta: { title: '日志查看', icon: 'Document' } + }, + { + path: 'users', + name: 'Users', + component: () => import('@/views/users/index.vue'), + meta: { title: '用户管理', icon: 'User', role: 'admin' } + } + ] + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + // 设置页面标题 + document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理' + + // 检查登录状态 + const authStore = useAuthStore() + + if (to.meta.public) { + next() + } else if (!authStore.isLoggedIn) { + next('/login') + } else if (to.meta.role && authStore.user?.role !== to.meta.role) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/views/app-config/index.vue b/frontend/src/views/app-config/index.vue index 34b6276..5c0e7ce 100644 --- a/frontend/src/views/app-config/index.vue +++ b/frontend/src/views/app-config/index.vue @@ -19,6 +19,10 @@ const query = reactive({ // 应用列表(从应用管理获取) const appList = ref([]) const appToolsMap = ref({}) // app_code -> tools[] +const appRequireJssdk = ref({}) // app_code -> require_jssdk + +// 企微应用列表(按租户) +const wechatAppList = ref([]) // 当前表单租户的企微应用列表 // 对话框 const dialogVisible = ref(false) @@ -29,12 +33,15 @@ const form = reactive({ tenant_id: '', app_code: 'tools', app_name: '', - wechat_corp_id: '', - wechat_agent_id: '', - wechat_secret: '', + wechat_app_id: null, // 关联的企微应用ID allowed_tools: [] }) +// 当前选择的应用是否需要 JS-SDK +const currentAppRequireJssdk = computed(() => { + return appRequireJssdk.value[form.app_code] || false +}) + // 根据选择的应用获取工具选项 const toolOptions = computed(() => { const tools = appToolsMap.value[form.app_code] || [] @@ -66,11 +73,13 @@ const urlInfo = ref({}) async function fetchApps() { try { - const res = await api.get('/api/apps/all') - appList.value = res.data || [] + const res = await api.get('/api/apps', { params: { size: 100 } }) + const apps = res.data.items || [] + appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name })) - // 获取每个应用的工具列表 - for (const app of appList.value) { + // 获取每个应用的工具列表和 JS-SDK 要求 + for (const app of apps) { + appRequireJssdk.value[app.app_code] = app.require_jssdk || false try { const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`) appToolsMap.value[app.app_code] = toolsRes.data || [] @@ -83,6 +92,19 @@ async function fetchApps() { } } +async function fetchWechatApps(tenantId) { + if (!tenantId) { + wechatAppList.value = [] + return + } + try { + const res = await api.get(`/api/tenant-wechat-apps/by-tenant/${tenantId}`) + wechatAppList.value = res.data || [] + } catch (e) { + wechatAppList.value = [] + } +} + async function fetchList() { loading.value = true try { @@ -113,37 +135,38 @@ function handleCreate() { tenant_id: '', app_code: 'tools', app_name: '', - wechat_corp_id: '', - wechat_agent_id: '', - wechat_secret: '', + wechat_app_id: null, allowed_tools: [] }) + wechatAppList.value = [] dialogVisible.value = true } -function handleEdit(row) { +async function handleEdit(row) { editingId.value = row.id dialogTitle.value = '编辑应用配置' Object.assign(form, { tenant_id: row.tenant_id, app_code: row.app_code, app_name: row.app_name || '', - wechat_corp_id: row.wechat_corp_id || '', - wechat_agent_id: row.wechat_agent_id || '', - wechat_secret: '', // 不回显密钥 + wechat_app_id: row.wechat_app_id || null, allowed_tools: row.allowed_tools || [] }) + // 获取该租户的企微应用列表 + await fetchWechatApps(row.tenant_id) dialogVisible.value = true } +// 租户ID变化时重新获取企微应用列表 +async function handleTenantChange() { + form.wechat_app_id = null + await fetchWechatApps(form.tenant_id) +} + async function handleSubmit() { await formRef.value.validate() const data = { ...form } - // 如果没有输入新密钥,不传这个字段 - if (!data.wechat_secret) { - delete data.wechat_secret - } try { if (editingId.value) { @@ -188,21 +211,6 @@ async function handleRegenerateToken(row) { } } -async function handleViewSecret(row) { - try { - const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`) - if (res.data.wechat_secret) { - ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', { - confirmButtonText: '关闭' - }) - } else { - ElMessage.info('未配置微信 Secret') - } - } catch (e) { - // 错误已在拦截器处理 - } -} - // 生成链接功能 function handleShowUrl(row) { currentRow.value = row @@ -319,12 +327,12 @@ onMounted(() => { - - - + @@ -341,12 +349,11 @@ onMounted(() => { ... - + @@ -367,7 +374,12 @@ onMounted(() => { - + @@ -379,16 +391,25 @@ onMounted(() => { - 企业微信配置 + 企业微信关联 - - - - - - - - + + + + +
+ 该租户暂无企微应用,请先在「企微应用」中配置 +
权限配置 diff --git a/frontend/src/views/apps/index.vue b/frontend/src/views/apps/index.vue index 5b47a5e..7a38a5d 100644 --- a/frontend/src/views/apps/index.vue +++ b/frontend/src/views/apps/index.vue @@ -1,302 +1,316 @@ - - - - - + + + + + diff --git a/frontend/src/views/tenant-wechat-apps/index.vue b/frontend/src/views/tenant-wechat-apps/index.vue new file mode 100644 index 0000000..a22fb36 --- /dev/null +++ b/frontend/src/views/tenant-wechat-apps/index.vue @@ -0,0 +1,238 @@ + + +