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
}