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
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
企业微信同步 API - 组织架构与用户同步管理接口
"""
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_schema import ResponseModel
from app.database import get_db
from core.wecom_sync.schema import (
DeptTreeRequest,
TestConnectionRequest,
WecomSyncConfigUpdate,
)
from core.wecom_sync.service import WecomSyncService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/wecom-sync", tags=["企业微信组织同步"])
@router.post("/test-connection", response_model=ResponseModel, summary="连接测试")
async def test_connection(data: TestConnectionRequest):
try:
result = await WecomSyncService.test_connection(
corp_id=data.corp_id,
corp_secret=data.corp_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_wecom", mask_secrets=True)
@router.put("/config", response_model=ResponseModel, summary="保存同步配置")
async def update_config(
data: WecomSyncConfigUpdate,
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_wecom", 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 WecomSyncService.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 WecomSyncService.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 WecomSyncService.get_sync_stats(db)
return stats
@router.post("/dept-tree", summary="获取企业微信部门树")
async def get_dept_tree(data: DeptTreeRequest):
try:
tree = await WecomSyncService.get_wecom_dept_tree(
corp_id=data.corp_id,
corp_secret=data.corp_secret,
)
return tree
except Exception as e:
raise HTTPException(status_code=400, detail=f"获取部门树失败: {str(e)}")
# ==================== 回调相关接口 ====================
@router.get("/callback", summary="企业微信回调URL验证")
async def wecom_callback_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
"""
企业微信在配置回调URL时会发送 GET 请求验证URL有效性
需要解密 echostr 并返回明文
"""
from core.wecom_sync.callback_handler import WecomCallbackHandler
try:
crypto = await WecomCallbackHandler.get_crypto()
except ValueError as e:
logger.error(f"回调配置不完整: {e}")
raise HTTPException(status_code=500, detail="回调配置不完整")
try:
reply_echostr = crypto.handle_verify(msg_signature, timestamp, nonce, echostr)
except ValueError:
raise HTTPException(status_code=403, detail="签名验证失败")
return PlainTextResponse(content=reply_echostr)
@router.post("/callback", summary="企业微信事件回调")
async def wecom_callback(
request: Request,
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
):
"""
企业微信事件订阅回调端点(无需认证)
企业微信通过 POST XML 推送事件变更
"""
from core.wecom_sync.callback_handler import WecomCallbackHandler
from xml.etree import ElementTree
try:
crypto = await WecomCallbackHandler.get_crypto()
except ValueError as e:
logger.error(f"回调配置不完整: {e}")
raise HTTPException(status_code=500, detail="回调配置不完整")
body = await request.body()
post_data = body.decode("utf-8")
try:
plaintext = crypto.handle_callback(msg_signature, timestamp, nonce, post_data)
except ValueError:
raise HTTPException(status_code=403, detail="签名验证失败")
# 解析 XML 事件内容
root = ElementTree.fromstring(plaintext)
event_type = ""
change_type = ""
event_node = root.find("Event")
if event_node is not None:
event_type = event_node.text or ""
change_type_node = root.find("ChangeType")
if change_type_node is not None:
change_type = change_type_node.text or ""
# 企业微信通讯录变更事件的 Event 为 "change_contact",具体类型在 ChangeType 中
actual_event = change_type if event_type == "change_contact" else event_type
logger.info(f"收到企业微信回调事件: Event={event_type}, ChangeType={change_type}")
# 提取事件数据
event_data = {}
for child in root:
event_data[child.tag] = child.text
if actual_event:
asyncio.create_task(_safe_handle_event(actual_event, event_data))
return PlainTextResponse(content=crypto.generate_success_response(), media_type="application/xml")
async def _safe_handle_event(event_type: str, event_data: dict) -> None:
from core.wecom_sync.callback_handler import WecomCallbackHandler
try:
await WecomCallbackHandler.handle_event(event_type, event_data)
except Exception as e:
logger.error(f"处理回调事件失败 [{event_type}]: {e}", exc_info=True)
@router.get("/callback/status", response_model=ResponseModel, summary="查询回调状态")
async def get_callback_status():
try:
result = await WecomSyncService.get_callback_status()
return ResponseModel(data=result, message="查询成功")
except Exception as e:
return ResponseModel(data={"registered": False}, message="查询失败")
@@ -0,0 +1,309 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
企业微信回调事件处理器
处理企业微信事件订阅推送的增量变更事件:
- 部门:create_party / update_party / delete_party
- 成员:create_user / update_user / delete_user
"""
import logging
from typing import Any, Dict, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config_manager import config_manager
from app.database import AsyncSessionLocal
from core.dept.model import Dept
from core.user.model import User
from core.user.service import UserService
from core.wecom_sync.client import WecomClient
from core.wecom_sync.crypto import WecomCrypto
logger = logging.getLogger(__name__)
DEPT_EVENTS = {"create_party", "update_party", "delete_party"}
USER_EVENTS = {"create_user", "update_user", "delete_user"}
class WecomCallbackHandler:
"""企业微信回调事件处理器"""
@classmethod
async def get_crypto(cls) -> WecomCrypto:
"""从配置获取加解密实例"""
config = await config_manager.get_group("sync_wecom")
token = config.get("callback_token")
aes_key = config.get("callback_aes_key")
corp_id = config.get("corp_id")
if not all([token, aes_key, corp_id]):
raise ValueError("企业微信回调配置不完整(callback_token / callback_aes_key / corp_id")
return WecomCrypto(token=token, aes_key=aes_key, corp_id=corp_id)
@classmethod
async def get_client(cls) -> WecomClient:
"""从配置获取企业微信客户端"""
config = await config_manager.get_group("sync_wecom")
corp_id = config.get("corp_id")
corp_secret = config.get("corp_secret")
if not corp_id or not corp_secret:
raise ValueError("企业微信同步凭证未配置")
return WecomClient(corp_id=corp_id, corp_secret=corp_secret)
@classmethod
async def handle_event(cls, event_type: str, event_data: Dict[str, Any]) -> None:
"""分发事件到对应处理方法"""
config = await config_manager.get_group("sync_wecom")
enable_dept = config.get("enable_dept_event") == "true"
enable_user = config.get("enable_user_event") == "true"
if event_type in DEPT_EVENTS and enable_dept:
await cls._handle_dept_event(event_type, event_data)
elif event_type in USER_EVENTS and enable_user:
await cls._handle_user_event(event_type, event_data)
else:
logger.info(f"忽略未处理的事件: {event_type}")
# ==================== 部门事件 ====================
@classmethod
async def _handle_dept_event(cls, event_type: str, event_data: Dict[str, Any]) -> None:
"""处理部门变更事件"""
dept_id = event_data.get("Id")
if not dept_id:
logger.warning(f"部门事件缺少 Id: {event_data}")
return
client = await cls.get_client()
async with AsyncSessionLocal() as db:
try:
if event_type == "delete_party":
await cls._remove_dept(db, int(dept_id))
else:
await cls._upsert_dept(db, client, int(dept_id))
except Exception as e:
logger.error(f"处理部门事件失败 dept_id={dept_id}: {e}")
await db.commit()
@classmethod
async def _upsert_dept(cls, db: AsyncSession, client: WecomClient, wecom_dept_id: int) -> None:
"""创建或更新单个部门"""
detail = await client.get_dept_detail(wecom_dept_id)
name = detail.get("name", "")
wecom_parent_id = detail.get("parentid")
result = await db.execute(
select(Dept).where(
Dept.wecom_dept_id == str(wecom_dept_id),
Dept.is_deleted == False, # noqa: E712
)
)
local_dept = result.scalar_one_or_none()
parent_id = None
level = 0
path = "/"
if wecom_parent_id:
parent_result = await db.execute(
select(Dept).where(
Dept.wecom_dept_id == str(wecom_parent_id),
Dept.is_deleted == False, # noqa: E712
)
)
parent_dept = parent_result.scalar_one_or_none()
if parent_dept:
parent_id = parent_dept.id
level = parent_dept.level + 1
path = f"{parent_dept.path or '/'}{parent_dept.id}/"
if local_dept:
local_dept.name = name
local_dept.parent_id = parent_id
local_dept.level = level
local_dept.path = path
else:
local_dept = Dept(
name=name,
wecom_dept_id=str(wecom_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} (wecom_dept_id={wecom_dept_id})")
@classmethod
async def _remove_dept(cls, db: AsyncSession, wecom_dept_id: int) -> None:
"""软删除部门"""
result = await db.execute(
select(Dept).where(
Dept.wecom_dept_id == str(wecom_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} (wecom_dept_id={wecom_dept_id})")
# ==================== 用户事件 ====================
@classmethod
async def _handle_user_event(cls, event_type: str, event_data: Dict[str, Any]) -> None:
"""处理用户变更事件"""
userid = event_data.get("UserID")
if not userid:
logger.warning(f"用户事件缺少 UserID: {event_data}")
return
new_userid = event_data.get("NewUserID")
client = await cls.get_client()
async with AsyncSessionLocal() as db:
dept_result = await db.execute(
select(Dept).where(
Dept.wecom_dept_id.isnot(None),
Dept.is_deleted == False, # noqa: E712
)
)
local_depts = dept_result.scalars().all()
wecom_dept_map = {dept.wecom_dept_id: dept.id for dept in local_depts}
try:
if event_type == "delete_user":
await cls._deactivate_user(db, userid)
elif event_type == "update_user" and new_userid:
await cls._update_userid(db, userid, new_userid)
await cls._upsert_user(db, client, new_userid, wecom_dept_map)
else:
await cls._upsert_user(db, client, userid, wecom_dept_map)
except Exception as e:
logger.error(f"处理用户事件失败 userid={userid}: {e}")
await db.commit()
@classmethod
async def _upsert_user(
cls,
db: AsyncSession,
client: WecomClient,
wecom_userid: str,
wecom_dept_map: Dict[str, str],
) -> None:
"""创建或更新单个用户"""
import secrets
import string
detail = await client.get_user_detail(wecom_userid)
name = detail.get("name", "")
mobile = detail.get("mobile", "")
email = detail.get("email", "")
status = detail.get("status", 1)
dept_ids = detail.get("department", [])
local_dept_id = None
main_department = detail.get("main_department")
if main_department and str(main_department) in wecom_dept_map:
local_dept_id = wecom_dept_map[str(main_department)]
else:
for did in dept_ids:
mapped = wecom_dept_map.get(str(did))
if mapped:
local_dept_id = mapped
break
# 按 wecom_userid 匹配
result = await db.execute(
select(User).where(
User.wecom_userid == wecom_userid,
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()
active = status == 1
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.wecom_userid = wecom_userid
local_user.user_status = 1 if active else 0
local_user.is_active = active
else:
username = mobile or f"wc_{wecom_userid}"
existing = await db.execute(select(User).where(User.username == username))
if existing.scalar_one_or_none():
username = f"wc_{wecom_userid}"
chars = string.ascii_letters + string.digits + "!@#$%"
password = "".join(secrets.choice(chars) for _ in range(16))
local_user = User(
username=username,
password=UserService.hash_password(password),
name=name,
mobile=mobile or None,
email=email or None,
dept_id=local_dept_id,
wecom_userid=wecom_userid,
user_type=1,
user_status=1 if active else 0,
is_active=active,
)
db.add(local_user)
await db.flush()
logger.info(f"用户同步成功: {name} (wecom_userid={wecom_userid})")
@classmethod
async def _update_userid(cls, db: AsyncSession, old_userid: str, new_userid: str) -> None:
"""处理 userid 变更"""
result = await db.execute(
select(User).where(
User.wecom_userid == old_userid,
User.is_deleted == False, # noqa: E712
)
)
local_user = result.scalar_one_or_none()
if local_user:
local_user.wecom_userid = new_userid
await db.flush()
logger.info(f"用户 userid 已更新: {old_userid} -> {new_userid}")
@classmethod
async def _deactivate_user(cls, db: AsyncSession, wecom_userid: str) -> None:
"""用户离职:禁用用户"""
result = await db.execute(
select(User).where(
User.wecom_userid == wecom_userid,
User.is_deleted == False, # noqa: E712
)
)
local_user = result.scalar_one_or_none()
if local_user:
local_user.user_status = 0
local_user.is_active = False
logger.info(f"用户已禁用: {local_user.name} (wecom_userid={wecom_userid})")
+180
View File
@@ -0,0 +1,180 @@
#!/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__)
WECOM_API_BASE = "https://qyapi.weixin.qq.com/cgi-bin"
class WecomClient:
"""企业微信通讯录 API 客户端"""
def __init__(self, corp_id: str, corp_secret: str):
self.corp_id = corp_id
self.corp_secret = corp_secret
self._access_token: Optional[str] = None
self._token_expires_at: float = 0
async def get_access_token(self) -> str:
"""获取 access_token(带内存缓存,提前 5 分钟过期)"""
now = time.time()
if self._access_token and now < self._token_expires_at:
return self._access_token
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{WECOM_API_BASE}/gettoken",
params={"corpid": self.corp_id, "corpsecret": self.corp_secret},
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") != 0:
raise Exception(f"获取企业微信 access_token 失败: {result.get('errmsg', result)}")
self._access_token = result["access_token"]
self._token_expires_at = now + result.get("expires_in", 7200) - 300
return self._access_token
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""带 access_token 的 GET 请求"""
token = await self.get_access_token()
all_params = {"access_token": token}
if params:
all_params.update(params)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{WECOM_API_BASE}{path}", params=all_params)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") != 0:
raise Exception(f"企业微信 API 调用失败 [{path}]: {result.get('errmsg', result)}")
return result
async def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
"""带 access_token 的 POST 请求"""
token = await self.get_access_token()
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{WECOM_API_BASE}{path}",
params={"access_token": token},
json=body,
)
resp.raise_for_status()
result = resp.json()
if result.get("errcode") != 0:
raise Exception(f"企业微信 API 调用失败 [{path}]: {result.get('errmsg', result)}")
return result
# ==================== 连接测试 ====================
async def test_connection(self) -> Dict[str, Any]:
"""测试连接:获取 token + 拉取根部门信息验证"""
await self.get_access_token()
result = await self._get("/department/list", {"id": 1})
dept_list = result.get("department", [])
root_name = ""
for d in dept_list:
if d.get("id") == 1:
root_name = d.get("name", "")
break
return {
"success": True,
"corp_name": root_name,
"dept_count": len(dept_list),
}
# ==================== 部门 API ====================
async def get_dept_list(self, dept_id: int = 1) -> List[Dict[str, Any]]:
"""
获取部门列表
https://developer.work.weixin.qq.com/document/path/90208
"""
result = await self._get("/department/list", {"id": dept_id})
return result.get("department", [])
async def get_dept_detail(self, dept_id: int) -> Dict[str, Any]:
"""获取单个部门详情"""
result = await self._get("/department/get", {"id": dept_id})
return result.get("department", {})
async def get_all_depts(self, root_dept_id: int = 1) -> List[Dict[str, Any]]:
"""
获取指定根部门下的全量部门列表
企业微信 /department/list 返回指定部门及所有子部门(递归),结果扁平
"""
all_depts = await self.get_dept_list(root_dept_id)
return [d for d in all_depts if d.get("id") != root_dept_id]
async def get_dept_tree(self, root_dept_id: int = 1) -> List[Dict[str, Any]]:
"""获取部门树形结构(供前端选择同步范围)"""
all_depts = await self.get_dept_list(root_dept_id)
dept_map: Dict[int, Dict[str, Any]] = {}
for d in all_depts:
dept_map[d["id"]] = {
"dept_id": d["id"],
"name": d.get("name", ""),
"parentid": d.get("parentid", 0),
"children": [],
}
tree: List[Dict[str, Any]] = []
for d in dept_map.values():
parent_id = d["parentid"]
if parent_id in dept_map and parent_id != d["dept_id"]:
dept_map[parent_id]["children"].append(d)
elif d["dept_id"] == root_dept_id:
tree.append(d)
else:
tree.append(d)
return tree
# ==================== 用户/成员 API ====================
async def get_user_list(self, dept_id: int) -> List[Dict[str, Any]]:
"""
获取部门成员详情列表
https://developer.work.weixin.qq.com/document/path/90201
"""
result = await self._get("/user/list", {"department_id": dept_id})
return result.get("userlist", [])
async def get_user_detail(self, userid: str) -> Dict[str, Any]:
"""获取成员详情"""
return await self._get("/user/get", {"userid": userid})
# ==================== 回调注册 API ====================
async def create_callback(
self,
callback_url: str,
callback_tag: List[str],
token: str,
aes_key: str,
) -> Dict[str, Any]:
"""
注册事件回调
https://developer.work.weixin.qq.com/document/path/90930
"""
# 企业微信的回调设置不是通过API注册的,而是在管理后台配置
# 这里提供一个验证URL有效性的接口(回调模式验证由 callback 端点的 GET 处理)
return {"errcode": 0, "errmsg": "ok"}
async def delete_callback(self) -> Dict[str, Any]:
"""企业微信回调通过管理后台管理,这里仅做标记"""
return {"errcode": 0, "errmsg": "ok"}
async def get_callback(self) -> Dict[str, Any]:
"""查询回调设置状态"""
return {"errcode": 0, "errmsg": "ok"}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
企业微信回调加解密工具
实现企业微信事件订阅所需的签名验证和消息加解密。
基于企业微信官方文档:https://developer.work.weixin.qq.com/document/path/90968
加密方案:AES-CBCPKCS#7 paddingkey 由 EncodingAESKey + "=" base64 解码得到
签名方案:SHA1(sort(token, timestamp, nonce, encrypt))
"""
import base64
import hashlib
import secrets
import struct
import time
from typing import Dict
from xml.etree import ElementTree
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
class WecomCrypto:
"""企业微信回调消息加解密"""
def __init__(self, token: str, aes_key: str, corp_id: str):
self.token = token
self.corp_id = corp_id
self.aes_key_bytes = base64.b64decode(aes_key + "=")
def _generate_nonce(self, length: int = 16) -> str:
return secrets.token_hex(length // 2)
def _pkcs7_pad(self, data: bytes, block_size: int = 32) -> bytes:
padding_len = block_size - (len(data) % block_size)
return data + bytes([padding_len] * padding_len)
def _pkcs7_unpad(self, data: bytes) -> bytes:
padding_len = data[-1]
return data[:-padding_len]
def _sign(self, token: str, timestamp: str, nonce: str, encrypt: str) -> str:
"""计算签名 SHA1(sort(token, timestamp, nonce, encrypt))"""
parts = sorted([token, timestamp, nonce, encrypt])
raw = "".join(parts).encode("utf-8")
return hashlib.sha1(raw).hexdigest()
def encrypt(self, plaintext: str) -> Dict[str, str]:
"""
加密明文消息,返回回调响应体
返回: {"msg_signature": ..., "timeStamp": ..., "nonce": ..., "encrypt": ...}
"""
nonce = self._generate_nonce()
timestamp = str(int(time.time()))
random_bytes = secrets.token_bytes(16)
text_bytes = plaintext.encode("utf-8")
corp_id_bytes = self.corp_id.encode("utf-8")
content = random_bytes + struct.pack("!I", len(text_bytes)) + text_bytes + corp_id_bytes
padded = self._pkcs7_pad(content)
iv = self.aes_key_bytes[:16]
cipher = Cipher(algorithms.AES(self.aes_key_bytes), modes.CBC(iv), backend=default_backend())
encryptor = cipher.encryptor()
encrypted = encryptor.update(padded) + encryptor.finalize()
encrypt_str = base64.b64encode(encrypted).decode("utf-8")
signature = self._sign(self.token, timestamp, nonce, encrypt_str)
return {
"msg_signature": signature,
"timeStamp": timestamp,
"nonce": nonce,
"encrypt": encrypt_str,
}
def decrypt(self, encrypt_str: str) -> str:
"""解密密文,返回明文 XML 字符串,并校验 receiveid"""
encrypted = base64.b64decode(encrypt_str)
iv = self.aes_key_bytes[:16]
cipher = Cipher(algorithms.AES(self.aes_key_bytes), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
unpadded = self._pkcs7_unpad(decrypted)
msg_len = struct.unpack("!I", unpadded[16:20])[0]
plaintext = unpadded[20:20 + msg_len].decode("utf-8")
receive_id = unpadded[20 + msg_len:].decode("utf-8")
if receive_id != self.corp_id:
raise ValueError(f"receiveid 校验失败: 期望 {self.corp_id},实际 {receive_id}")
return plaintext
def verify_signature(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool:
"""验证回调签名"""
calculated = self._sign(self.token, timestamp, nonce, encrypt)
return calculated == msg_signature
def handle_callback(
self,
msg_signature: str,
timestamp: str,
nonce: str,
post_data: str,
) -> str:
"""
处理 POST 回调:从 XML 中提取 Encrypt,验签 + 解密
返回解密后的明文 XML 字符串
"""
root = ElementTree.fromstring(post_data)
encrypt = root.find("Encrypt").text
if not self.verify_signature(msg_signature, timestamp, nonce, encrypt):
raise ValueError("签名验证失败")
return self.decrypt(encrypt)
def handle_verify(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
"""
处理 GET 验证请求(URL 验证)
验签后解密 echostr,返回明文用于响应
"""
if not self.verify_signature(msg_signature, timestamp, nonce, echostr):
raise ValueError("签名验证失败")
return self.decrypt(echostr)
def generate_success_response(self) -> str:
"""生成回调成功响应(加密 "success",返回 XML"""
result = self.encrypt("success")
return (
f'<xml>'
f'<Encrypt><![CDATA[{result["encrypt"]}]]></Encrypt>'
f'<MsgSignature><![CDATA[{result["msg_signature"]}]]></MsgSignature>'
f'<TimeStamp>{result["timeStamp"]}</TimeStamp>'
f'<Nonce><![CDATA[{result["nonce"]}]]></Nonce>'
f'</xml>'
)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
WecomSyncLog Model - 企业微信同步日志模型
记录每次组织架构/用户同步的执行情况
"""
from sqlalchemy import Column, String, Text, Integer, DateTime
from app.base_model import BaseModel
class WecomSyncLog(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_wecom_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"<WecomSyncLog {self.sync_type} {self.status}>"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
企业微信同步 Schema - 请求/响应模型
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class TestConnectionRequest(BaseModel):
corp_id: Optional[str] = None
corp_secret: Optional[str] = None
class WecomSyncConfigUpdate(BaseModel):
corp_id: Optional[str] = None
corp_secret: Optional[str] = None
sync_dept_id: Optional[str] = None
sync_root_dept_id: Optional[str] = None
enable_dept_event: Optional[str] = None
enable_user_event: Optional[str] = None
callback_token: Optional[str] = None
callback_aes_key: Optional[str] = None
callback_url: Optional[str] = None
class SyncTypeStats(BaseModel):
total_count: int = 0
success_count: int = 0
fail_count: int = 0
not_synced: int = 0
status: Optional[str] = None
sync_time: Optional[str] = None
class WecomDeptTreeNode(BaseModel):
dept_id: int
name: str
children: List["WecomDeptTreeNode"] = []
class DeptTreeRequest(BaseModel):
corp_id: Optional[str] = None
corp_secret: Optional[str] = None
+438
View File
@@ -0,0 +1,438 @@
#!/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.user.model import User
from core.user.service import UserService
from core.wecom_sync.client import WecomClient
from core.wecom_sync.model import WecomSyncLog
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 WecomSyncService:
"""企业微信组织架构同步服务"""
@staticmethod
def _is_masked(value: Optional[str]) -> bool:
"""判断值是否为脱敏值"""
return bool(value and "***" in value)
@staticmethod
async def _get_client() -> WecomClient:
config = await config_manager.get_group("sync_wecom")
corp_id = config.get("corp_id")
corp_secret = config.get("corp_secret")
if not corp_id or not corp_secret:
raise ValueError("企业微信同步凭证未配置(corp_id/corp_secret")
return WecomClient(corp_id=corp_id, corp_secret=corp_secret)
@staticmethod
async def _get_sync_config() -> Dict[str, Any]:
return await config_manager.get_group("sync_wecom")
@classmethod
async def _resolve_client(cls, corp_id: str = None, corp_secret: str = None) -> WecomClient:
"""解析客户端凭证:有效明文直接用,脱敏值或空值从数据库获取"""
use_input = (
corp_id and corp_secret
and not cls._is_masked(corp_id)
and not cls._is_masked(corp_secret)
)
if use_input:
return WecomClient(corp_id=corp_id, corp_secret=corp_secret)
return await cls._get_client()
# ==================== 连接测试 ====================
@classmethod
async def test_connection(cls, corp_id: str = None, corp_secret: str = None) -> Dict[str, Any]:
client = await cls._resolve_client(corp_id, corp_secret)
return await client.test_connection()
# ==================== 部门树 ====================
@classmethod
async def get_wecom_dept_tree(
cls,
corp_id: str = None,
corp_secret: str = None,
) -> List[Dict[str, Any]]:
client = await cls._resolve_client(corp_id, corp_secret)
tree = await client.get_dept_tree(1)
return tree
# ==================== 部门同步 ====================
@classmethod
async def sync_departments(cls, db: AsyncSession) -> Dict[str, Any]:
config = await cls._get_sync_config()
sync_dept_id = int(config.get("sync_dept_id") or 1)
sync_root_dept_id = config.get("sync_root_dept_id") or None
client = await cls._get_client()
log = WecomSyncLog(
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:
all_depts_raw = await client.get_dept_list(sync_dept_id)
total_count = len(all_depts_raw)
# 按 parentid 排序保证父部门先处理
all_depts_raw.sort(key=lambda d: d.get("id", 0))
dt_to_local: Dict[int, str] = {}
for wecom_dept in all_depts_raw:
wecom_dept_id = wecom_dept.get("id")
name = wecom_dept.get("name", "")
wecom_parent_id = wecom_dept.get("parentid")
try:
result = await db.execute(
select(Dept).where(
Dept.wecom_dept_id == str(wecom_dept_id),
Dept.is_deleted == False, # noqa: E712
)
)
local_dept = result.scalar_one_or_none()
if wecom_dept_id == sync_dept_id:
parent_id = sync_root_dept_id
elif wecom_parent_id and int(wecom_parent_id) in dt_to_local:
parent_id = dt_to_local[int(wecom_parent_id)]
elif wecom_parent_id == sync_dept_id and sync_root_dept_id:
parent_id = dt_to_local.get(sync_dept_id, sync_root_dept_id)
else:
parent_id = None
level = 0
path = "/"
if parent_id:
parent_result = await db.execute(
select(Dept).where(Dept.id == parent_id)
)
parent = parent_result.scalar_one_or_none()
if parent:
level = parent.level + 1
path = f"{parent.path or '/'}{parent.id}/"
if local_dept:
local_dept.name = name
local_dept.parent_id = parent_id
local_dept.level = level
local_dept.path = path
else:
local_dept = Dept(
name=name,
wecom_dept_id=str(wecom_dept_id),
parent_id=parent_id,
level=level,
path=path,
dept_type="department",
status=True,
)
db.add(local_dept)
await db.flush()
dt_to_local[wecom_dept_id] = local_dept.id
success_count += 1
except Exception as e:
logger.error(f"同步部门失败 dept_id={wecom_dept_id}: {e}")
errors.append({"dept_id": wecom_dept_id, "name": 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]:
client = await cls._get_client()
log = WecomSyncLog(
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.wecom_dept_id.isnot(None),
Dept.is_deleted == False, # noqa: E712
)
)
local_depts = result.scalars().all()
wecom_dept_map = {dept.wecom_dept_id: dept.id for dept in local_depts}
for dept in local_depts:
try:
wecom_dept_id = int(dept.wecom_dept_id)
users = await client.get_user_list(wecom_dept_id)
for wecom_user in users:
userid = wecom_user.get("userid")
if not userid or userid in processed_userids:
continue
processed_userids.add(userid)
total_count += 1
try:
await cls._upsert_user(db, wecom_user, wecom_dept_map)
success_count += 1
except Exception as e:
logger.error(f"同步用户失败 userid={userid}: {e}")
errors.append({
"userid": userid,
"name": wecom_user.get("name", ""),
"error": str(e),
})
except Exception as e:
logger.error(f"拉取部门用户失败 dept={dept.wecom_dept_id}: {e}")
errors.append({
"dept_id": dept.wecom_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,
wecom_user: Dict[str, Any],
wecom_dept_map: Dict[str, str],
) -> None:
userid = wecom_user.get("userid")
name = wecom_user.get("name", "")
mobile = wecom_user.get("mobile", "")
email = wecom_user.get("email", "")
status = wecom_user.get("status", 1)
dept_ids = wecom_user.get("department", [])
main_department = wecom_user.get("main_department")
local_dept_id = None
if main_department and str(main_department) in wecom_dept_map:
local_dept_id = wecom_dept_map[str(main_department)]
else:
for did in dept_ids:
mapped = wecom_dept_map.get(str(did))
if mapped:
local_dept_id = mapped
break
# 按 wecom_userid 匹配
result = await db.execute(
select(User).where(
User.wecom_userid == userid,
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()
active = status == 1
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.wecom_userid = userid
local_user.user_status = 1 if active else 0
local_user.is_active = active
else:
username = mobile or f"wc_{userid}"
existing = await db.execute(
select(User).where(User.username == username)
)
if existing.scalar_one_or_none():
username = f"wc_{userid}"
local_user = User(
username=username,
password=UserService.hash_password(_generate_random_password()),
name=name,
mobile=mobile or None,
email=email or None,
dept_id=local_dept_id,
wecom_userid=userid,
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(WecomSyncLog)
.where(
WecomSyncLog.sync_type == sync_type,
WecomSyncLog.is_deleted == False, # noqa: E712
)
.order_by(desc(WecomSyncLog.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
# ==================== 回调状态 ====================
CALLBACK_TAGS = [
"create_party",
"update_party",
"delete_party",
"create_user",
"update_user",
"delete_user",
]
@classmethod
async def get_callback_status(cls) -> Dict[str, Any]:
"""查询回调配置状态(企业微信回调在管理后台配置,这里只检查本地配置是否完整)"""
config = await cls._get_sync_config()
token = config.get("callback_token")
aes_key = config.get("callback_aes_key")
callback_url = config.get("callback_url")
registered = bool(token and aes_key and callback_url)
return {
"registered": registered,
"callback_url": callback_url or "",
"subscribed_events": cls.CALLBACK_TAGS if registered else [],
}