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
+177
View File
@@ -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}")
+217
View File
@@ -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", "")}
+38
View File
@@ -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"] = []
+523
View File
@@ -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}