Files
2026-06-08 18:14:59 +08:00

359 lines
13 KiB
Python
Raw Permalink 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.
#!/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)