#!/usr/bin/env python # -*- coding: utf-8 -*- """ 钉钉回调事件处理器 处理钉钉事件订阅推送的增量变更事件: - 部门:org_dept_create / org_dept_modify / org_dept_remove - 用户:user_add_org / user_modify_org / user_leave_org / user_active_org """ import logging from datetime import datetime from typing import Any, Dict, List from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config_manager import config_manager from app.database import AsyncSessionLocal from core.dept.model import Dept from core.dingtalk_sync.client import DingtalkClient from core.dingtalk_sync.crypto import DingtalkCrypto from core.dingtalk_sync.model import DingtalkStreamEventLog from core.user.model import User from core.user.service import UserService logger = logging.getLogger(__name__) # 部门事件 DEPT_EVENTS = {"org_dept_create", "org_dept_modify", "org_dept_remove"} # 用户事件 USER_EVENTS = {"user_add_org", "user_modify_org", "user_leave_org", "user_active_org"} class DingtalkCallbackHandler: """钉钉回调事件处理器""" @classmethod async def get_crypto(cls) -> DingtalkCrypto: """从配置获取加解密实例""" config = await config_manager.get_group("sync_dingtalk") token = config.get("callback_token") aes_key = config.get("callback_aes_key") corp_id = config.get("corp_id") if not all([token, aes_key, corp_id]): raise ValueError("钉钉回调配置不完整(callback_token / callback_aes_key / corp_id)") return DingtalkCrypto(token=token, aes_key=aes_key, corp_id=corp_id) @classmethod async def get_client(cls) -> DingtalkClient: """从配置获取钉钉客户端""" config = await config_manager.get_group("sync_dingtalk") app_key = config.get("app_key") app_secret = config.get("app_secret") if not app_key or not app_secret: raise ValueError("钉钉同步凭证未配置") return DingtalkClient(app_key=app_key, app_secret=app_secret) @classmethod async def handle_event(cls, event_type: str, event_data: Dict[str, Any]) -> None: """ 分发事件到对应处理方法 event_data 格式因事件类型不同而异,通常包含变更对象的 ID 列表 """ config = await config_manager.get_group("sync_dingtalk") enable_dept = config.get("enable_dept_event") == "true" enable_user = config.get("enable_user_event") == "true" if event_type in DEPT_EVENTS and enable_dept: await cls._handle_dept_event(event_type, event_data) elif event_type in USER_EVENTS and enable_user: await cls._handle_user_event(event_type, event_data) elif event_type == "check_url": pass # 注册回调时的验证请求,不需要处理 else: logger.info(f"忽略未处理的事件: {event_type}") # ==================== 部门事件 ==================== @classmethod async def _handle_dept_event(cls, event_type: str, event_data: Dict[str, Any]) -> None: """处理部门变更事件""" dept_ids: List[int] = event_data.get("DeptId", []) if not dept_ids: logger.warning(f"部门事件缺少 DeptId: {event_data}") return client = await cls.get_client() async with AsyncSessionLocal() as db: for dept_id in dept_ids: try: if event_type == "org_dept_remove": name = await cls._remove_dept(db, dept_id) else: name = await cls._upsert_dept(db, client, dept_id) cls._write_event_log( db, event_type=event_type, target_type="dept", target_name=name, dingtalk_dept_id=str(dept_id), status="success", ) except Exception as e: logger.error(f"处理部门事件失败 dept_id={dept_id}: {e}") cls._write_event_log( db, event_type=event_type, target_type="dept", dingtalk_dept_id=str(dept_id), status="failed", error_detail=str(e), ) await db.commit() @classmethod async def _upsert_dept(cls, db: AsyncSession, client: DingtalkClient, dt_dept_id: int) -> str: """创建或更新单个部门,返回部门名称""" detail = await client.get_dept_detail(dt_dept_id) name = detail.get("name", "") dt_parent_id = detail.get("parent_id") result = await db.execute( select(Dept).where( Dept.dingtalk_dept_id == str(dt_dept_id), Dept.is_deleted == False, # noqa: E712 ) ) local_dept = result.scalar_one_or_none() parent_id = None level = 0 path = "/" if dt_parent_id: parent_result = await db.execute( select(Dept).where( Dept.dingtalk_dept_id == str(dt_parent_id), Dept.is_deleted == False, # noqa: E712 ) ) parent_dept = parent_result.scalar_one_or_none() if parent_dept: parent_id = parent_dept.id level = parent_dept.level + 1 path = f"{parent_dept.path or '/'}{parent_dept.id}/" if local_dept: local_dept.name = name local_dept.parent_id = parent_id local_dept.level = level local_dept.path = path else: local_dept = Dept( name=name, dingtalk_dept_id=str(dt_dept_id), parent_id=parent_id, level=level, path=path, dept_type="department", status=True, ) db.add(local_dept) await db.flush() logger.info(f"部门同步成功: {name} (dingtalk_dept_id={dt_dept_id})") return name @classmethod async def _remove_dept(cls, db: AsyncSession, dt_dept_id: int) -> str: """软删除部门,返回部门名称""" result = await db.execute( select(Dept).where( Dept.dingtalk_dept_id == str(dt_dept_id), Dept.is_deleted == False, # noqa: E712 ) ) local_dept = result.scalar_one_or_none() if local_dept: local_dept.is_deleted = True logger.info(f"部门已删除: {local_dept.name} (dingtalk_dept_id={dt_dept_id})") return local_dept.name else: logger.info(f"部门不存在,跳过删除: dingtalk_dept_id={dt_dept_id}") return "" # ==================== 用户事件 ==================== @classmethod async def _handle_user_event(cls, event_type: str, event_data: Dict[str, Any]) -> None: """处理用户变更事件""" user_ids: List[str] = event_data.get("UserId", []) if not user_ids: logger.warning(f"用户事件缺少 UserId: {event_data}") return client = await cls.get_client() async with AsyncSessionLocal() as db: dept_result = await db.execute( select(Dept).where( Dept.dingtalk_dept_id.isnot(None), Dept.is_deleted == False, # noqa: E712 ) ) local_depts = dept_result.scalars().all() dt_dept_map = {dept.dingtalk_dept_id: dept.id for dept in local_depts} for userid in user_ids: try: if event_type == "user_leave_org": name = await cls._deactivate_user(db, userid) else: name = await cls._upsert_user(db, client, userid, dt_dept_map) cls._write_event_log( db, event_type=event_type, target_type="user", target_name=name, dingtalk_userid=userid, status="success", ) except Exception as e: logger.error(f"处理用户事件失败 userid={userid}: {e}") cls._write_event_log( db, event_type=event_type, target_type="user", dingtalk_userid=userid, status="failed", error_detail=str(e), ) await db.commit() @classmethod async def _upsert_user( cls, db: AsyncSession, client: DingtalkClient, dt_userid: str, dt_dept_map: Dict[str, str], ) -> str: """创建或更新单个用户,返回用户姓名""" import secrets import string detail = await client.get_user_detail(dt_userid) name = detail.get("name", "") mobile = detail.get("mobile", "") email = detail.get("email", "") unionid = detail.get("unionid") job_number = detail.get("job_number", "") active = detail.get("active", True) dept_ids = detail.get("dept_id_list", []) local_dept_id = None for did in dept_ids: mapped = dt_dept_map.get(str(did)) if mapped: local_dept_id = mapped break result = await db.execute( select(User).where( User.dingtalk_userid == dt_userid, User.is_deleted == False, # noqa: E712 ) ) local_user = result.scalar_one_or_none() if not local_user and unionid: result = await db.execute( select(User).where( User.dingtalk_unionid == unionid, User.is_deleted == False, # noqa: E712 ) ) local_user = result.scalar_one_or_none() if not local_user and mobile: result = await db.execute( select(User).where( User.mobile == mobile, User.is_deleted == False, # noqa: E712 ) ) local_user = result.scalar_one_or_none() if local_user: local_user.name = name if mobile: local_user.mobile = mobile if email: local_user.email = email if local_dept_id: local_user.dept_id = local_dept_id local_user.dingtalk_userid = dt_userid if unionid: local_user.dingtalk_unionid = unionid local_user.user_status = 1 if active else 0 local_user.is_active = active else: username = job_number or mobile or f"dt_{dt_userid}" existing = await db.execute(select(User).where(User.username == username)) if existing.scalar_one_or_none(): username = f"dt_{dt_userid}" chars = string.ascii_letters + string.digits + "!@#$%" password = "".join(secrets.choice(chars) for _ in range(16)) local_user = User( username=username, password=UserService.hash_password(password), name=name, mobile=mobile or None, email=email or None, dept_id=local_dept_id, dingtalk_userid=dt_userid, dingtalk_unionid=unionid or None, user_type=1, user_status=1 if active else 0, is_active=active, ) db.add(local_user) await db.flush() logger.info(f"用户同步成功: {name} (dingtalk_userid={dt_userid})") return name @classmethod async def _deactivate_user(cls, db: AsyncSession, dt_userid: str) -> str: """用户离职:禁用用户,返回用户姓名""" result = await db.execute( select(User).where( User.dingtalk_userid == dt_userid, User.is_deleted == False, # noqa: E712 ) ) local_user = result.scalar_one_or_none() if local_user: local_user.user_status = 0 local_user.is_active = False logger.info(f"用户已禁用: {local_user.name} (dingtalk_userid={dt_userid})") return local_user.name else: logger.info(f"用户不存在,跳过禁用: dingtalk_userid={dt_userid}") return "" @classmethod def _write_event_log( cls, db: AsyncSession, event_type: str, target_type: str, target_name: str = None, dingtalk_dept_id: str = None, dingtalk_userid: str = None, status: str = "success", error_detail: str = None, ) -> None: """写入一条 Stream 增量事件日志""" log = DingtalkStreamEventLog( event_type=event_type, target_type=target_type, target_name=target_name or None, dingtalk_dept_id=dingtalk_dept_id, dingtalk_userid=dingtalk_userid, status=status, error_detail=error_detail, event_time=datetime.now(), ) db.add(log)