feat: 租户级企微配置改造
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_tenant_wechat_apps 表(租户企微应用配置)
- platform_apps 增加 require_jssdk 字段
- platform_tenant_apps 增加 wechat_app_id 关联字段
- 新增企微应用管理 API 和页面
- 应用管理页面增加 JS-SDK 开关
- 应用配置页面增加企微应用选择
This commit is contained in:
111
2026-01-23 19:05:00 +08:00
parent f815b29c51
commit c4bd7c8251
13 changed files with 1198 additions and 580 deletions

View File

@@ -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"
}

View File

@@ -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"
]

View File

@@ -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)

View File

@@ -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)) # 访问令牌(长期有效)

View File

@@ -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)

View File

@@ -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

View File

@@ -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 [],

View File

@@ -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
}