Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
#!/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.feishu_sync.schema import (
|
||||
DeptTreeRequest,
|
||||
FeishuSyncConfigUpdate,
|
||||
TestConnectionRequest,
|
||||
)
|
||||
from core.feishu_sync.service import FeishuSyncService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/feishu-sync", tags=["飞书组织同步"])
|
||||
|
||||
|
||||
@router.post("/test-connection", response_model=ResponseModel, summary="连接测试")
|
||||
async def test_connection(data: TestConnectionRequest):
|
||||
try:
|
||||
result = await FeishuSyncService.test_connection(
|
||||
app_id=data.app_id,
|
||||
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_feishu", mask_secrets=True)
|
||||
|
||||
|
||||
@router.put("/config", response_model=ResponseModel, summary="保存同步配置")
|
||||
async def update_config(
|
||||
data: FeishuSyncConfigUpdate,
|
||||
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_feishu", configs)
|
||||
return ResponseModel(data=updated, message="保存成功")
|
||||
|
||||
|
||||
@router.post("/sync/dept", response_model=ResponseModel, summary="同步组织架构")
|
||||
async def sync_departments(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
result = await FeishuSyncService.sync_departments(db)
|
||||
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.post("/sync/user", response_model=ResponseModel, summary="同步用户")
|
||||
async def sync_users(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
result = await FeishuSyncService.sync_users(db)
|
||||
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.get("/stats", summary="获取同步统计")
|
||||
async def get_sync_stats(db: AsyncSession = Depends(get_db)):
|
||||
stats = await FeishuSyncService.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 FeishuSyncService.get_sync_logs(db, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.post("/dept-tree", summary="获取飞书部门树")
|
||||
async def get_dept_tree(data: DeptTreeRequest):
|
||||
try:
|
||||
tree = await FeishuSyncService.get_feishu_dept_tree(
|
||||
app_id=data.app_id,
|
||||
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 feishu_callback(request: Request):
|
||||
"""
|
||||
飞书事件订阅回调端点(无需认证)
|
||||
|
||||
处理两种场景:
|
||||
1. URL 验证: {"type":"url_verification","challenge":"xxx","token":"xxx"}
|
||||
2. 事件推送: {"encrypt":"xxx"} 或明文 v2.0 事件
|
||||
签名通过 Header: X-Lark-Signature, X-Lark-Request-Timestamp, X-Lark-Request-Nonce
|
||||
"""
|
||||
from core.feishu_sync.callback_handler import FeishuCallbackHandler
|
||||
|
||||
try:
|
||||
crypto = await FeishuCallbackHandler.get_crypto()
|
||||
except ValueError as e:
|
||||
logger.error(f"回调配置不完整: {e}")
|
||||
raise HTTPException(status_code=500, detail="回调配置不完整")
|
||||
|
||||
raw_body = await request.body()
|
||||
body_str = raw_body.decode("utf-8")
|
||||
|
||||
try:
|
||||
body = json.loads(body_str)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="无效的 JSON 请求体")
|
||||
|
||||
# URL 验证阶段飞书可能不带签名头,先尝试处理
|
||||
if body.get("type") == "url_verification":
|
||||
event_body = crypto.decrypt_event(body)
|
||||
return crypto.handle_url_verification(event_body)
|
||||
|
||||
# 事件推送必须验证签名
|
||||
signature = request.headers.get("X-Lark-Signature", "")
|
||||
timestamp = request.headers.get("X-Lark-Request-Timestamp", "")
|
||||
nonce = request.headers.get("X-Lark-Request-Nonce", "")
|
||||
|
||||
if not all([signature, timestamp, nonce]):
|
||||
raise HTTPException(status_code=403, detail="缺少签名验证头")
|
||||
|
||||
if not crypto.verify_signature(timestamp, nonce, body_str, signature):
|
||||
raise HTTPException(status_code=403, detail="签名验证失败")
|
||||
|
||||
event_body = crypto.decrypt_event(body)
|
||||
|
||||
event_type = event_body.get("header", {}).get("event_type", "")
|
||||
logger.info(f"收到飞书回调事件: {event_type}")
|
||||
|
||||
asyncio.create_task(_safe_handle_event(event_body))
|
||||
|
||||
return {"code": 0, "msg": "ok"}
|
||||
|
||||
|
||||
async def _safe_handle_event(event_body: dict) -> None:
|
||||
"""安全地处理回调事件"""
|
||||
from core.feishu_sync.callback_handler import FeishuCallbackHandler
|
||||
try:
|
||||
await FeishuCallbackHandler.handle_event(event_body)
|
||||
except Exception as e:
|
||||
logger.error(f"处理回调事件失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
@router.get("/callback/status", response_model=ResponseModel, summary="查询回调状态")
|
||||
async def get_callback_status():
|
||||
try:
|
||||
result = await FeishuSyncService.get_callback_status()
|
||||
return ResponseModel(data=result, message="查询成功")
|
||||
except Exception as e:
|
||||
return ResponseModel(data={"registered": False}, message="未注册或查询失败")
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
飞书回调事件处理器
|
||||
|
||||
处理飞书事件订阅推送的增量变更事件(v2.0 格式):
|
||||
- 部门:contact.department.created_v3 / contact.department.updated_v3 / contact.department.deleted_v3
|
||||
- 用户:contact.user.created_v3 / contact.user.updated_v3 / contact.user.deleted_v3
|
||||
|
||||
飞书事件结构:
|
||||
{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "...", "event_type": "...", ...},
|
||||
"event": {"object": {...}, "old_object": {...}}
|
||||
}
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
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.feishu_sync.client import FeishuClient
|
||||
from core.feishu_sync.crypto import FeishuCrypto
|
||||
from core.user.model import User
|
||||
from core.user.service import UserService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEPT_EVENTS = {
|
||||
"contact.department.created_v3",
|
||||
"contact.department.updated_v3",
|
||||
"contact.department.deleted_v3",
|
||||
}
|
||||
USER_EVENTS = {
|
||||
"contact.user.created_v3",
|
||||
"contact.user.updated_v3",
|
||||
"contact.user.deleted_v3",
|
||||
}
|
||||
|
||||
# 简易幂等去重(进程内存级),记录已处理的 event_id
|
||||
_processed_event_ids: Set[str] = set()
|
||||
_MAX_EVENT_IDS = 10000
|
||||
|
||||
|
||||
class FeishuCallbackHandler:
|
||||
"""飞书回调事件处理器"""
|
||||
|
||||
@classmethod
|
||||
async def get_crypto(cls) -> FeishuCrypto:
|
||||
"""从配置获取加解密实例"""
|
||||
config = await config_manager.get_group("sync_feishu")
|
||||
encrypt_key = config.get("encrypt_key", "")
|
||||
verification_token = config.get("verification_token", "")
|
||||
if not encrypt_key or not verification_token:
|
||||
raise ValueError("飞书回调配置不完整(encrypt_key / verification_token)")
|
||||
return FeishuCrypto(encrypt_key=encrypt_key, verification_token=verification_token)
|
||||
|
||||
@classmethod
|
||||
async def get_client(cls) -> 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("飞书同步凭证未配置")
|
||||
return FeishuClient(app_id=app_id, app_secret=app_secret)
|
||||
|
||||
@classmethod
|
||||
def _check_idempotent(cls, event_id: str) -> bool:
|
||||
"""检查 event_id 是否已处理(幂等去重),返回 True 表示已处理过"""
|
||||
global _processed_event_ids
|
||||
if event_id in _processed_event_ids:
|
||||
return True
|
||||
if len(_processed_event_ids) >= _MAX_EVENT_IDS:
|
||||
_processed_event_ids = set()
|
||||
_processed_event_ids.add(event_id)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def handle_event(cls, event_body: Dict[str, Any]) -> None:
|
||||
"""
|
||||
处理 v2.0 格式的事件
|
||||
|
||||
event_body 已解密后的完整 JSON:
|
||||
{"schema":"2.0","header":{...},"event":{...}}
|
||||
"""
|
||||
header = event_body.get("header", {})
|
||||
event_type = header.get("event_type", "")
|
||||
event_id = header.get("event_id", "")
|
||||
|
||||
if event_id and cls._check_idempotent(event_id):
|
||||
logger.info(f"重复事件,跳过: event_id={event_id}")
|
||||
return
|
||||
|
||||
config = await config_manager.get_group("sync_feishu")
|
||||
enable_dept = config.get("enable_dept_event") == "true"
|
||||
enable_user = config.get("enable_user_event") == "true"
|
||||
|
||||
event = event_body.get("event", {})
|
||||
|
||||
if event_type in DEPT_EVENTS and enable_dept:
|
||||
await cls._handle_dept_event(event_type, event)
|
||||
elif event_type in USER_EVENTS and enable_user:
|
||||
await cls._handle_user_event(event_type, event)
|
||||
else:
|
||||
logger.info(f"忽略未处理的事件: {event_type}")
|
||||
|
||||
# ==================== 部门事件 ====================
|
||||
|
||||
@classmethod
|
||||
async def _handle_dept_event(cls, event_type: str, event: Dict[str, Any]) -> None:
|
||||
"""处理部门变更事件"""
|
||||
obj = event.get("object", {})
|
||||
dept_id = obj.get("open_department_id", "")
|
||||
|
||||
if not dept_id:
|
||||
logger.warning(f"部门事件缺少 open_department_id: {event}")
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
if event_type == "contact.department.deleted_v3":
|
||||
await cls._remove_dept(db, dept_id)
|
||||
else:
|
||||
await cls._upsert_dept_from_event(db, obj)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"处理部门事件失败 dept_id={dept_id}: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _upsert_dept_from_event(cls, db: AsyncSession, obj: Dict[str, Any]) -> None:
|
||||
"""从事件 object 创建或更新部门"""
|
||||
dept_id = obj.get("open_department_id", "")
|
||||
name = obj.get("name", "")
|
||||
parent_dept_id = obj.get("parent_department_id", "0")
|
||||
|
||||
result = await db.execute(
|
||||
select(Dept).where(
|
||||
Dept.feishu_dept_id == dept_id,
|
||||
Dept.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
local_dept = result.scalar_one_or_none()
|
||||
|
||||
parent_id = None
|
||||
level = 0
|
||||
path = "/"
|
||||
if parent_dept_id and parent_dept_id != "0":
|
||||
parent_result = await db.execute(
|
||||
select(Dept).where(
|
||||
Dept.feishu_dept_id == parent_dept_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,
|
||||
feishu_dept_id=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} (feishu_dept_id={dept_id})")
|
||||
|
||||
@classmethod
|
||||
async def _remove_dept(cls, db: AsyncSession, feishu_dept_id: str) -> None:
|
||||
"""软删除部门"""
|
||||
result = await db.execute(
|
||||
select(Dept).where(
|
||||
Dept.feishu_dept_id == feishu_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} (feishu_dept_id={feishu_dept_id})")
|
||||
else:
|
||||
logger.info(f"部门不存在,跳过删除: feishu_dept_id={feishu_dept_id}")
|
||||
|
||||
# ==================== 用户事件 ====================
|
||||
|
||||
@classmethod
|
||||
async def _handle_user_event(cls, event_type: str, event: Dict[str, Any]) -> None:
|
||||
"""处理用户变更事件"""
|
||||
obj = event.get("object", {})
|
||||
open_id = obj.get("open_id", "")
|
||||
|
||||
if not open_id:
|
||||
logger.warning(f"用户事件缺少 open_id: {event}")
|
||||
return
|
||||
|
||||
client = await cls.get_client()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
dept_result = await db.execute(
|
||||
select(Dept).where(
|
||||
Dept.feishu_dept_id.isnot(None),
|
||||
Dept.is_deleted == False, # noqa: E712
|
||||
)
|
||||
)
|
||||
local_depts = dept_result.scalars().all()
|
||||
feishu_dept_map = {dept.feishu_dept_id: dept.id for dept in local_depts}
|
||||
|
||||
try:
|
||||
if event_type == "contact.user.deleted_v3":
|
||||
await cls._deactivate_user(db, open_id)
|
||||
else:
|
||||
await cls._upsert_user(db, client, open_id, feishu_dept_map)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"处理用户事件失败 open_id={open_id}: {e}")
|
||||
|
||||
@classmethod
|
||||
async def _upsert_user(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
client: FeishuClient,
|
||||
open_id: str,
|
||||
feishu_dept_map: Dict[str, str],
|
||||
) -> None:
|
||||
"""创建或更新单个用户"""
|
||||
import secrets
|
||||
import string
|
||||
|
||||
detail = await client.get_user_detail(open_id)
|
||||
name = detail.get("name", "")
|
||||
mobile = detail.get("mobile", "")
|
||||
email = detail.get("email", "")
|
||||
union_id = detail.get("union_id")
|
||||
status_info = detail.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 = detail.get("department_ids", [])
|
||||
|
||||
local_dept_id = None
|
||||
for did in dept_ids:
|
||||
mapped = feishu_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}"
|
||||
|
||||
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,
|
||||
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()
|
||||
logger.info(f"用户同步成功: {name} (feishu_userid={open_id})")
|
||||
|
||||
@classmethod
|
||||
async def _deactivate_user(cls, db: AsyncSession, open_id: str) -> None:
|
||||
"""用户离职:禁用用户"""
|
||||
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 local_user:
|
||||
local_user.user_status = 0
|
||||
local_user.is_active = False
|
||||
logger.info(f"用户已禁用: {local_user.name} (feishu_userid={open_id})")
|
||||
else:
|
||||
logger.info(f"用户不存在,跳过禁用: feishu_userid={open_id}")
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/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__)
|
||||
|
||||
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
|
||||
|
||||
|
||||
class FeishuClient:
|
||||
"""飞书通讯录 API 客户端"""
|
||||
|
||||
def __init__(self, app_id: str, app_secret: str):
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self._tenant_access_token: Optional[str] = None
|
||||
self._token_expires_at: float = 0
|
||||
|
||||
async def get_tenant_access_token(self) -> str:
|
||||
"""获取 tenant_access_token(带内存缓存,提前 5 分钟过期)"""
|
||||
now = time.time()
|
||||
if self._tenant_access_token and now < self._token_expires_at:
|
||||
return self._tenant_access_token
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{FEISHU_API_BASE}/auth/v3/tenant_access_token/internal",
|
||||
json={"app_id": self.app_id, "app_secret": self.app_secret},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"获取飞书 tenant_access_token 失败: {result.get('msg', result)}")
|
||||
|
||||
self._tenant_access_token = result["tenant_access_token"]
|
||||
self._token_expires_at = now + result.get("expire", 7200) - 300
|
||||
return self._tenant_access_token
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""带 Bearer token 的 GET 请求"""
|
||||
token = await self.get_tenant_access_token()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
f"{FEISHU_API_BASE}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"飞书 API 调用失败 [{path}]: {result.get('msg', result)}")
|
||||
return result
|
||||
|
||||
async def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""带 Bearer token 的 POST 请求"""
|
||||
token = await self.get_tenant_access_token()
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
f"{FEISHU_API_BASE}{path}",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("code") != 0:
|
||||
raise Exception(f"飞书 API 调用失败 [{path}]: {result.get('msg', result)}")
|
||||
return result
|
||||
|
||||
# ==================== 连接测试 ====================
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""测试连接:获取 token + 拉取根部门信息验证"""
|
||||
await self.get_tenant_access_token()
|
||||
result = await self._get("/contact/v3/departments/0", {
|
||||
"department_id_type": "open_department_id",
|
||||
})
|
||||
dept_info = result.get("data", {}).get("department", {})
|
||||
return {
|
||||
"success": True,
|
||||
"corp_name": dept_info.get("name", ""),
|
||||
"dept_id": dept_info.get("open_department_id", "0"),
|
||||
}
|
||||
|
||||
# ==================== 部门 API ====================
|
||||
|
||||
async def get_dept_children(
|
||||
self,
|
||||
dept_id: str = "0",
|
||||
page_size: int = 50,
|
||||
page_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取子部门列表(分页)
|
||||
https://open.feishu.cn/document/server-docs/contact-v3/department/children
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"department_id_type": "open_department_id",
|
||||
"page_size": page_size,
|
||||
}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
result = await self._get(f"/contact/v3/departments/{dept_id}/children", params)
|
||||
return result.get("data", {})
|
||||
|
||||
async def get_dept_detail(self, dept_id: str) -> Dict[str, Any]:
|
||||
"""获取部门详情"""
|
||||
result = await self._get(f"/contact/v3/departments/{dept_id}", {
|
||||
"department_id_type": "open_department_id",
|
||||
})
|
||||
return result.get("data", {}).get("department", {})
|
||||
|
||||
async def get_all_depts(self, root_dept_id: str = "0") -> List[Dict[str, Any]]:
|
||||
"""
|
||||
递归获取指定根部门下的全量部门列表(广度优先 + 自动分页)
|
||||
返回扁平列表,包含 parent_department_id 信息
|
||||
"""
|
||||
all_depts: List[Dict[str, Any]] = []
|
||||
queue = [root_dept_id]
|
||||
|
||||
while queue:
|
||||
current_dept_id = queue.pop(0)
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
data = await self.get_dept_children(current_dept_id, page_size=50, page_token=page_token)
|
||||
items = data.get("items", [])
|
||||
for item in items:
|
||||
all_depts.append(item)
|
||||
child_id = item.get("open_department_id")
|
||||
if child_id:
|
||||
queue.append(child_id)
|
||||
|
||||
if not data.get("has_more", False):
|
||||
break
|
||||
page_token = data.get("page_token")
|
||||
|
||||
return all_depts
|
||||
|
||||
async def get_dept_tree(self, root_dept_id: str = "0") -> List[Dict[str, Any]]:
|
||||
"""获取部门树形结构(供前端选择同步范围)"""
|
||||
all_depts = await self.get_all_depts(root_dept_id)
|
||||
|
||||
dept_map: Dict[str, Dict[str, Any]] = {}
|
||||
for d in all_depts:
|
||||
did = d.get("open_department_id", "")
|
||||
dept_map[did] = {
|
||||
"dept_id": did,
|
||||
"name": d.get("name", ""),
|
||||
"parent_department_id": d.get("parent_department_id", "0"),
|
||||
"children": [],
|
||||
}
|
||||
|
||||
tree: List[Dict[str, Any]] = []
|
||||
for d in dept_map.values():
|
||||
parent_id = d["parent_department_id"]
|
||||
if parent_id in dept_map and parent_id != d["dept_id"]:
|
||||
dept_map[parent_id]["children"].append(d)
|
||||
else:
|
||||
tree.append(d)
|
||||
|
||||
return tree
|
||||
|
||||
# ==================== 用户 API ====================
|
||||
|
||||
async def get_user_list(
|
||||
self,
|
||||
dept_id: str,
|
||||
page_size: int = 50,
|
||||
page_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取部门用户列表(分页)
|
||||
https://open.feishu.cn/document/server-docs/contact-v3/user/find_by_department
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"department_id": dept_id,
|
||||
"department_id_type": "open_department_id",
|
||||
"page_size": page_size,
|
||||
}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
result = await self._get("/contact/v3/users/find_by_department", params)
|
||||
return result.get("data", {})
|
||||
|
||||
async def get_all_users_in_dept(self, dept_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取部门下所有用户(自动翻页)"""
|
||||
users: List[Dict[str, Any]] = []
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
data = await self.get_user_list(dept_id, page_size=50, page_token=page_token)
|
||||
items = data.get("items", [])
|
||||
users.extend(items)
|
||||
|
||||
if not data.get("has_more", False):
|
||||
break
|
||||
page_token = data.get("page_token")
|
||||
|
||||
return users
|
||||
|
||||
async def get_user_detail(self, user_id: str) -> Dict[str, Any]:
|
||||
"""获取用户详情"""
|
||||
result = await self._get(f"/contact/v3/users/{user_id}", {
|
||||
"department_id_type": "open_department_id",
|
||||
})
|
||||
return result.get("data", {}).get("user", {})
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
飞书回调加解密工具
|
||||
|
||||
实现飞书事件订阅所需的签名验证和消息解密。
|
||||
飞书文档:https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case
|
||||
|
||||
加密方案:AES-256-CBC,密钥 = SHA256(encrypt_key),IV 为密文前16字节
|
||||
签名方案:SHA256(timestamp + nonce + encrypt_key + body)
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
|
||||
class FeishuCrypto:
|
||||
"""飞书回调消息加解密"""
|
||||
|
||||
def __init__(self, encrypt_key: str, verification_token: str):
|
||||
self.encrypt_key = encrypt_key
|
||||
self.verification_token = verification_token
|
||||
self._aes_key = hashlib.sha256(encrypt_key.encode("utf-8")).digest() if encrypt_key else None
|
||||
|
||||
def decrypt(self, encrypt_str: str) -> str:
|
||||
"""
|
||||
解密飞书加密消息
|
||||
|
||||
飞书加密格式:base64(iv + AES-256-CBC(plaintext))
|
||||
- 前16字节为IV
|
||||
- 其余为密文(PKCS7 padding 已包含在内)
|
||||
"""
|
||||
if not self._aes_key:
|
||||
raise ValueError("encrypt_key 未配置,无法解密")
|
||||
|
||||
import base64
|
||||
encrypted = base64.b64decode(encrypt_str)
|
||||
|
||||
iv = encrypted[:16]
|
||||
cipher = Cipher(algorithms.AES(self._aes_key), modes.CBC(iv), backend=default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted = decryptor.update(encrypted[16:]) + decryptor.finalize()
|
||||
|
||||
# PKCS7 unpad with validation
|
||||
if not decrypted:
|
||||
raise ValueError("解密结果为空")
|
||||
padding_len = decrypted[-1]
|
||||
if padding_len < 1 or padding_len > 16 or padding_len > len(decrypted):
|
||||
raise ValueError(f"PKCS7 填充长度无效: {padding_len}")
|
||||
plaintext = decrypted[:-padding_len]
|
||||
|
||||
return plaintext.decode("utf-8")
|
||||
|
||||
def verify_signature(self, timestamp: str, nonce: str, body: str, signature: str) -> bool:
|
||||
"""
|
||||
验证飞书回调签名
|
||||
SHA256(timestamp + nonce + encrypt_key + body) == X-Lark-Signature
|
||||
"""
|
||||
raw = f"{timestamp}{nonce}{self.encrypt_key}{body}"
|
||||
calculated = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
return calculated == signature
|
||||
|
||||
def decrypt_event(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
解密事件体:如果 body 包含 encrypt 字段,先解密再解析 JSON
|
||||
"""
|
||||
if "encrypt" in body:
|
||||
plaintext = self.decrypt(body["encrypt"])
|
||||
return json.loads(plaintext)
|
||||
return body
|
||||
|
||||
def handle_url_verification(self, body: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
处理 URL 验证请求
|
||||
飞书发送 {"challenge":"xxx","token":"xxx","type":"url_verification"}
|
||||
需返回 {"challenge":"xxx"}
|
||||
"""
|
||||
token = body.get("token", "")
|
||||
if token != self.verification_token:
|
||||
raise ValueError(f"verification_token 校验失败: 期望 {self.verification_token},实际 {token}")
|
||||
return {"challenge": body.get("challenge", "")}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
FeishuSyncLog Model - 飞书同步日志模型
|
||||
记录每次组织架构/用户同步的执行情况
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, DateTime
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class FeishuSyncLog(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_feishu_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"<FeishuSyncLog {self.sync_type} {self.status}>"
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
飞书同步 Schema - 请求/响应模型
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
app_id: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
|
||||
|
||||
class FeishuSyncConfigUpdate(BaseModel):
|
||||
app_id: 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
|
||||
encrypt_key: Optional[str] = None
|
||||
verification_token: Optional[str] = None
|
||||
callback_url: Optional[str] = None
|
||||
|
||||
|
||||
class DeptTreeRequest(BaseModel):
|
||||
app_id: Optional[str] = None
|
||||
app_secret: Optional[str] = None
|
||||
|
||||
|
||||
class FeishuDeptTreeNode(BaseModel):
|
||||
dept_id: str
|
||||
name: str
|
||||
children: List["FeishuDeptTreeNode"] = []
|
||||
@@ -0,0 +1,523 @@
|
||||
#!/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}
|
||||
Reference in New Issue
Block a user