PMFlow/backend/app/api/feishu.py

377 lines
12 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 - 飞书集成接口。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
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
from ..services.feishu_long_connection import get_feishu_long_connection_manager
router = APIRouter(prefix="/feishu", tags=["Feishu Integration"])
def _build_long_connection_failure(long_status: Dict[str, Any]) -> tuple[str, Optional[str]]:
code = str(long_status.get("last_error_code") or "").strip().lower()
last_error = str(long_status.get("last_error") or "").strip()
retryable = bool(long_status.get("last_error_retryable"))
if code in {"network_error", "thread_exited"} and not retryable:
return (
"长连接自动重连次数已耗尽,请检查网络/凭证后手动重启飞书长连接",
last_error or None,
)
if code == "auth_failed":
return (
"长连接鉴权失败,请检查 App ID / App Secret、应用凭证是否已生效,以及飞书应用权限是否完整",
last_error or None,
)
if code == "network_error":
return (
"长连接预检失败,后端到飞书开放平台的网络或 TLS 证书链异常",
last_error or None,
)
if code == "sdk_missing":
return ("长连接 SDK 未安装(缺少 lark-oapi)", None)
if code == "thread_exited":
return ("长连接已退出,系统正在等待下一次自动重连窗口", last_error or None)
if last_error:
return ("长连接未建立,请检查配置与后端日志", last_error)
return ("长连接未建立,请检查配置后重试", None)
# ==================== Request/Response Models ====================
class FeishuConfigRequest(BaseModel):
"""飞书配置请求"""
app_id: str = Field(..., description="飞书 App ID")
app_secret: str = Field(..., description="飞书 App Secret")
encrypt_key: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
verification_token: Optional[str] = Field(None, description="已废弃字段,保留仅为兼容旧客户端")
event_mode: str = Field("long_connection", description="事件接收模式,现仅支持 long_connection")
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
获取步骤:
1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 在「凭证与基础信息」页面获取 App ID 和 App Secret
4. 添加权限:im:message, im:message:group_at_msg, im:chat
5. 订阅事件:接收消息 v1 (message.receive_v1)
"""
# 检查用户权限
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以配置飞书集成")
try:
event_mode = "long_connection"
service = get_feishu_service(db)
await service.save_config(
app_id=request.app_id,
app_secret=request.app_secret,
encrypt_key="",
verification_token="",
event_mode=event_mode,
tenant_id=tenant_id
)
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
next_steps = [
"1. 飞书应用事件订阅至少包含:接收消息 v1 (message.receive_v1)",
"2. 当前模式:long_connection",
"3. 后端已自动重启飞书长连接",
]
return {
'status': 'success',
'message': '飞书配置已保存,并已切换到长连接模式',
'next_steps': next_steps
}
except HTTPException:
raise
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)
manager = get_feishu_long_connection_manager()
can_view_secrets = has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"])
return {
'is_configured': service.is_configured(),
'app_id': service.app_id,
'app_secret': service.app_secret if can_view_secrets else None,
'event_mode': "long_connection",
'long_connection': manager.status() if manager else None,
}
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.type == 'feishu'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.is_active = False
await db.commit()
manager = get_feishu_long_connection_manager()
if manager:
await manager.restart()
return {'status': 'success', 'message': '飞书配置已删除'}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"删除配置失败: {str(e)}"
)
@router.get("/long-connection/status")
async def feishu_long_connection_status(
current_user: User = Depends(get_current_user),
):
"""查询飞书长连接状态。"""
manager = get_feishu_long_connection_manager()
if not manager:
return {
"status": "error",
"message": "Feishu 长连接管理器未初始化",
"mode": "long_connection",
"data": None,
}
return {
"status": "success",
"mode": "long_connection",
"data": manager.status(),
}
@router.post("/long-connection/restart")
async def restart_feishu_long_connection(
current_user: User = Depends(get_current_user),
):
"""手动重启飞书长连接。"""
if not has_any_role(str(current_user.role or ""), ["ADMIN", "BOSS"]):
raise HTTPException(status_code=403, detail="只有管理员可以重启飞书长连接")
manager = get_feishu_long_connection_manager()
if not manager:
raise HTTPException(status_code=500, detail="Feishu 长连接管理器未初始化")
await manager.restart()
return {
"status": "success",
"message": "Feishu 长连接已重启",
"mode": "long_connection",
"data": manager.status(),
}
# ==================== 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)
manager = get_feishu_long_connection_manager()
if not service.is_configured():
return {
'status': 'error',
'message': '飞书未配置'
}
# 尝试获取 tenant_access_token 测试连接
try:
access_token = await service._get_tenant_access_token()
event_mode = "long_connection"
long_status = manager.status() if manager else None
if not long_status:
return {
'status': 'error',
'message': '长连接管理器未初始化',
'event_mode': event_mode,
}
if not long_status.get("sdk_available"):
return {
'status': 'error',
'message': '长连接 SDK 未安装(缺少 lark-oapi)',
'event_mode': event_mode,
'long_connection': long_status,
}
if not long_status.get("thread_alive"):
message, detail = _build_long_connection_failure(long_status)
return {
'status': 'error',
'message': message,
'event_mode': event_mode,
'long_connection': long_status,
'detail': detail,
}
return {
'status': 'success',
'message': '飞书连接正常',
'has_token': bool(access_token),
'mode': 'long_connection',
'event_mode': event_mode,
'long_connection': long_status,
}
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)}"
)