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