Files
ai-agent-admin/backend-fastapi/core/feishu_sync/service.py
T
2026-06-08 18:14:59 +08:00

524 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
飞书组织架构同步服务
实现部门和用户从飞书到本系统的单向同步
"""
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.feishu_sync.client import FeishuClient
from core.feishu_sync.model import FeishuSyncLog
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 FeishuSyncService:
"""飞书组织架构同步服务"""
@staticmethod
def _is_masked(value: Optional[str]) -> bool:
"""判断值是否为脱敏值"""
return bool(value and "***" in value)
@staticmethod
async def _get_client() -> FeishuClient:
"""从配置获取飞书客户端"""
config = await config_manager.get_group("sync_feishu")
app_id = config.get("app_id")
app_secret = config.get("app_secret")
if not app_id or not app_secret:
raise ValueError("飞书同步凭证未配置(app_id/app_secret")
return FeishuClient(app_id=app_id, app_secret=app_secret)
@staticmethod
async def _get_sync_config() -> Dict[str, Any]:
return await config_manager.get_group("sync_feishu")
@classmethod
async def _resolve_client(cls, app_id: str = None, app_secret: str = None) -> FeishuClient:
"""解析客户端凭证:有效明文直接用,脱敏值或空值从数据库获取"""
use_input = (
app_id and app_secret
and not cls._is_masked(app_id)
and not cls._is_masked(app_secret)
)
if use_input:
return FeishuClient(app_id=app_id, app_secret=app_secret)
return await cls._get_client()
# ==================== 连接测试 ====================
@classmethod
async def test_connection(cls, app_id: str = None, app_secret: str = None) -> Dict[str, Any]:
client = await cls._resolve_client(app_id, app_secret)
return await client.test_connection()
# ==================== 部门树(供前端选择范围) ====================
@classmethod
async def get_feishu_dept_tree(
cls,
app_id: str = None,
app_secret: str = None,
) -> List[Dict[str, Any]]:
"""获取飞书部门树(用于前端选择同步范围)"""
client = await cls._resolve_client(app_id, app_secret)
root_detail = await client.get_dept_detail("0")
children = await client.get_dept_tree("0")
return [{
"dept_id": "0",
"name": root_detail.get("name", "根部门"),
"children": children,
}]
# ==================== 部门同步 ====================
@classmethod
async def sync_departments(cls, db: AsyncSession) -> Dict[str, Any]:
"""
全量同步飞书部门到本系统
流程:
1. 从飞书拉取指定根部门下的全量部门
2. 按层级顺序处理,通过 feishu_dept_id 匹配本地 Dept
3. 已存在则更新,不存在则创建
"""
config = await cls._get_sync_config()
sync_dept_id = config.get("sync_dept_id") or "0"
sync_root_dept_id = config.get("sync_root_dept_id") or None
client = await cls._get_client()
log = FeishuSyncLog(
sync_type="dept",
status="running",
started_at=datetime.now(),
)
db.add(log)
await db.commit()
await db.refresh(log)
errors = []
success_count = 0
total_count = 0
try:
root_detail = await client.get_dept_detail(sync_dept_id)
all_depts = await client.get_all_depts(sync_dept_id)
root_detail["parent_department_id"] = None
root_detail["open_department_id"] = sync_dept_id
all_feishu_depts = [root_detail] + all_depts
total_count = len(all_feishu_depts)
# feishu_dept_id -> 本地 dept_id 映射
fs_to_local: Dict[str, str] = {}
for fs_dept in all_feishu_depts:
fs_dept_id = fs_dept.get("open_department_id", "")
fs_name = fs_dept.get("name", "")
fs_parent_id = fs_dept.get("parent_department_id")
try:
result = await db.execute(
select(Dept).where(
Dept.feishu_dept_id == fs_dept_id,
Dept.is_deleted == False, # noqa: E712
)
)
local_dept = result.scalar_one_or_none()
if fs_dept_id == sync_dept_id:
parent_id = sync_root_dept_id
elif fs_parent_id and fs_parent_id in fs_to_local:
parent_id = fs_to_local[fs_parent_id]
elif fs_parent_id == sync_dept_id and sync_root_dept_id:
parent_id = fs_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 = fs_name
local_dept.parent_id = parent_id
local_dept.level = level
local_dept.path = path
else:
local_dept = Dept(
name=fs_name,
feishu_dept_id=fs_dept_id,
parent_id=parent_id,
level=level,
path=path,
dept_type="department",
status=True,
)
db.add(local_dept)
await db.flush()
fs_to_local[fs_dept_id] = local_dept.id
success_count += 1
except Exception as e:
logger.error(f"同步部门失败 dept_id={fs_dept_id}: {e}")
errors.append({"dept_id": fs_dept_id, "name": fs_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()
raise
return {
"total": total_count,
"success": success_count,
"fail": len(errors),
"status": log.status,
"errors": errors,
}
# ==================== 用户同步 ====================
@classmethod
async def sync_users(cls, db: AsyncSession) -> Dict[str, Any]:
"""
全量同步飞书用户到本系统
流程:
1. 遍历已同步的所有部门(有 feishu_dept_id 的)
2. 拉取每个部门下的用户
3. 通过 feishu_userid 匹配,已有则更新,不存在则创建
"""
client = await cls._get_client()
log = FeishuSyncLog(
sync_type="user",
status="running",
started_at=datetime.now(),
)
db.add(log)
await db.commit()
await db.refresh(log)
errors = []
success_count = 0
total_count = 0
processed_userids = set()
try:
result = await db.execute(
select(Dept).where(
Dept.feishu_dept_id.isnot(None),
Dept.is_deleted == False, # noqa: E712
)
)
local_depts = result.scalars().all()
fs_dept_map = {dept.feishu_dept_id: dept.id for dept in local_depts}
for dept in local_depts:
try:
users = await client.get_all_users_in_dept(dept.feishu_dept_id)
for fs_user in users:
open_id = fs_user.get("open_id", "")
if not open_id or open_id in processed_userids:
continue
processed_userids.add(open_id)
total_count += 1
try:
await cls._upsert_user(db, fs_user, fs_dept_map)
success_count += 1
except Exception as e:
logger.error(f"同步用户失败 open_id={open_id}: {e}")
errors.append({
"open_id": open_id,
"name": fs_user.get("name", ""),
"error": str(e),
})
except Exception as e:
logger.error(f"拉取部门用户失败 dept={dept.feishu_dept_id}: {e}")
errors.append({
"dept_id": dept.feishu_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()
raise
return {
"total": total_count,
"success": success_count,
"fail": len(errors),
"status": log.status,
"errors": errors,
}
@classmethod
async def _upsert_user(
cls,
db: AsyncSession,
fs_user: Dict[str, Any],
fs_dept_map: Dict[str, str],
) -> None:
"""新增或更新单个用户"""
open_id = fs_user.get("open_id", "")
union_id = fs_user.get("union_id")
name = fs_user.get("name", "")
mobile = fs_user.get("mobile", "")
email = fs_user.get("email", "")
avatar_info = fs_user.get("avatar", {})
avatar = avatar_info.get("avatar_72", "") if isinstance(avatar_info, dict) else ""
status_info = fs_user.get("status", {})
active = (
status_info.get("is_activated", True)
and not status_info.get("is_frozen", False)
and not status_info.get("is_resigned", False)
and not status_info.get("is_exited", False)
)
dept_ids = fs_user.get("department_ids", [])
local_dept_id = None
if dept_ids:
for did in dept_ids:
mapped = fs_dept_map.get(did)
if mapped:
local_dept_id = mapped
break
result = await db.execute(
select(User).where(
User.feishu_userid == open_id,
User.is_deleted == False, # noqa: E712
)
)
local_user = result.scalar_one_or_none()
if not local_user and union_id:
result = await db.execute(
select(User).where(
User.feishu_union_id == union_id,
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.feishu_userid = open_id
if union_id:
local_user.feishu_union_id = union_id
local_user.user_status = 1 if active else 0
local_user.is_active = active
else:
username = mobile or f"fs_{open_id}"
existing = await db.execute(
select(User).where(User.username == username)
)
if existing.scalar_one_or_none():
username = f"fs_{open_id}"
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,
feishu_userid=open_id,
feishu_union_id=union_id 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]:
"""获取最新的同步统计数据"""
stats = {}
for sync_type in ("dept", "user"):
result = await db.execute(
select(FeishuSyncLog)
.where(
FeishuSyncLog.sync_type == sync_type,
FeishuSyncLog.is_deleted == False, # noqa: E712
)
.order_by(desc(FeishuSyncLog.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_logs(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
"""获取同步日志列表"""
offset = (page - 1) * page_size
result = await db.execute(
select(FeishuSyncLog)
.where(FeishuSyncLog.is_deleted == False) # noqa: E712
.order_by(desc(FeishuSyncLog.sys_create_datetime))
.offset(offset)
.limit(page_size)
)
logs = result.scalars().all()
from sqlalchemy import func
count_result = await db.execute(
select(func.count(FeishuSyncLog.id)).where(
FeishuSyncLog.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,
}
# ==================== 事件回调管理 ====================
@classmethod
async def get_callback_status(cls) -> Dict[str, Any]:
"""查询回调配置状态(飞书回调在管理后台手动配置)"""
config = await cls._get_sync_config()
encrypt_key = config.get("encrypt_key", "")
verification_token = config.get("verification_token", "")
callback_url = config.get("callback_url", "")
if encrypt_key and verification_token and callback_url:
return {
"registered": True,
"callback_url": callback_url,
"subscribed_events": [
"contact.department.created_v3",
"contact.department.updated_v3",
"contact.department.deleted_v3",
"contact.user.created_v3",
"contact.user.updated_v3",
"contact.user.deleted_v3",
],
}
return {"registered": False}