Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
#!/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="查询成功")
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉通讯录 API 客户端
|
||||
封装钉钉开放平台部门和用户相关接口
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
|
||||
|
||||
|
||||
class DingtalkClient:
|
||||
"""钉钉通讯录 API 客户端"""
|
||||
|
||||
def __init__(self, app_key: str, app_secret: str):
|
||||
self.app_key = app_key
|
||||
self.app_secret = app_secret
|
||||
self._access_token: Optional[str] = None
|
||||
self._token_expires_at: float = 0
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
"""获取 access_token(带内存缓存,提前 5 分钟过期)"""
|
||||
now = time.time()
|
||||
if self._access_token and now < self._token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{DINGTALK_API_BASE}/gettoken",
|
||||
params={"appkey": self.app_key, "appsecret": self.app_secret},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
raise Exception(f"获取钉钉 access_token 失败: {result.get('errmsg', result)}")
|
||||
|
||||
self._access_token = result["access_token"]
|
||||
self._token_expires_at = now + result.get("expires_in", 7200) - 300
|
||||
return self._access_token
|
||||
|
||||
async def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""带 access_token 的 POST 请求"""
|
||||
token = await self.get_access_token()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{DINGTALK_API_BASE}{path}",
|
||||
params={"access_token": token},
|
||||
json=body,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
raise Exception(f"钉钉 API 调用失败 [{path}]: {result.get('errmsg', result)}")
|
||||
return result
|
||||
|
||||
# ==================== 连接测试 ====================
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""
|
||||
测试连接:获取 token + 拉取根部门信息验证
|
||||
返回根部门名称等基本信息
|
||||
"""
|
||||
await self.get_access_token()
|
||||
result = await self._post("/topapi/v2/department/get", {"dept_id": 1})
|
||||
dept_info = result.get("result", {})
|
||||
return {
|
||||
"success": True,
|
||||
"corp_name": dept_info.get("name", ""),
|
||||
"dept_id": dept_info.get("dept_id"),
|
||||
}
|
||||
|
||||
# ==================== 部门 API ====================
|
||||
|
||||
async def get_dept_list(self, dept_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取子部门列表
|
||||
https://open.dingtalk.com/document/orgapp/obtain-the-department-list-v2
|
||||
"""
|
||||
result = await self._post(
|
||||
"/topapi/v2/department/listsub",
|
||||
{"dept_id": dept_id},
|
||||
)
|
||||
return result.get("result", [])
|
||||
|
||||
async def get_dept_detail(self, dept_id: int) -> Dict[str, Any]:
|
||||
"""获取部门详情"""
|
||||
result = await self._post(
|
||||
"/topapi/v2/department/get",
|
||||
{"dept_id": dept_id},
|
||||
)
|
||||
return result.get("result", {})
|
||||
|
||||
async def get_all_depts(self, root_dept_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
递归获取指定根部门下的全量部门列表(广度优先)
|
||||
返回扁平列表,包含 parent_id 信息
|
||||
"""
|
||||
all_depts = []
|
||||
queue = [root_dept_id]
|
||||
|
||||
while queue:
|
||||
current_dept_id = queue.pop(0)
|
||||
|
||||
if current_dept_id != root_dept_id:
|
||||
detail = await self.get_dept_detail(current_dept_id)
|
||||
detail["parent_dept_id"] = detail.get("parent_id", root_dept_id)
|
||||
all_depts.append(detail)
|
||||
|
||||
children = await self.get_dept_list(current_dept_id)
|
||||
for child in children:
|
||||
child_id = child.get("dept_id")
|
||||
if child_id:
|
||||
queue.append(child_id)
|
||||
|
||||
return all_depts
|
||||
|
||||
async def get_dept_tree(self, root_dept_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取部门树形结构(供前端选择同步范围)
|
||||
"""
|
||||
children = await self.get_dept_list(root_dept_id)
|
||||
tree = []
|
||||
for child in children:
|
||||
node = {
|
||||
"dept_id": child.get("dept_id"),
|
||||
"name": child.get("name"),
|
||||
"children": await self.get_dept_tree(child.get("dept_id")),
|
||||
}
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
# ==================== 用户 API ====================
|
||||
|
||||
async def get_user_list(self, dept_id: int, cursor: int = 0, size: int = 100) -> Dict[str, Any]:
|
||||
"""
|
||||
获取部门用户列表(分页)
|
||||
https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
|
||||
"""
|
||||
result = await self._post(
|
||||
"/topapi/v2/user/list",
|
||||
{"dept_id": dept_id, "cursor": cursor, "size": size},
|
||||
)
|
||||
return result.get("result", {})
|
||||
|
||||
async def get_all_users_in_dept(self, dept_id: int) -> List[Dict[str, Any]]:
|
||||
"""获取部门下所有用户(自动翻页)"""
|
||||
users = []
|
||||
cursor = 0
|
||||
|
||||
while True:
|
||||
page = await self.get_user_list(dept_id, cursor=cursor)
|
||||
page_list = page.get("list", [])
|
||||
users.extend(page_list)
|
||||
|
||||
if not page.get("has_more", False):
|
||||
break
|
||||
cursor = page.get("next_cursor", 0)
|
||||
|
||||
return users
|
||||
|
||||
async def get_user_detail(self, userid: str) -> Dict[str, Any]:
|
||||
"""获取用户详情"""
|
||||
result = await self._post(
|
||||
"/topapi/v2/user/get",
|
||||
{"userid": userid},
|
||||
)
|
||||
return result.get("result", {})
|
||||
|
||||
# ==================== 回调注册 API ====================
|
||||
|
||||
async def register_callback(
|
||||
self,
|
||||
callback_url: str,
|
||||
callback_tag: List[str],
|
||||
token: str,
|
||||
aes_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
注册事件回调
|
||||
https://open.dingtalk.com/document/orgapp/registers-an-event-callback-interface
|
||||
"""
|
||||
return await self._post(
|
||||
"/call_back/register_call_back",
|
||||
{
|
||||
"call_back_tag": callback_tag,
|
||||
"token": token,
|
||||
"aes_key": aes_key,
|
||||
"url": callback_url,
|
||||
},
|
||||
)
|
||||
|
||||
async def update_callback(
|
||||
self,
|
||||
callback_url: str,
|
||||
callback_tag: List[str],
|
||||
token: str,
|
||||
aes_key: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新事件回调"""
|
||||
return await self._post(
|
||||
"/call_back/update_call_back",
|
||||
{
|
||||
"call_back_tag": callback_tag,
|
||||
"token": token,
|
||||
"aes_key": aes_key,
|
||||
"url": callback_url,
|
||||
},
|
||||
)
|
||||
|
||||
async def delete_callback(self) -> Dict[str, Any]:
|
||||
"""删除事件回调"""
|
||||
token = await self.get_access_token()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{DINGTALK_API_BASE}/call_back/delete_call_back",
|
||||
params={"access_token": token},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("errcode") != 0:
|
||||
raise Exception(f"删除回调失败: {result.get('errmsg', result)}")
|
||||
return result
|
||||
|
||||
async def get_callback(self) -> Dict[str, Any]:
|
||||
"""查询已注册的事件回调"""
|
||||
token = await self.get_access_token()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{DINGTALK_API_BASE}/call_back/get_call_back",
|
||||
params={"access_token": token},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉回调加解密工具
|
||||
|
||||
实现钉钉事件订阅所需的签名验证和消息加解密。
|
||||
基于钉钉官方文档:https://open.dingtalk.com/document/orgapp/callback-overview
|
||||
|
||||
加密方案:AES-CBC,PKCS#7 padding,key 由 aes_key + "=" base64 解码得到
|
||||
签名方案:SHA1(token + timestamp + nonce + encrypt)
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
class DingtalkCrypto:
|
||||
"""钉钉回调消息加解密"""
|
||||
|
||||
def __init__(self, token: str, aes_key: str, corp_id: str):
|
||||
self.token = token
|
||||
self.corp_id = corp_id
|
||||
self.aes_key_bytes = base64.b64decode(aes_key + "=")
|
||||
|
||||
def _generate_nonce(self, length: int = 16) -> str:
|
||||
return secrets.token_hex(length // 2)
|
||||
|
||||
def _pkcs7_pad(self, data: bytes, block_size: int = 32) -> bytes:
|
||||
padding_len = block_size - (len(data) % block_size)
|
||||
return data + bytes([padding_len] * padding_len)
|
||||
|
||||
def _pkcs7_unpad(self, data: bytes) -> bytes:
|
||||
padding_len = data[-1]
|
||||
return data[:-padding_len]
|
||||
|
||||
def _sign(self, token: str, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
"""计算签名 SHA1(sort(token, timestamp, nonce, encrypt))"""
|
||||
parts = sorted([token, timestamp, nonce, encrypt])
|
||||
raw = "".join(parts).encode("utf-8")
|
||||
return hashlib.sha1(raw).hexdigest()
|
||||
|
||||
def encrypt(self, plaintext: str) -> Dict[str, str]:
|
||||
"""
|
||||
加密明文消息,返回回调响应体
|
||||
|
||||
返回: {"msg_signature": ..., "timeStamp": ..., "nonce": ..., "encrypt": ...}
|
||||
"""
|
||||
nonce = self._generate_nonce()
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
# 16字节随机字符串 + 4字节网络序消息长度 + 明文 + corp_id
|
||||
random_bytes = secrets.token_bytes(16)
|
||||
text_bytes = plaintext.encode("utf-8")
|
||||
corp_id_bytes = self.corp_id.encode("utf-8")
|
||||
content = random_bytes + struct.pack("!I", len(text_bytes)) + text_bytes + corp_id_bytes
|
||||
|
||||
# AES-CBC 加密
|
||||
padded = self._pkcs7_pad(content)
|
||||
iv = self.aes_key_bytes[:16]
|
||||
cipher = Cipher(algorithms.AES(self.aes_key_bytes), modes.CBC(iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
encrypt_str = base64.b64encode(encrypted).decode("utf-8")
|
||||
signature = self._sign(self.token, timestamp, nonce, encrypt_str)
|
||||
|
||||
return {
|
||||
"msg_signature": signature,
|
||||
"timeStamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"encrypt": encrypt_str,
|
||||
}
|
||||
|
||||
def decrypt(self, encrypt_str: str) -> str:
|
||||
"""解密密文,返回明文 JSON 字符串,并校验 corp_id"""
|
||||
encrypted = base64.b64decode(encrypt_str)
|
||||
|
||||
iv = self.aes_key_bytes[:16]
|
||||
cipher = Cipher(algorithms.AES(self.aes_key_bytes), modes.CBC(iv), backend=default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted = decryptor.update(encrypted) + decryptor.finalize()
|
||||
|
||||
unpadded = self._pkcs7_unpad(decrypted)
|
||||
|
||||
msg_len = struct.unpack("!I", unpadded[16:20])[0]
|
||||
plaintext = unpadded[20:20 + msg_len].decode("utf-8")
|
||||
|
||||
receive_id = unpadded[20 + msg_len:].decode("utf-8")
|
||||
if receive_id != self.corp_id:
|
||||
raise ValueError(f"corp_id 校验失败: 期望 {self.corp_id},实际 {receive_id}")
|
||||
|
||||
return plaintext
|
||||
|
||||
def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
|
||||
"""验证回调签名"""
|
||||
calculated = self._sign(self.token, timestamp, nonce, encrypt)
|
||||
return calculated == msg_signature
|
||||
|
||||
def handle_callback(
|
||||
self,
|
||||
msg_signature: str,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
encrypt: str,
|
||||
) -> str:
|
||||
"""
|
||||
处理回调:验签 + 解密
|
||||
|
||||
返回解密后的明文 JSON 字符串
|
||||
"""
|
||||
if not self.verify_signature(msg_signature, timestamp, nonce, encrypt):
|
||||
raise ValueError("签名验证失败")
|
||||
return self.decrypt(encrypt)
|
||||
|
||||
def generate_success_response(self) -> Dict[str, str]:
|
||||
"""生成回调成功响应(加密 "success")"""
|
||||
return self.encrypt("success")
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉同步日志模型
|
||||
- DingtalkSyncLog: 全量同步任务日志
|
||||
- DingtalkStreamEventLog: Stream 增量事件日志
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class DingtalkSyncLog(BaseModel):
|
||||
"""
|
||||
钉钉全量同步日志
|
||||
|
||||
字段说明:
|
||||
- sync_type: 同步类型 dept=部门 user=用户
|
||||
- total_count: 钉钉侧总数
|
||||
- success_count: 同步成功数
|
||||
- fail_count: 同步失败数
|
||||
- status: 同步状态 running/success/partial/failed
|
||||
- error_detail: 失败详情(JSON)
|
||||
- started_at: 同步开始时间
|
||||
- finished_at: 同步结束时间
|
||||
"""
|
||||
__tablename__ = "core_dingtalk_sync_log"
|
||||
|
||||
sync_type = Column(String(20), nullable=False, index=True, comment="同步类型: dept/user")
|
||||
total_count = Column(Integer, default=0, comment="总数")
|
||||
success_count = Column(Integer, default=0, comment="成功数")
|
||||
fail_count = Column(Integer, default=0, comment="失败数")
|
||||
status = Column(String(20), default="running", index=True, comment="状态: running/success/partial/failed")
|
||||
error_detail = Column(Text, nullable=True, comment="失败详情")
|
||||
started_at = Column(DateTime, nullable=True, comment="开始时间")
|
||||
finished_at = Column(DateTime, nullable=True, comment="结束时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DingtalkSyncLog {self.sync_type} {self.status}>"
|
||||
|
||||
|
||||
class DingtalkStreamEventLog(BaseModel):
|
||||
"""
|
||||
钉钉 Stream 增量事件日志
|
||||
|
||||
记录每一次 Stream 推送的增量变更(部门/用户的创建、修改、删除等)
|
||||
"""
|
||||
__tablename__ = "core_dingtalk_stream_event_log"
|
||||
|
||||
event_type = Column(String(50), nullable=False, index=True, comment="事件类型: org_dept_create/user_add_org等")
|
||||
target_type = Column(String(20), nullable=False, index=True, comment="目标类型: dept/user")
|
||||
target_name = Column(String(100), nullable=True, comment="目标名称")
|
||||
dingtalk_dept_id = Column(String(50), nullable=True, comment="钉钉部门ID")
|
||||
dingtalk_userid = Column(String(100), nullable=True, comment="钉钉用户ID")
|
||||
status = Column(String(20), default="success", index=True, comment="状态: success/failed")
|
||||
error_detail = Column(Text, nullable=True, comment="失败详情")
|
||||
event_time = Column(DateTime, nullable=True, comment="事件时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DingtalkStreamEventLog {self.event_type} {self.target_type} {self.status}>"
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉同步 Schema - 请求/响应模型
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
app_key: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
|
||||
|
||||
class TestConnectionResponse(BaseModel):
|
||||
success: bool
|
||||
corp_name: Optional[str] = None
|
||||
dept_id: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class DingtalkSyncConfigUpdate(BaseModel):
|
||||
corp_id: Optional[str] = None
|
||||
app_key: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
sync_dept_id: Optional[str] = None
|
||||
sync_root_dept_id: Optional[str] = None
|
||||
enable_dept_event: Optional[str] = None
|
||||
enable_user_event: Optional[str] = None
|
||||
callback_token: Optional[str] = None
|
||||
callback_aes_key: Optional[str] = None
|
||||
callback_url: Optional[str] = None
|
||||
|
||||
|
||||
class SyncTypeStats(BaseModel):
|
||||
total_count: int = 0
|
||||
success_count: int = 0
|
||||
fail_count: int = 0
|
||||
not_synced: int = 0
|
||||
status: Optional[str] = None
|
||||
sync_time: Optional[str] = None
|
||||
|
||||
|
||||
class SyncStatsResponse(BaseModel):
|
||||
dept: SyncTypeStats
|
||||
user: SyncTypeStats
|
||||
|
||||
|
||||
class SyncLogItem(BaseModel):
|
||||
id: str
|
||||
sync_type: str
|
||||
total_count: Optional[int] = 0
|
||||
success_count: Optional[int] = 0
|
||||
fail_count: Optional[int] = 0
|
||||
status: Optional[str] = None
|
||||
error_detail: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class SyncLogListResponse(BaseModel):
|
||||
items: List[SyncLogItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class DingtalkDeptTreeNode(BaseModel):
|
||||
dept_id: int
|
||||
name: str
|
||||
children: List["DingtalkDeptTreeNode"] = []
|
||||
|
||||
|
||||
class DeptTreeRequest(BaseModel):
|
||||
app_key: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
@@ -0,0 +1,694 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉组织架构同步服务
|
||||
实现部门和用户从钉钉到本系统的单向同步
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config_manager import config_manager
|
||||
from core.dept.model import Dept
|
||||
from core.dingtalk_sync.client import DingtalkClient
|
||||
from core.dingtalk_sync.model import DingtalkSyncLog
|
||||
from core.user.model import User
|
||||
from core.user.service import UserService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_random_password(length: int = 16) -> str:
|
||||
"""生成随机密码"""
|
||||
chars = string.ascii_letters + string.digits + "!@#$%"
|
||||
return "".join(secrets.choice(chars) for _ in range(length))
|
||||
|
||||
|
||||
class DingtalkSyncService:
|
||||
"""钉钉组织架构同步服务"""
|
||||
|
||||
@staticmethod
|
||||
def _is_masked(value: Optional[str]) -> bool:
|
||||
"""判断值是否为脱敏值"""
|
||||
return bool(value and "***" in value)
|
||||
|
||||
@staticmethod
|
||||
async def _get_client() -> 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("钉钉同步凭证未配置(app_key/app_secret)")
|
||||
return DingtalkClient(app_key=app_key, app_secret=app_secret)
|
||||
|
||||
@staticmethod
|
||||
async def _get_sync_config() -> Dict[str, Any]:
|
||||
"""获取同步配置"""
|
||||
return await config_manager.get_group("sync_dingtalk")
|
||||
|
||||
@classmethod
|
||||
async def _resolve_client(cls, app_key: str = None, app_secret: str = None) -> DingtalkClient:
|
||||
"""解析客户端凭证:有效明文直接用,脱敏值或空值从数据库获取"""
|
||||
use_input = (
|
||||
app_key and app_secret
|
||||
and not cls._is_masked(app_key)
|
||||
and not cls._is_masked(app_secret)
|
||||
)
|
||||
if use_input:
|
||||
return DingtalkClient(app_key=app_key, app_secret=app_secret)
|
||||
return await cls._get_client()
|
||||
|
||||
# ==================== 连接测试 ====================
|
||||
|
||||
@classmethod
|
||||
async def test_connection(cls, app_key: str = None, app_secret: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
测试钉钉连接
|
||||
可传入临时凭证测试(保存前),也可使用已保存的配置
|
||||
"""
|
||||
client = await cls._resolve_client(app_key, app_secret)
|
||||
return await client.test_connection()
|
||||
|
||||
# ==================== 部门树(供前端选择范围) ====================
|
||||
|
||||
@classmethod
|
||||
async def get_dingtalk_dept_tree(
|
||||
cls,
|
||||
app_key: str = None,
|
||||
app_secret: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取钉钉部门树(用于前端选择同步范围)"""
|
||||
client = await cls._resolve_client(app_key, app_secret)
|
||||
|
||||
root_detail = await client.get_dept_detail(1)
|
||||
children = await client.get_dept_tree(1)
|
||||
return [{
|
||||
"dept_id": 1,
|
||||
"name": root_detail.get("name", "根部门"),
|
||||
"children": children,
|
||||
}]
|
||||
|
||||
# ==================== 部门同步 ====================
|
||||
|
||||
@classmethod
|
||||
async def start_sync_departments(cls) -> str:
|
||||
"""
|
||||
创建同步日志并返回 log_id,实际同步由调用方用 asyncio.create_task 执行
|
||||
"""
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
log = DingtalkSyncLog(
|
||||
sync_type="dept",
|
||||
status="running",
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return log.id
|
||||
|
||||
@classmethod
|
||||
async def sync_departments(cls, log_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
全量同步钉钉部门到本系统(后台任务,使用独立 session)
|
||||
|
||||
流程:
|
||||
1. 从钉钉拉取指定根部门下的全量部门
|
||||
2. 按层级顺序处理,通过 dingtalk_dept_id 匹配本地 Dept
|
||||
3. 已存在则更新,不存在则创建
|
||||
"""
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
config = await cls._get_sync_config()
|
||||
sync_dept_id = int(config.get("sync_dept_id") or 1)
|
||||
sync_root_dept_id = config.get("sync_root_dept_id") or None
|
||||
|
||||
client = await cls._get_client()
|
||||
|
||||
errors = []
|
||||
success_count = 0
|
||||
total_count = 0
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(DingtalkSyncLog).where(DingtalkSyncLog.id == log_id))
|
||||
log = result.scalar_one()
|
||||
|
||||
try:
|
||||
root_detail = await client.get_dept_detail(sync_dept_id)
|
||||
all_depts = await client.get_all_depts(sync_dept_id)
|
||||
|
||||
root_detail["parent_dept_id"] = None
|
||||
all_dingtalk_depts = [root_detail] + all_depts
|
||||
total_count = len(all_dingtalk_depts)
|
||||
|
||||
dt_to_local: Dict[int, str] = {}
|
||||
|
||||
for dt_dept in all_dingtalk_depts:
|
||||
dt_dept_id = dt_dept.get("dept_id")
|
||||
dt_name = dt_dept.get("name", "")
|
||||
dt_parent_id = dt_dept.get("parent_dept_id") or dt_dept.get("parent_id")
|
||||
|
||||
try:
|
||||
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 dt_dept_id == sync_dept_id:
|
||||
parent_id = sync_root_dept_id
|
||||
elif dt_parent_id and int(dt_parent_id) in dt_to_local:
|
||||
parent_id = dt_to_local[int(dt_parent_id)]
|
||||
elif dt_parent_id == sync_dept_id and sync_root_dept_id:
|
||||
parent_id = dt_to_local.get(sync_dept_id, sync_root_dept_id)
|
||||
else:
|
||||
parent_id = None
|
||||
|
||||
level = 0
|
||||
path = "/"
|
||||
if parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(Dept).where(Dept.id == parent_id)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if parent:
|
||||
level = parent.level + 1
|
||||
path = f"{parent.path or '/'}{parent.id}/"
|
||||
|
||||
if local_dept:
|
||||
local_dept.name = dt_name
|
||||
local_dept.parent_id = parent_id
|
||||
local_dept.level = level
|
||||
local_dept.path = path
|
||||
else:
|
||||
local_dept = Dept(
|
||||
name=dt_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()
|
||||
dt_to_local[dt_dept_id] = local_dept.id
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"同步部门失败 dept_id={dt_dept_id}: {e}")
|
||||
errors.append({"dept_id": dt_dept_id, "name": dt_name, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
|
||||
log.total_count = total_count
|
||||
log.success_count = success_count
|
||||
log.fail_count = len(errors)
|
||||
log.status = "success" if not errors else ("partial" if success_count > 0 else "failed")
|
||||
log.error_detail = json.dumps(errors, ensure_ascii=False) if errors else None
|
||||
log.finished_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"部门同步异常: {e}")
|
||||
log.status = "failed"
|
||||
log.error_detail = str(e)
|
||||
log.finished_at = datetime.now()
|
||||
log.total_count = total_count
|
||||
log.success_count = success_count
|
||||
log.fail_count = total_count - success_count
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"total": total_count,
|
||||
"success": success_count,
|
||||
"fail": len(errors),
|
||||
"status": log.status,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# ==================== 用户同步 ====================
|
||||
|
||||
@classmethod
|
||||
async def start_sync_users(cls) -> str:
|
||||
"""
|
||||
创建同步日志并返回 log_id,实际同步由调用方用 asyncio.create_task 执行
|
||||
"""
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
log = DingtalkSyncLog(
|
||||
sync_type="user",
|
||||
status="running",
|
||||
started_at=datetime.now(),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return log.id
|
||||
|
||||
@classmethod
|
||||
async def sync_users(cls, log_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
全量同步钉钉用户到本系统(后台任务,使用独立 session)
|
||||
|
||||
流程:
|
||||
1. 遍历已同步的所有部门(有 dingtalk_dept_id 的)
|
||||
2. 拉取每个部门下的用户
|
||||
3. 通过 dingtalk_userid 匹配,已有则更新,不存在则创建
|
||||
"""
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
client = await cls._get_client()
|
||||
|
||||
errors = []
|
||||
success_count = 0
|
||||
total_count = 0
|
||||
processed_userids = set()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(DingtalkSyncLog).where(DingtalkSyncLog.id == log_id))
|
||||
log = result.scalar_one()
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Dept).where(
|
||||
Dept.dingtalk_dept_id.isnot(None),
|
||||
Dept.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
local_depts = result.scalars().all()
|
||||
|
||||
dt_dept_map = {dept.dingtalk_dept_id: dept.id for dept in local_depts}
|
||||
|
||||
for dept in local_depts:
|
||||
try:
|
||||
dt_dept_id = int(dept.dingtalk_dept_id)
|
||||
users = await client.get_all_users_in_dept(dt_dept_id)
|
||||
|
||||
for dt_user in users:
|
||||
dt_userid = dt_user.get("userid")
|
||||
if not dt_userid or dt_userid in processed_userids:
|
||||
continue
|
||||
|
||||
processed_userids.add(dt_userid)
|
||||
total_count += 1
|
||||
|
||||
try:
|
||||
await cls._upsert_user(db, dt_user, dt_dept_map)
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"同步用户失败 userid={dt_userid}: {e}")
|
||||
errors.append({
|
||||
"userid": dt_userid,
|
||||
"name": dt_user.get("name", ""),
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"拉取部门用户失败 dept={dept.dingtalk_dept_id}: {e}")
|
||||
errors.append({
|
||||
"dept_id": dept.dingtalk_dept_id,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
log.total_count = total_count
|
||||
log.success_count = success_count
|
||||
log.fail_count = len(errors)
|
||||
log.status = "success" if not errors else ("partial" if success_count > 0 else "failed")
|
||||
log.error_detail = json.dumps(errors, ensure_ascii=False) if errors else None
|
||||
log.finished_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"用户同步异常: {e}")
|
||||
log.status = "failed"
|
||||
log.error_detail = str(e)
|
||||
log.finished_at = datetime.now()
|
||||
log.total_count = total_count
|
||||
log.success_count = success_count
|
||||
log.fail_count = total_count - success_count
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"total": total_count,
|
||||
"success": success_count,
|
||||
"fail": len(errors),
|
||||
"status": log.status,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def _upsert_user(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
dt_user: Dict[str, Any],
|
||||
dt_dept_map: Dict[str, str],
|
||||
) -> None:
|
||||
"""新增或更新单个用户"""
|
||||
dt_userid = dt_user.get("userid")
|
||||
dt_unionid = dt_user.get("unionid")
|
||||
name = dt_user.get("name", "")
|
||||
mobile = dt_user.get("mobile", "")
|
||||
email = dt_user.get("email", "")
|
||||
avatar = dt_user.get("avatar", "")
|
||||
job_number = dt_user.get("job_number", "")
|
||||
title = dt_user.get("title", "")
|
||||
active = dt_user.get("active", True)
|
||||
|
||||
# 用户所属主部门
|
||||
dept_ids = dt_user.get("dept_id_list", [])
|
||||
local_dept_id = None
|
||||
if dept_ids:
|
||||
for did in dept_ids:
|
||||
mapped = dt_dept_map.get(str(did))
|
||||
if mapped:
|
||||
local_dept_id = mapped
|
||||
break
|
||||
|
||||
# 先按 dingtalk_userid 匹配
|
||||
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()
|
||||
|
||||
# 再按 dingtalk_unionid 匹配
|
||||
if not local_user and dt_unionid:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.dingtalk_unionid == dt_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 dt_unionid:
|
||||
local_user.dingtalk_unionid = dt_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}"
|
||||
|
||||
# 检查 username 是否已存在
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
username = f"dt_{dt_userid}"
|
||||
|
||||
local_user = User(
|
||||
username=username,
|
||||
password=UserService.hash_password(_generate_random_password()),
|
||||
name=name,
|
||||
mobile=mobile or None,
|
||||
email=email or None,
|
||||
dept_id=local_dept_id,
|
||||
dingtalk_userid=dt_userid,
|
||||
dingtalk_unionid=dt_unionid or None,
|
||||
user_type=1,
|
||||
user_status=1 if active else 0,
|
||||
is_active=active,
|
||||
)
|
||||
db.add(local_user)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ==================== 同步统计 ====================
|
||||
|
||||
@classmethod
|
||||
async def get_sync_stats(cls, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""获取最新的同步统计数据(分 dept/user 各取最新一条已完成的日志)"""
|
||||
stats = {}
|
||||
for sync_type in ("dept", "user"):
|
||||
# 优先取最新的已完成日志(非 running)
|
||||
result = await db.execute(
|
||||
select(DingtalkSyncLog)
|
||||
.where(
|
||||
DingtalkSyncLog.sync_type == sync_type,
|
||||
DingtalkSyncLog.is_deleted == False, # noqa: E712
|
||||
DingtalkSyncLog.status != "running",
|
||||
)
|
||||
.order_by(desc(DingtalkSyncLog.sys_create_datetime))
|
||||
.limit(1)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
# 如果没有已完成的,取最新的(可能是 running)
|
||||
if not log:
|
||||
result = await db.execute(
|
||||
select(DingtalkSyncLog)
|
||||
.where(
|
||||
DingtalkSyncLog.sync_type == sync_type,
|
||||
DingtalkSyncLog.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(desc(DingtalkSyncLog.sys_create_datetime))
|
||||
.limit(1)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
|
||||
if log:
|
||||
stats[sync_type] = {
|
||||
"total_count": log.total_count or 0,
|
||||
"success_count": log.success_count or 0,
|
||||
"fail_count": log.fail_count or 0,
|
||||
"not_synced": max(0, (log.total_count or 0) - (log.success_count or 0) - (log.fail_count or 0)),
|
||||
"status": log.status,
|
||||
"sync_time": log.finished_at.isoformat() if log.finished_at else None,
|
||||
}
|
||||
else:
|
||||
stats[sync_type] = {
|
||||
"total_count": 0,
|
||||
"success_count": 0,
|
||||
"fail_count": 0,
|
||||
"not_synced": 0,
|
||||
"status": None,
|
||||
"sync_time": None,
|
||||
}
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
async def get_sync_status(cls, db: AsyncSession, log_id: str) -> Dict[str, Any]:
|
||||
"""查询单次同步任务的实时状态"""
|
||||
result = await db.execute(
|
||||
select(DingtalkSyncLog).where(DingtalkSyncLog.id == log_id)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
return {"status": "not_found"}
|
||||
return {
|
||||
"id": log.id,
|
||||
"sync_type": log.sync_type,
|
||||
"total_count": log.total_count or 0,
|
||||
"success_count": log.success_count or 0,
|
||||
"fail_count": log.fail_count or 0,
|
||||
"status": log.status,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def get_sync_logs(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取同步日志列表"""
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(DingtalkSyncLog)
|
||||
.where(DingtalkSyncLog.is_deleted == False) # noqa: E712
|
||||
.order_by(desc(DingtalkSyncLog.sys_create_datetime))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
from sqlalchemy import func
|
||||
count_result = await db.execute(
|
||||
select(func.count(DingtalkSyncLog.id)).where(
|
||||
DingtalkSyncLog.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"sync_type": log.sync_type,
|
||||
"total_count": log.total_count,
|
||||
"success_count": log.success_count,
|
||||
"fail_count": log.fail_count,
|
||||
"status": log.status,
|
||||
"error_detail": log.error_detail,
|
||||
"started_at": log.started_at.isoformat() if log.started_at else None,
|
||||
"finished_at": log.finished_at.isoformat() if log.finished_at else None,
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# ==================== Stream 增量事件日志 ====================
|
||||
|
||||
@classmethod
|
||||
async def get_stream_events(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""获取 Stream 增量事件日志列表"""
|
||||
from core.dingtalk_sync.model import DingtalkStreamEventLog
|
||||
from sqlalchemy import func
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(DingtalkStreamEventLog)
|
||||
.where(DingtalkStreamEventLog.is_deleted == False) # noqa: E712
|
||||
.order_by(desc(DingtalkStreamEventLog.sys_create_datetime))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count(DingtalkStreamEventLog.id)).where(
|
||||
DingtalkStreamEventLog.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"target_type": log.target_type,
|
||||
"target_name": log.target_name,
|
||||
"dingtalk_dept_id": log.dingtalk_dept_id,
|
||||
"dingtalk_userid": log.dingtalk_userid,
|
||||
"status": log.status,
|
||||
"error_detail": log.error_detail,
|
||||
"event_time": log.event_time.isoformat() if log.event_time else None,
|
||||
}
|
||||
for log in logs
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# ==================== 事件回调管理 ====================
|
||||
|
||||
# 订阅的通讯录事件类型
|
||||
CALLBACK_TAGS = [
|
||||
"org_dept_create",
|
||||
"org_dept_modify",
|
||||
"org_dept_remove",
|
||||
"user_add_org",
|
||||
"user_modify_org",
|
||||
"user_leave_org",
|
||||
"user_active_org",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def register_callback(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
向钉钉注册事件回调
|
||||
|
||||
需要配置中已填写 callback_token、callback_aes_key
|
||||
callback_url 由后端根据 APP_HOST 等设置自动生成
|
||||
"""
|
||||
config = await cls._get_sync_config()
|
||||
callback_token = config.get("callback_token")
|
||||
callback_aes_key = config.get("callback_aes_key")
|
||||
if not callback_token or not callback_aes_key:
|
||||
raise ValueError("请先配置 回调Token 和 回调AES Key")
|
||||
|
||||
callback_url = config.get("callback_url")
|
||||
if not callback_url:
|
||||
raise ValueError("请先配置回调地址(callback_url)")
|
||||
|
||||
client = await cls._get_client()
|
||||
|
||||
# 先查询是否已注册,如果已注册则走更新
|
||||
existing = await client.get_callback()
|
||||
if existing.get("errcode") == 0 and existing.get("url"):
|
||||
result = await client.update_callback(
|
||||
callback_url=callback_url,
|
||||
callback_tag=cls.CALLBACK_TAGS,
|
||||
token=callback_token,
|
||||
aes_key=callback_aes_key,
|
||||
)
|
||||
else:
|
||||
result = await client.register_callback(
|
||||
callback_url=callback_url,
|
||||
callback_tag=cls.CALLBACK_TAGS,
|
||||
token=callback_token,
|
||||
aes_key=callback_aes_key,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"callback_url": callback_url,
|
||||
"subscribed_events": cls.CALLBACK_TAGS,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def delete_callback(cls) -> None:
|
||||
"""删除已注册的钉钉事件回调"""
|
||||
client = await cls._get_client()
|
||||
await client.delete_callback()
|
||||
|
||||
@classmethod
|
||||
async def get_callback_status(cls) -> Dict[str, Any]:
|
||||
"""查询当前回调注册状态"""
|
||||
client = await cls._get_client()
|
||||
result = await client.get_callback()
|
||||
|
||||
if result.get("errcode") == 0 and result.get("url"):
|
||||
return {
|
||||
"registered": True,
|
||||
"callback_url": result.get("url", ""),
|
||||
"subscribed_events": result.get("call_back_tag", []),
|
||||
}
|
||||
return {"registered": False}
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
钉钉 Stream 模式事件监听客户端
|
||||
|
||||
取代 HTTP 回调模式,通过 WebSocket 长连接接收钉钉通讯录变更事件。
|
||||
无需公网回调地址,无需配置 callback_token/callback_aes_key/corp_id。
|
||||
|
||||
使用方式:
|
||||
1. 在钉钉开放平台应用配置中,开启 Stream 模式推送
|
||||
2. 后端启动后自动建立长连接,接收事件推送
|
||||
|
||||
Stream 模式事件数据格式与 HTTP 推送不同:
|
||||
HTTP: { "EventType": "...", "UserId": [...], "DeptId": [...] }
|
||||
Stream: event.headers.event_type 为事件类型,
|
||||
event.data 为事件体 { "timeStamp": "...", "deptId": [...] / "userId": [...] }
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import dingtalk_stream
|
||||
from dingtalk_stream import AckMessage
|
||||
from dingtalk_stream import EventMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEDUP_MAX = 256
|
||||
|
||||
|
||||
class _EventDedup:
|
||||
"""基于 OrderedDict 的有界去重缓存,保留最近 N 个 event_id"""
|
||||
|
||||
def __init__(self, maxlen: int = _DEDUP_MAX):
|
||||
self._seen: OrderedDict[str, None] = OrderedDict()
|
||||
self._maxlen = maxlen
|
||||
|
||||
def is_duplicate(self, event_id: str) -> bool:
|
||||
if event_id in self._seen:
|
||||
self._seen.move_to_end(event_id)
|
||||
return True
|
||||
self._seen[event_id] = None
|
||||
if len(self._seen) > self._maxlen:
|
||||
self._seen.popitem(last=False)
|
||||
return False
|
||||
|
||||
|
||||
class DingtalkStreamEventHandler(dingtalk_stream.EventHandler):
|
||||
"""钉钉 Stream 事件处理器 - 处理通讯录变更事件"""
|
||||
|
||||
_dedup = _EventDedup()
|
||||
|
||||
async def process(self, event: EventMessage) -> tuple:
|
||||
try:
|
||||
event_type = event.headers.event_type if event.headers else ''
|
||||
event_data = event.data if isinstance(event.data, dict) else {}
|
||||
|
||||
event_id = getattr(event.headers, 'event_id', None) or ''
|
||||
if event_id and self._dedup.is_duplicate(event_id):
|
||||
logger.info("跳过重复事件: event_id=%s, type=%s", event_id, event_type)
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
DingtalkStreamManager._record_event(event_type)
|
||||
|
||||
if not event_type:
|
||||
logger.warning(f"收到未知事件: {event_data}")
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
event_body = event_data
|
||||
|
||||
logger.info(f"收到钉钉 Stream 事件: {event_type}")
|
||||
|
||||
from core.dingtalk_sync.callback_handler import DingtalkCallbackHandler
|
||||
from core.dingtalk_sync.callback_handler import DEPT_EVENTS, USER_EVENTS
|
||||
|
||||
from app.config_manager import config_manager
|
||||
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:
|
||||
normalized = self._normalize_dept_event(event_body)
|
||||
await DingtalkCallbackHandler.handle_event(event_type, normalized)
|
||||
|
||||
elif event_type in USER_EVENTS and enable_user:
|
||||
normalized = self._normalize_user_event(event_body)
|
||||
await DingtalkCallbackHandler.handle_event(event_type, normalized)
|
||||
|
||||
else:
|
||||
logger.info(f"忽略未启用的事件: {event_type}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理 Stream 事件失败: {e}", exc_info=True)
|
||||
|
||||
return AckMessage.STATUS_OK, 'OK'
|
||||
|
||||
@staticmethod
|
||||
def _normalize_dept_event(event_body: dict) -> dict:
|
||||
"""将 Stream 模式的部门事件字段转为 HTTP 回调格式"""
|
||||
dept_ids = event_body.get('deptId') or event_body.get('DeptId') or []
|
||||
return {"DeptId": dept_ids}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_user_event(event_body: dict) -> dict:
|
||||
"""将 Stream 模式的用户事件字段转为 HTTP 回调格式"""
|
||||
user_ids = event_body.get('userId') or event_body.get('UserId') or []
|
||||
return {"UserId": user_ids}
|
||||
|
||||
|
||||
class DingtalkStreamManager:
|
||||
"""钉钉 Stream 模式连接管理器"""
|
||||
|
||||
_client: Optional[dingtalk_stream.DingTalkStreamClient] = None
|
||||
_task: Optional[asyncio.Task] = None
|
||||
_connected: bool = False
|
||||
_started_at: Optional[str] = None
|
||||
|
||||
# 事件统计
|
||||
_event_stats = {
|
||||
"total_events": 0,
|
||||
"last_event_type": None,
|
||||
"last_event_time": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _record_event(cls, event_type: str):
|
||||
cls._event_stats["total_events"] += 1
|
||||
cls._event_stats["last_event_type"] = event_type
|
||||
cls._event_stats["last_event_time"] = datetime.now().isoformat()
|
||||
|
||||
@classmethod
|
||||
async def start(cls):
|
||||
"""启动 Stream 连接,使用同步配置中的 app_key/app_secret"""
|
||||
from app.config_manager import config_manager
|
||||
|
||||
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:
|
||||
logger.warning("钉钉同步凭证未配置(app_key/app_secret),Stream 模式未启动")
|
||||
return
|
||||
|
||||
credential = dingtalk_stream.Credential(app_key, app_secret)
|
||||
client = dingtalk_stream.DingTalkStreamClient(credential)
|
||||
|
||||
handler = DingtalkStreamEventHandler()
|
||||
client.register_all_event_handler(handler)
|
||||
|
||||
cls._client = client
|
||||
cls._connected = True
|
||||
cls._started_at = datetime.now().isoformat()
|
||||
cls._task = asyncio.create_task(cls._run(client))
|
||||
logger.info("钉钉 Stream 模式已启动(app_key=%s)", app_key[:6] + "***")
|
||||
|
||||
@classmethod
|
||||
async def _run(cls, client: dingtalk_stream.DingTalkStreamClient):
|
||||
"""
|
||||
运行 Stream 客户端(带超时和重连)。
|
||||
|
||||
SDK 的 open_connection() 使用同步 requests(短暂阻塞可接受),
|
||||
但 websockets.connect() 可能因为网络/防火墙问题长时间卡住,
|
||||
因此对整个 start() 加超时保护,超时后自动重试。
|
||||
"""
|
||||
import json as _json
|
||||
import websockets as _ws
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
client.pre_start()
|
||||
while cls._connected:
|
||||
try:
|
||||
connection = await asyncio.get_event_loop().run_in_executor(
|
||||
None, client.open_connection
|
||||
)
|
||||
if not connection:
|
||||
logger.error("钉钉 Stream: open_connection 返回空")
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
logger.info("钉钉 Stream endpoint: %s", connection.get('endpoint', ''))
|
||||
uri = f'{connection["endpoint"]}?ticket={quote_plus(connection["ticket"])}'
|
||||
|
||||
async with asyncio.timeout(30):
|
||||
websocket = await _ws.connect(uri)
|
||||
|
||||
logger.info("钉钉 Stream WebSocket 连接成功")
|
||||
client.websocket = websocket
|
||||
|
||||
asyncio.create_task(client.keepalive(websocket))
|
||||
async for raw_message in websocket:
|
||||
json_message = _json.loads(raw_message)
|
||||
asyncio.create_task(client.background_task(json_message))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("钉钉 Stream 任务已取消")
|
||||
break
|
||||
except TimeoutError:
|
||||
logger.warning("钉钉 Stream WebSocket 连接超时(30s),将重试")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except (_ws.exceptions.ConnectionClosedError, ConnectionError, OSError) as e:
|
||||
logger.warning(f"钉钉 Stream 连接断开: {e},10s 后重连")
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉 Stream 异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
cls._connected = False
|
||||
|
||||
@classmethod
|
||||
async def stop(cls):
|
||||
"""停止 Stream 连接"""
|
||||
cls._connected = False
|
||||
if cls._task:
|
||||
cls._task.cancel()
|
||||
cls._task = None
|
||||
if cls._client:
|
||||
cls._client = None
|
||||
cls._started_at = None
|
||||
logger.info("钉钉 Stream 模式已停止")
|
||||
|
||||
@classmethod
|
||||
def is_running(cls) -> bool:
|
||||
"""
|
||||
检查 Stream 是否正在运行。
|
||||
dingtalk_stream SDK 的 start() 可能在连接建立后返回(task done),
|
||||
但连接实际仍然活跃,因此用 _connected 标志而非仅依赖 task 状态。
|
||||
"""
|
||||
if cls._connected and cls._task is not None:
|
||||
if cls._task.done():
|
||||
ex = cls._task.exception() if not cls._task.cancelled() else None
|
||||
if ex or cls._task.cancelled():
|
||||
cls._connected = False
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def status(cls) -> dict:
|
||||
"""获取 Stream 模式状态"""
|
||||
return {
|
||||
"stream_mode": True,
|
||||
"running": cls.is_running(),
|
||||
"started_at": cls._started_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_event_stats(cls) -> dict:
|
||||
"""获取 Stream 事件统计"""
|
||||
return {
|
||||
**cls.status(),
|
||||
"total_events": cls._event_stats.get("total_events", 0),
|
||||
"last_event_type": cls._event_stats.get("last_event_type"),
|
||||
"last_event_time": cls._event_stats.get("last_event_time"),
|
||||
}
|
||||
Reference in New Issue
Block a user