363 lines
11 KiB
Python
363 lines
11 KiB
Python
"""
|
||
Feishu Integration API - 飞书集成接口
|
||
|
||
功能:
|
||
1. 配置飞书应用凭证
|
||
2. 验证 URL 和处理事件
|
||
3. 发送消息到飞书群
|
||
4. 查询集成状态
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Request, BackgroundTasks
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from pydantic import BaseModel, Field
|
||
from typing import List, Optional, Dict, Any
|
||
from datetime import datetime, timezone
|
||
import json
|
||
|
||
from ..database import get_db
|
||
from ..dependencies.auth import get_current_user
|
||
from ..dependencies.tenant import get_tenant_id
|
||
from ..models import User
|
||
from ..services.auth import has_any_role
|
||
from ..services.feishu_integration import get_feishu_service
|
||
|
||
|
||
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
|
||
|
||
|
||
# ==================== Request/Response Models ====================
|
||
|
||
class FeishuConfigRequest(BaseModel):
|
||
"""飞书配置请求"""
|
||
app_id: str = Field(..., description="飞书 App ID")
|
||
app_secret: str = Field(..., description="飞书 App Secret")
|
||
encrypt_key: str = Field(..., description="事件订阅加密密钥")
|
||
verification_token: str = Field(..., description="事件订阅验证令牌")
|
||
|
||
|
||
class FeishuSendMessageRequest(BaseModel):
|
||
"""发送消息请求"""
|
||
chat_id: str = Field(..., description="群聊 ID")
|
||
message: str = Field(..., description="消息内容")
|
||
|
||
|
||
class FeishuSendCardRequest(BaseModel):
|
||
"""发送卡片请求"""
|
||
chat_id: str = Field(..., description="群聊 ID")
|
||
title: str = Field(..., description="卡片标题")
|
||
content: List[Dict[str, Any]] = Field(..., description="卡片内容")
|
||
|
||
|
||
# ==================== Configuration Endpoints ====================
|
||
|
||
@router.post("/config")
|
||
async def save_feishu_config(
|
||
request: FeishuConfigRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""
|
||
保存飞书配置
|
||
|
||
配置信息说明:
|
||
- app_id: 飞书应用的 App ID,从飞书开放平台获取
|
||
- app_secret: 飞书应用的 App Secret
|
||
- encrypt_key: 事件订阅的加密 Key
|
||
- verification_token: 事件订阅的验证 Token
|
||
|
||
获取步骤:
|
||
1. 访问 https://open.feishu.cn/
|
||
2. 创建企业自建应用
|
||
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
|
||
4. 在「事件订阅」配置 Encrypt Key 和 Verification Token
|
||
5. 添加权限:im:message, im:message:group_at_msg, im:chat
|
||
6. 订阅事件:接收消息 v1 (message.receive_v1)
|
||
"""
|
||
# 检查用户权限
|
||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
|
||
|
||
try:
|
||
service = get_feishu_service(db)
|
||
await service.save_config(
|
||
app_id=request.app_id,
|
||
app_secret=request.app_secret,
|
||
encrypt_key=request.encrypt_key,
|
||
verification_token=request.verification_token,
|
||
tenant_id=tenant_id
|
||
)
|
||
|
||
return {
|
||
'status': 'success',
|
||
'message': '飞书配置已保存',
|
||
'next_steps': [
|
||
'1. 在飞书开放平台配置事件订阅 URL',
|
||
f'2. URL: https://your-domain.com/api/feishu/webhook',
|
||
'3. 点击验证按钮完成 URL 验证'
|
||
]
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"保存配置失败: {str(e)}"
|
||
)
|
||
|
||
|
||
@router.get("/config")
|
||
async def get_feishu_config(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""获取飞书配置状态(不返回敏感信息)"""
|
||
try:
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id)
|
||
|
||
return {
|
||
'is_configured': service.is_configured(),
|
||
'app_id': service.app_id if service.is_configured() else None,
|
||
'has_encrypt_key': bool(service.encrypt_key),
|
||
'has_verification_token': bool(service.verification_token)
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"获取配置失败: {str(e)}"
|
||
)
|
||
|
||
|
||
@router.delete("/config")
|
||
async def delete_feishu_config(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""删除飞书配置"""
|
||
from ..models import Integration
|
||
from sqlalchemy.future import select
|
||
|
||
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
|
||
raise HTTPException(status_code=403, detail="只有管理员可以删除配置")
|
||
|
||
try:
|
||
result = await db.execute(
|
||
select(Integration).where(
|
||
Integration.tenant_id == tenant_id,
|
||
Integration.provider == 'feishu'
|
||
)
|
||
)
|
||
integration = result.scalar_one_or_none()
|
||
|
||
if integration:
|
||
integration.is_active = False
|
||
await db.commit()
|
||
|
||
return {'status': 'success', 'message': '飞书配置已删除'}
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"删除配置失败: {str(e)}"
|
||
)
|
||
|
||
|
||
# ==================== Webhook Endpoints ====================
|
||
|
||
@router.get("/webhook")
|
||
async def verify_webhook_url(
|
||
challenge: str,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
验证飞书事件订阅 URL
|
||
|
||
飞书会发送 GET 请求来验证 URL 有效性
|
||
请求参数: challenge
|
||
返回: {"challenge": challenge}
|
||
|
||
注意:此端点不需要身份验证,因为飞书不会发送认证令牌
|
||
"""
|
||
try:
|
||
service = get_feishu_service(db)
|
||
# 使用默认租户 ID 1
|
||
await service.load_config(tenant_id=1)
|
||
|
||
if not service.is_configured():
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="请先配置飞书"
|
||
)
|
||
|
||
challenge_response = service.verify_url(challenge)
|
||
|
||
return {
|
||
"challenge": challenge_response
|
||
}
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/webhook")
|
||
async def handle_feishu_event(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
处理飞书事件推送
|
||
|
||
飞书会发送 POST 请求推送事件
|
||
需要验证签名确保请求来自飞书
|
||
"""
|
||
try:
|
||
# 获取请求头
|
||
timestamp = request.headers.get('X-Lark-Request-Timestamp', '')
|
||
nonce = request.headers.get('X-Lark-Request-Nonce', '')
|
||
signature = request.headers.get('X-Lark-Signature', '')
|
||
|
||
# 读取请求体
|
||
body_bytes = await request.body()
|
||
body = body_bytes.decode('utf-8')
|
||
|
||
# 解析 JSON 获取 tenant_id(如果有多租户)
|
||
body_json = json.loads(body) if body else {}
|
||
tenant_id = 1 # 默认租户
|
||
|
||
# 加载飞书配置
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id)
|
||
|
||
# 验证签名
|
||
if not service.verify_signature(timestamp, nonce, body, signature):
|
||
raise HTTPException(status_code=401, detail="签名验证失败")
|
||
|
||
# 解析事件
|
||
envelope = json.loads(body)
|
||
event_data = envelope.get('event', {})
|
||
|
||
# 处理事件
|
||
result = await service.handle_message_event(event_data)
|
||
|
||
return result
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"[Feishu] Webhook error: {e}")
|
||
# 飞书要求返回成功状态,避免重复推送
|
||
return {'code': 0, 'msg': 'received'}
|
||
|
||
|
||
# ==================== Message Endpoints ====================
|
||
|
||
@router.post("/send-message")
|
||
async def send_feishu_message(
|
||
request: FeishuSendMessageRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""
|
||
发送文本消息到飞书群
|
||
|
||
用于发送简报、通知等
|
||
"""
|
||
try:
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id)
|
||
|
||
result = await service.send_message_to_group(
|
||
chat_id=request.chat_id,
|
||
message=request.message
|
||
)
|
||
|
||
return {
|
||
'status': 'success',
|
||
'result': result
|
||
}
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"发送消息失败: {str(e)}"
|
||
)
|
||
|
||
|
||
@router.post("/send-card")
|
||
async def send_feishu_card(
|
||
request: FeishuSendCardRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""
|
||
发送富文本卡片到飞书群
|
||
|
||
用于发送格式化的每日简报、风险预警等
|
||
"""
|
||
try:
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id)
|
||
|
||
result = await service.send_rich_card(
|
||
chat_id=request.chat_id,
|
||
title=request.title,
|
||
content=request.content
|
||
)
|
||
|
||
return {
|
||
'status': 'success',
|
||
'result': result
|
||
}
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"发送卡片失败: {str(e)}"
|
||
)
|
||
|
||
|
||
# ==================== Test Endpoints ====================
|
||
|
||
@router.post("/test-connection")
|
||
async def test_feishu_connection(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id)
|
||
):
|
||
"""测试飞书连接是否正常"""
|
||
try:
|
||
service = get_feishu_service(db)
|
||
await service.load_config(tenant_id)
|
||
|
||
if not service.is_configured():
|
||
return {
|
||
'status': 'error',
|
||
'message': '飞书未配置'
|
||
}
|
||
|
||
# 尝试获取 tenant_access_token 测试连接
|
||
try:
|
||
access_token = await service._get_tenant_access_token()
|
||
return {
|
||
'status': 'success',
|
||
'message': '飞书连接正常',
|
||
'has_token': bool(access_token)
|
||
}
|
||
except Exception as token_error:
|
||
return {
|
||
'status': 'error',
|
||
'message': f'获取 Token 失败: {str(token_error)}',
|
||
'hint': '请检查 App ID 和 App Secret 是否正确'
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"测试连接失败: {str(e)}"
|
||
)
|