Build lightweight AI agent admin
This commit is contained in:
@@ -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})")
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
企业微信回调加解密工具
|
||||
|
||||
实现企业微信事件订阅所需的签名验证和消息加解密。
|
||||
基于企业微信官方文档:https://developer.work.weixin.qq.com/document/path/90968
|
||||
|
||||
加密方案:AES-CBC,PKCS#7 padding,key 由 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>'
|
||||
)
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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 [],
|
||||
}
|
||||
Reference in New Issue
Block a user