#!/usr/bin/env python # -*- coding: utf-8 -*- """ 钉钉同步 API - 组织架构与用户同步管理接口 """ import asyncio import json import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from app.base_schema import ResponseModel from app.database import get_db from core.dingtalk_sync.schema import ( DeptTreeRequest, DingtalkSyncConfigUpdate, TestConnectionRequest, ) from core.dingtalk_sync.service import DingtalkSyncService logger = logging.getLogger(__name__) router = APIRouter(prefix="/dingtalk-sync", tags=["钉钉组织同步"]) @router.post("/test-connection", response_model=ResponseModel, summary="连接测试") async def test_connection(data: TestConnectionRequest): """ 测试钉钉连接 可传入临时凭证(保存前测试),不传则使用已保存配置 """ try: result = await DingtalkSyncService.test_connection( app_key=data.app_key, app_secret=data.app_secret, ) return ResponseModel(data=result, message="连接成功") except Exception as e: raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}") @router.get("/config", summary="获取同步配置") async def get_config(): """获取钉钉同步配置(敏感字段脱敏)""" from core.system_config.service import SystemConfigService return await SystemConfigService.get_group_config("sync_dingtalk", mask_secrets=True) @router.put("/config", response_model=ResponseModel, summary="保存同步配置") async def update_config( data: DingtalkSyncConfigUpdate, db: AsyncSession = Depends(get_db), ): """保存钉钉同步配置""" from core.system_config.service import SystemConfigService configs = {k: v for k, v in data.model_dump().items() if v is not None} updated = await SystemConfigService.update_group_config(db, "sync_dingtalk", configs) return ResponseModel(data=updated, message="保存成功") @router.post("/sync/dept", response_model=ResponseModel, summary="同步组织架构") async def sync_departments(): """手动触发部门同步(异步任务,立即返回 log_id)""" try: log_id = await DingtalkSyncService.start_sync_departments() asyncio.create_task(_safe_sync_task(DingtalkSyncService.sync_departments, log_id)) return ResponseModel(data={"log_id": log_id}, message="同步任务已提交") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"部门同步启动失败: {e}") raise HTTPException(status_code=500, detail=f"同步失败: {str(e)}") @router.post("/sync/user", response_model=ResponseModel, summary="同步用户") async def sync_users(): """手动触发用户同步(异步任务,立即返回 log_id)""" try: log_id = await DingtalkSyncService.start_sync_users() asyncio.create_task(_safe_sync_task(DingtalkSyncService.sync_users, log_id)) return ResponseModel(data={"log_id": log_id}, message="同步任务已提交") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"用户同步启动失败: {e}") raise HTTPException(status_code=500, detail=f"同步失败: {str(e)}") async def _safe_sync_task(sync_fn, log_id: str) -> None: """安全执行同步任务,捕获异常避免任务崩溃""" try: await sync_fn(log_id) except Exception as e: logger.error(f"后台同步任务异常 [log_id={log_id}]: {e}", exc_info=True) @router.get("/sync/status/{log_id}", response_model=ResponseModel, summary="查询同步任务状态") async def get_sync_status(log_id: str, db: AsyncSession = Depends(get_db)): """轮询查询单次同步任务的实时状态""" result = await DingtalkSyncService.get_sync_status(db, log_id) return ResponseModel(data=result) @router.get("/stats", summary="获取同步统计") async def get_sync_stats(db: AsyncSession = Depends(get_db)): """获取最新的部门/用户同步统计数据""" stats = await DingtalkSyncService.get_sync_stats(db) return stats @router.get("/logs", summary="获取同步日志") async def get_sync_logs( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): """获取同步日志列表""" return await DingtalkSyncService.get_sync_logs(db, page=page, page_size=page_size) @router.get("/stream/events", response_model=ResponseModel, summary="获取 Stream 增量事件日志") async def get_stream_events( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), ): """获取 Stream 增量同步事件日志(最新排序)""" result = await DingtalkSyncService.get_stream_events(db, page=page, page_size=page_size) return ResponseModel(data=result) @router.post("/dept-tree", summary="获取钉钉部门树") async def get_dept_tree(data: DeptTreeRequest): """ 从钉钉拉取部门树(用于选择同步范围) 可传入临时凭证,不传则使用已保存配置 """ try: tree = await DingtalkSyncService.get_dingtalk_dept_tree( app_key=data.app_key, app_secret=data.app_secret, ) return tree except Exception as e: raise HTTPException(status_code=400, detail=f"获取部门树失败: {str(e)}") # ==================== 回调相关接口 ==================== @router.post("/callback", summary="钉钉事件回调") async def dingtalk_callback( request: Request, msg_signature: str = Query(..., alias="msg_signature"), timestamp: str = Query(..., alias="timestamp"), nonce: str = Query(..., alias="nonce"), ): """ 钉钉事件订阅回调端点(无需认证) 钉钉在注册回调时、以及每次事件推送时都会 POST 到这个地址。 请求体: {"encrypt": "..."} Query params: msg_signature, timestamp, nonce """ from core.dingtalk_sync.callback_handler import DingtalkCallbackHandler try: crypto = await DingtalkCallbackHandler.get_crypto() except ValueError as e: logger.error(f"回调配置不完整: {e}") raise HTTPException(status_code=500, detail="回调配置不完整") body = await request.json() encrypt = body.get("encrypt", "") if not encrypt: raise HTTPException(status_code=400, detail="缺少 encrypt 字段") try: plaintext = crypto.handle_callback(msg_signature, timestamp, nonce, encrypt) except ValueError: raise HTTPException(status_code=403, detail="签名验证失败") event_data = json.loads(plaintext) event_type = event_data.get("EventType", "") logger.info(f"收到钉钉回调事件: {event_type}") # 异步处理事件(不阻塞响应) if event_type != "check_url": asyncio.create_task(_safe_handle_event(event_type, event_data)) return crypto.generate_success_response() async def _safe_handle_event(event_type: str, event_data: dict) -> None: """安全地处理回调事件,捕获异常避免任务崩溃""" from core.dingtalk_sync.callback_handler import DingtalkCallbackHandler try: await DingtalkCallbackHandler.handle_event(event_type, event_data) except Exception as e: logger.error(f"处理回调事件失败 [{event_type}]: {e}", exc_info=True) @router.post("/callback/register", response_model=ResponseModel, summary="注册事件回调") async def register_callback(): """ 向钉钉注册回调地址,订阅通讯录变更事件 需要先在配置中填写 callback_token 和 callback_aes_key """ try: result = await DingtalkSyncService.register_callback() return ResponseModel(data=result, message="注册回调成功") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"注册回调失败: {e}") raise HTTPException(status_code=500, detail=f"注册回调失败: {str(e)}") @router.delete("/callback/register", response_model=ResponseModel, summary="删除事件回调") async def delete_callback(): """删除已注册的钉钉事件回调""" try: await DingtalkSyncService.delete_callback() return ResponseModel(message="删除回调成功") except Exception as e: logger.error(f"删除回调失败: {e}") raise HTTPException(status_code=500, detail=f"删除回调失败: {str(e)}") @router.get("/callback/status", response_model=ResponseModel, summary="查询回调状态") async def get_callback_status(): """查询当前钉钉回调注册状态""" try: result = await DingtalkSyncService.get_callback_status() return ResponseModel(data=result, message="查询成功") except Exception as e: return ResponseModel(data={"registered": False}, message="未注册或查询失败") # ==================== Stream 模式相关接口 ==================== @router.get("/stream/status", response_model=ResponseModel, summary="查询 Stream 模式状态") async def get_stream_status(): """查询钉钉 Stream 模式连接状态和事件统计""" from core.dingtalk_sync.stream_client import DingtalkStreamManager return ResponseModel(data=DingtalkStreamManager.get_event_stats(), message="查询成功")