377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""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)}"
|
||
)
|