feat: 新增睿美云对接模块
All checks were successful
continuous-integration/drone/push Build is passing

- 扩展 ToolConfig 配置类型,新增 external_api 类型
- 实现接口注册表,包含 90+ 睿美云开放接口定义
- 实现 TPOS SHA256WithRSA 签名鉴权
- 实现睿美云 API 客户端,支持多租户配置
- 新增代理路由 /api/ruimeiyun/call/{api_name}
- 支持接口权限控制和健康检查
This commit is contained in:
2026-01-30 17:27:58 +08:00
parent c1ba17f809
commit afcf30b519
23 changed files with 6290 additions and 4648 deletions

View File

@@ -1,211 +1,211 @@
"""通知渠道API路由"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.notification_channel import TaskNotifyChannel
router = APIRouter(prefix="/api/notification-channels", tags=["notification-channels"])
# ==================== Schemas ====================
class ChannelCreate(BaseModel):
tenant_id: str
channel_name: str
channel_type: str # dingtalk_bot, wecom_bot
webhook_url: str
sign_secret: Optional[str] = None # 钉钉加签密钥
description: Optional[str] = None
class ChannelUpdate(BaseModel):
channel_name: Optional[str] = None
channel_type: Optional[str] = None
webhook_url: Optional[str] = None
sign_secret: Optional[str] = None
description: Optional[str] = None
is_enabled: Optional[bool] = None
# ==================== CRUD ====================
@router.get("")
async def list_channels(
tenant_id: Optional[str] = None,
channel_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
query = db.query(TaskNotifyChannel)
if tenant_id:
query = query.filter(TaskNotifyChannel.tenant_id == tenant_id)
if channel_type:
query = query.filter(TaskNotifyChannel.channel_type == channel_type)
if is_enabled is not None:
query = query.filter(TaskNotifyChannel.is_enabled == is_enabled)
total = query.count()
items = query.order_by(desc(TaskNotifyChannel.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_channel(c) for c in items]
}
@router.get("/{channel_id}")
async def get_channel(channel_id: int, db: Session = Depends(get_db)):
"""获取渠道详情"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
return format_channel(channel)
@router.post("")
async def create_channel(data: ChannelCreate, db: Session = Depends(get_db)):
"""创建通知渠道"""
channel = TaskNotifyChannel(
tenant_id=data.tenant_id,
channel_name=data.channel_name,
channel_type=data.channel_type,
webhook_url=data.webhook_url,
sign_secret=data.sign_secret,
description=data.description,
is_enabled=True
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/{channel_id}")
async def update_channel(channel_id: int, data: ChannelUpdate, db: Session = Depends(get_db)):
"""更新通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
if data.channel_name is not None:
channel.channel_name = data.channel_name
if data.channel_type is not None:
channel.channel_type = data.channel_type
if data.webhook_url is not None:
channel.webhook_url = data.webhook_url
if data.sign_secret is not None:
channel.sign_secret = data.sign_secret
if data.description is not None:
channel.description = data.description
if data.is_enabled is not None:
channel.is_enabled = data.is_enabled
db.commit()
return {"success": True}
@router.delete("/{channel_id}")
async def delete_channel(channel_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
@router.post("/{channel_id}/test")
async def test_channel(channel_id: int, db: Session = Depends(get_db)):
"""测试通知渠道"""
import httpx
import time
import hmac
import hashlib
import base64
import urllib.parse
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
test_content = f"**测试消息**\n\n渠道名称: {channel.channel_name}\n发送时间: 测试中..."
try:
url = channel.webhook_url
if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
# 拼接签名参数
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = {
"msgtype": "markdown",
"markdown": {
"title": "渠道测试",
"text": test_content
}
}
else: # wecom_bot
payload = {
"msgtype": "markdown",
"markdown": {
"content": test_content
}
}
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload)
result = response.json()
# 钉钉返回 errcode=0企微返回 errcode=0
if result.get('errcode') == 0:
return {"success": True, "message": "发送成功"}
else:
return {"success": False, "message": f"发送失败: {result}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {str(e)}"}
# ==================== Helpers ====================
def format_channel(channel: TaskNotifyChannel) -> dict:
"""格式化渠道数据"""
return {
"id": channel.id,
"tenant_id": channel.tenant_id,
"channel_name": channel.channel_name,
"channel_type": channel.channel_type,
"webhook_url": channel.webhook_url,
"sign_secret": channel.sign_secret,
"description": channel.description,
"is_enabled": channel.is_enabled,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}
"""通知渠道API路由"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..models.notification_channel import TaskNotifyChannel
router = APIRouter(prefix="/api/notification-channels", tags=["notification-channels"])
# ==================== Schemas ====================
class ChannelCreate(BaseModel):
tenant_id: str
channel_name: str
channel_type: str # dingtalk_bot, wecom_bot
webhook_url: str
sign_secret: Optional[str] = None # 钉钉加签密钥
description: Optional[str] = None
class ChannelUpdate(BaseModel):
channel_name: Optional[str] = None
channel_type: Optional[str] = None
webhook_url: Optional[str] = None
sign_secret: Optional[str] = None
description: Optional[str] = None
is_enabled: Optional[bool] = None
# ==================== CRUD ====================
@router.get("")
async def list_channels(
tenant_id: Optional[str] = None,
channel_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db)
):
"""获取通知渠道列表"""
query = db.query(TaskNotifyChannel)
if tenant_id:
query = query.filter(TaskNotifyChannel.tenant_id == tenant_id)
if channel_type:
query = query.filter(TaskNotifyChannel.channel_type == channel_type)
if is_enabled is not None:
query = query.filter(TaskNotifyChannel.is_enabled == is_enabled)
total = query.count()
items = query.order_by(desc(TaskNotifyChannel.created_at)).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"items": [format_channel(c) for c in items]
}
@router.get("/{channel_id}")
async def get_channel(channel_id: int, db: Session = Depends(get_db)):
"""获取渠道详情"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
return format_channel(channel)
@router.post("")
async def create_channel(data: ChannelCreate, db: Session = Depends(get_db)):
"""创建通知渠道"""
channel = TaskNotifyChannel(
tenant_id=data.tenant_id,
channel_name=data.channel_name,
channel_type=data.channel_type,
webhook_url=data.webhook_url,
sign_secret=data.sign_secret,
description=data.description,
is_enabled=True
)
db.add(channel)
db.commit()
db.refresh(channel)
return {"success": True, "id": channel.id}
@router.put("/{channel_id}")
async def update_channel(channel_id: int, data: ChannelUpdate, db: Session = Depends(get_db)):
"""更新通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
if data.channel_name is not None:
channel.channel_name = data.channel_name
if data.channel_type is not None:
channel.channel_type = data.channel_type
if data.webhook_url is not None:
channel.webhook_url = data.webhook_url
if data.sign_secret is not None:
channel.sign_secret = data.sign_secret
if data.description is not None:
channel.description = data.description
if data.is_enabled is not None:
channel.is_enabled = data.is_enabled
db.commit()
return {"success": True}
@router.delete("/{channel_id}")
async def delete_channel(channel_id: int, db: Session = Depends(get_db)):
"""删除通知渠道"""
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
db.delete(channel)
db.commit()
return {"success": True}
@router.post("/{channel_id}/test")
async def test_channel(channel_id: int, db: Session = Depends(get_db)):
"""测试通知渠道"""
import httpx
import time
import hmac
import hashlib
import base64
import urllib.parse
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel:
raise HTTPException(status_code=404, detail="渠道不存在")
test_content = f"**测试消息**\n\n渠道名称: {channel.channel_name}\n发送时间: 测试中..."
try:
url = channel.webhook_url
if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
# 拼接签名参数
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = {
"msgtype": "markdown",
"markdown": {
"title": "渠道测试",
"text": test_content
}
}
else: # wecom_bot
payload = {
"msgtype": "markdown",
"markdown": {
"content": test_content
}
}
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json=payload)
result = response.json()
# 钉钉返回 errcode=0企微返回 errcode=0
if result.get('errcode') == 0:
return {"success": True, "message": "发送成功"}
else:
return {"success": False, "message": f"发送失败: {result}"}
except Exception as e:
return {"success": False, "message": f"发送失败: {str(e)}"}
# ==================== Helpers ====================
def format_channel(channel: TaskNotifyChannel) -> dict:
"""格式化渠道数据"""
return {
"id": channel.id,
"tenant_id": channel.tenant_id,
"channel_name": channel.channel_name,
"channel_type": channel.channel_type,
"webhook_url": channel.webhook_url,
"sign_secret": channel.sign_secret,
"description": channel.description,
"is_enabled": channel.is_enabled,
"created_at": channel.created_at,
"updated_at": channel.updated_at
}

View File

@@ -0,0 +1,287 @@
"""
睿美云代理路由
提供统一的睿美云接口代理能力,支持:
- 多租户配置隔离
- 接口权限控制
- 统一日志记录
- 错误处理
"""
import logging
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.ruimeiyun import RuimeiyunClient, RUIMEIYUN_APIS, get_api_definition
from ..services.ruimeiyun.client import RuimeiyunError
from ..services.ruimeiyun.registry import get_all_modules, get_api_list_by_module, get_api_summary
from .auth import get_current_user
from ..models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ruimeiyun", tags=["睿美云代理"])
# ========================================
# Schemas
# ========================================
class RuimeiyunCallRequest(BaseModel):
"""睿美云接口调用请求"""
params: Optional[Dict[str, Any]] = None # URL 参数
body: Optional[Dict[str, Any]] = None # 请求体
class RuimeiyunRawCallRequest(BaseModel):
"""睿美云原始接口调用请求"""
method: str # HTTP 方法
path: str # API 路径
params: Optional[Dict[str, Any]] = None
body: Optional[Dict[str, Any]] = None
# ========================================
# API Endpoints
# ========================================
@router.post("/call/{api_name}")
async def call_ruimeiyun_api(
api_name: str,
request: RuimeiyunCallRequest,
tenant_id: str = Query(..., description="租户ID"),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
调用睿美云接口(通过接口名称)
Args:
api_name: 接口名称,如 customer.search, order.list
request: 请求参数
tenant_id: 租户ID
Returns:
睿美云接口返回的数据
示例:
POST /api/ruimeiyun/call/customer.search?tenant_id=xxx
Body: {"params": {"keyword": "13800138000", "page": 1, "size": 20}}
"""
try:
client = RuimeiyunClient(tenant_id, db)
result = await client.call(
api_name=api_name,
params=request.params,
body=request.body
)
if result.success:
return {
"success": True,
"data": result.data
}
else:
return {
"success": False,
"error": result.error,
"raw": result.raw_response
}
except RuimeiyunError as e:
logger.error(f"睿美云调用失败: {api_name}, {e}")
raise HTTPException(status_code=e.status_code, detail=str(e))
except Exception as e:
logger.exception(f"睿美云调用异常: {api_name}")
raise HTTPException(status_code=500, detail=f"调用失败: {str(e)}")
@router.post("/call-raw")
async def call_ruimeiyun_raw(
request: RuimeiyunRawCallRequest,
tenant_id: str = Query(..., description="租户ID"),
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
直接调用睿美云接口(通过路径)
用于调用未在注册表中定义的接口
Args:
request: 请求参数(包含 method, path, params, body
tenant_id: 租户ID
Returns:
睿美云接口返回的数据
示例:
POST /api/ruimeiyun/call-raw?tenant_id=xxx
Body: {
"method": "GET",
"path": "/api/v1/tpos/customer/customer-search",
"params": {"keyword": "13800138000"}
}
"""
try:
client = RuimeiyunClient(tenant_id, db)
result = await client.call_raw(
method=request.method,
path=request.path,
params=request.params,
body=request.body
)
if result.success:
return {
"success": True,
"data": result.data
}
else:
return {
"success": False,
"error": result.error,
"raw": result.raw_response
}
except RuimeiyunError as e:
logger.error(f"睿美云调用失败: {request.path}, {e}")
raise HTTPException(status_code=e.status_code, detail=str(e))
except Exception as e:
logger.exception(f"睿美云调用异常: {request.path}")
raise HTTPException(status_code=500, detail=f"调用失败: {str(e)}")
# ========================================
# 接口元数据
# ========================================
@router.get("/apis")
async def list_apis(
module: Optional[str] = None,
user: User = Depends(get_current_user)
):
"""
获取可用的接口列表
Args:
module: 模块名称(可选),如 customer, order
Returns:
接口列表
"""
if module:
apis = get_api_list_by_module(module)
return {
"module": module,
"count": len(apis),
"apis": apis
}
else:
return {
"count": len(RUIMEIYUN_APIS),
"summary": get_api_summary(),
"apis": RUIMEIYUN_APIS
}
@router.get("/apis/{api_name}")
async def get_api_info(
api_name: str,
user: User = Depends(get_current_user)
):
"""
获取接口详情
Args:
api_name: 接口名称,如 customer.search
Returns:
接口定义
"""
api_def = get_api_definition(api_name)
if not api_def:
raise HTTPException(status_code=404, detail=f"接口不存在: {api_name}")
return {
"name": api_name,
**api_def
}
@router.get("/modules")
async def list_modules(
user: User = Depends(get_current_user)
):
"""
获取所有模块列表
Returns:
模块名称列表和每个模块的接口数量
"""
modules = get_all_modules()
summary = get_api_summary()
return {
"modules": [
{"name": m, "count": summary.get(m, 0)}
for m in modules
]
}
# ========================================
# 健康检查
# ========================================
@router.get("/health/{tenant_id}")
async def check_ruimeiyun_health(
tenant_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
检查租户的睿美云连接状态
Args:
tenant_id: 租户ID
Returns:
连接状态信息
"""
try:
client = RuimeiyunClient(tenant_id, db)
# 调用门店列表接口测试连接
result = await client.call("tenant.list")
if result.success:
return {
"status": "connected",
"tenant_id": tenant_id,
"base_url": client.config.base_url,
"account": client.config.account,
"message": "连接正常"
}
else:
return {
"status": "error",
"tenant_id": tenant_id,
"message": result.error
}
except RuimeiyunError as e:
return {
"status": "error",
"tenant_id": tenant_id,
"message": str(e)
}
except Exception as e:
return {
"status": "error",
"tenant_id": tenant_id,
"message": f"检查失败: {str(e)}"
}

File diff suppressed because it is too large Load Diff

View File

@@ -354,7 +354,8 @@ async def get_config_types():
{"code": "datasource", "name": "数据源配置", "description": "数据库连接等"},
{"code": "jssdk", "name": "JS-SDK 配置", "description": "企微侧边栏等"},
{"code": "webhook", "name": "Webhook 配置", "description": "n8n 工作流地址等"},
{"code": "params", "name": "工具参数", "description": "各工具的自定义参数"}
{"code": "params", "name": "工具参数", "description": "各工具的自定义参数"},
{"code": "external_api", "name": "外部API配置", "description": "睿美云等外部系统对接"}
]
}
@@ -387,5 +388,11 @@ async def get_config_keys():
{"key": "default_data_tenant_id", "name": "默认数据租户ID", "encrypted": False},
{"key": "enable_deep_thinking", "name": "启用深度思考", "encrypted": False},
{"key": "max_history_rounds", "name": "最大历史轮数", "encrypted": False}
],
"external_api": [
{"key": "ruimeiyun_base_url", "name": "睿美云 API 地址", "encrypted": False},
{"key": "ruimeiyun_account", "name": "睿美云 TPOS 账号", "encrypted": False},
{"key": "ruimeiyun_private_key", "name": "睿美云 RSA 私钥", "encrypted": True},
{"key": "ruimeiyun_allowed_apis", "name": "允许的接口列表(JSON)", "encrypted": False}
]
}