Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+249
View File
@@ -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-CBCPKCS#7 paddingkey 由 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"),
}