PMFlow/backend/app/api/feishu.py

363 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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