- 新增 dingtalk_service.py 调用钉钉开放API - 支持获取 Access Token、部门列表、员工列表 - employee_sync_service 改为从钉钉API获取员工 - 前端配置界面支持配置 CorpId、ClientId、ClientSecret - 移除外部数据库表依赖
This commit is contained in:
276
backend/app/services/dingtalk_service.py
Normal file
276
backend/app/services/dingtalk_service.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
钉钉开放平台 API 服务
|
||||
用于通过钉钉 API 获取组织架构和员工信息
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DingTalkService:
|
||||
"""钉钉 API 服务"""
|
||||
|
||||
BASE_URL = "https://api.dingtalk.com"
|
||||
OAPI_URL = "https://oapi.dingtalk.com"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corp_id: str,
|
||||
client_id: str,
|
||||
client_secret: str
|
||||
):
|
||||
"""
|
||||
初始化钉钉服务
|
||||
|
||||
Args:
|
||||
corp_id: 企业 CorpId
|
||||
client_id: 应用 ClientId (AppKey)
|
||||
client_secret: 应用 ClientSecret (AppSecret)
|
||||
"""
|
||||
self.corp_id = corp_id
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._access_token: Optional[str] = None
|
||||
self._token_expires_at: Optional[datetime] = None
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
"""
|
||||
获取钉钉 Access Token
|
||||
|
||||
使用新版 OAuth2 接口获取
|
||||
|
||||
Returns:
|
||||
access_token
|
||||
"""
|
||||
# 检查缓存的 token 是否有效
|
||||
if self._access_token and self._token_expires_at:
|
||||
if datetime.now() < self._token_expires_at - timedelta(minutes=5):
|
||||
return self._access_token
|
||||
|
||||
url = f"{self.BASE_URL}/v1.0/oauth2/{self.corp_id}/token"
|
||||
|
||||
payload = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "client_credentials"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
self._access_token = data["access_token"]
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||
|
||||
logger.info(f"获取钉钉 Access Token 成功,有效期 {expires_in} 秒")
|
||||
return self._access_token
|
||||
|
||||
async def get_department_list(self, dept_id: int = 1) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取部门列表
|
||||
|
||||
Args:
|
||||
dept_id: 父部门ID,根部门为1
|
||||
|
||||
Returns:
|
||||
部门列表
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"{self.OAPI_URL}/topapi/v2/department/listsub"
|
||||
|
||||
params = {"access_token": access_token}
|
||||
payload = {"dept_id": dept_id}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
raise Exception(f"获取部门列表失败: {data.get('errmsg')}")
|
||||
|
||||
return data.get("result", [])
|
||||
|
||||
async def get_all_departments(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
递归获取所有部门
|
||||
|
||||
Returns:
|
||||
所有部门列表(扁平化)
|
||||
"""
|
||||
all_departments = []
|
||||
|
||||
async def fetch_recursive(parent_id: int):
|
||||
departments = await self.get_department_list(parent_id)
|
||||
for dept in departments:
|
||||
all_departments.append(dept)
|
||||
# 递归获取子部门
|
||||
await fetch_recursive(dept["dept_id"])
|
||||
|
||||
await fetch_recursive(1) # 从根部门开始
|
||||
logger.info(f"获取到 {len(all_departments)} 个部门")
|
||||
return all_departments
|
||||
|
||||
async def get_department_users(
|
||||
self,
|
||||
dept_id: int,
|
||||
cursor: int = 0,
|
||||
size: int = 100
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取部门用户列表
|
||||
|
||||
Args:
|
||||
dept_id: 部门ID
|
||||
cursor: 分页游标
|
||||
size: 每页大小,最大100
|
||||
|
||||
Returns:
|
||||
用户列表和分页信息
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"{self.OAPI_URL}/topapi/v2/user/list"
|
||||
|
||||
params = {"access_token": access_token}
|
||||
payload = {
|
||||
"dept_id": dept_id,
|
||||
"cursor": cursor,
|
||||
"size": size
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
raise Exception(f"获取部门用户失败: {data.get('errmsg')}")
|
||||
|
||||
return data.get("result", {})
|
||||
|
||||
async def get_all_employees(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有在职员工
|
||||
|
||||
遍历所有部门获取员工列表
|
||||
|
||||
Returns:
|
||||
员工列表
|
||||
"""
|
||||
logger.info("开始从钉钉 API 获取所有员工...")
|
||||
|
||||
# 1. 获取所有部门
|
||||
departments = await self.get_all_departments()
|
||||
|
||||
# 创建部门ID到名称的映射
|
||||
dept_map = {dept["dept_id"]: dept["name"] for dept in departments}
|
||||
dept_map[1] = "根部门" # 添加根部门
|
||||
|
||||
# 2. 遍历所有部门获取员工
|
||||
all_employees = {} # 使用字典去重(按 userid)
|
||||
|
||||
for dept in [{"dept_id": 1, "name": "根部门"}] + departments:
|
||||
dept_id = dept["dept_id"]
|
||||
dept_name = dept["name"]
|
||||
|
||||
cursor = 0
|
||||
while True:
|
||||
result = await self.get_department_users(dept_id, cursor)
|
||||
users = result.get("list", [])
|
||||
|
||||
for user in users:
|
||||
userid = user.get("userid")
|
||||
if userid and userid not in all_employees:
|
||||
# 转换为统一格式
|
||||
employee = self._convert_user_to_employee(user, dept_name)
|
||||
all_employees[userid] = employee
|
||||
|
||||
# 检查是否还有更多数据
|
||||
if not result.get("has_more", False):
|
||||
break
|
||||
cursor = result.get("next_cursor", 0)
|
||||
|
||||
employees = list(all_employees.values())
|
||||
logger.info(f"获取到 {len(employees)} 位在职员工")
|
||||
return employees
|
||||
|
||||
def _convert_user_to_employee(
|
||||
self,
|
||||
user: Dict[str, Any],
|
||||
dept_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将钉钉用户数据转换为员工数据格式
|
||||
|
||||
Args:
|
||||
user: 钉钉用户数据
|
||||
dept_name: 部门名称
|
||||
|
||||
Returns:
|
||||
标准员工数据格式
|
||||
"""
|
||||
return {
|
||||
'full_name': user.get('name', ''),
|
||||
'phone': user.get('mobile', ''),
|
||||
'email': user.get('email', ''),
|
||||
'department': dept_name,
|
||||
'position': user.get('title', ''),
|
||||
'employee_no': user.get('job_number', ''),
|
||||
'is_leader': user.get('leader', False),
|
||||
'is_active': user.get('active', True),
|
||||
'dingtalk_id': user.get('userid', ''),
|
||||
'join_date': user.get('hired_date'),
|
||||
'work_location': user.get('work_place', ''),
|
||||
'avatar': user.get('avatar', ''),
|
||||
}
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""
|
||||
测试钉钉 API 连接
|
||||
|
||||
Returns:
|
||||
测试结果
|
||||
"""
|
||||
try:
|
||||
# 1. 测试获取 token
|
||||
token = await self.get_access_token()
|
||||
|
||||
# 2. 测试获取根部门信息
|
||||
departments = await self.get_department_list(1)
|
||||
|
||||
# 3. 获取根部门员工数量
|
||||
result = await self.get_department_users(1, size=1)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "连接成功",
|
||||
"corp_id": self.corp_id,
|
||||
"department_count": len(departments) + 1, # +1 是根部门
|
||||
"has_employees": result.get("has_more", False) or len(result.get("list", [])) > 0
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = "HTTP错误"
|
||||
if e.response.status_code == 400:
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_detail = error_data.get("message", str(e))
|
||||
except:
|
||||
pass
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接失败: {error_detail}",
|
||||
"error": str(e)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接失败: {str(e)}",
|
||||
"error": str(e)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
员工同步服务
|
||||
从外部钉钉员工表同步员工数据到考培练系统
|
||||
从钉钉开放 API 同步员工数据到考培练系统
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
@@ -23,115 +23,79 @@ logger = get_logger(__name__)
|
||||
class EmployeeSyncService:
|
||||
"""员工同步服务"""
|
||||
|
||||
# 默认外部数据库连接配置(向后兼容,从环境变量读取)
|
||||
DEFAULT_TABLE_NAME = "v_钉钉员工表"
|
||||
|
||||
def __init__(self, db: AsyncSession, tenant_id: int = 1):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.external_engine = None
|
||||
self.table_name = self.DEFAULT_TABLE_NAME
|
||||
self._db_url = None
|
||||
self._dingtalk_config = None
|
||||
|
||||
async def _get_table_name_from_db(self) -> str:
|
||||
"""从数据库获取员工表名配置"""
|
||||
async def _get_dingtalk_config(self) -> Dict[str, str]:
|
||||
"""从数据库获取钉钉 API 配置"""
|
||||
if self._dingtalk_config:
|
||||
return self._dingtalk_config
|
||||
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
text("""
|
||||
SELECT config_value
|
||||
SELECT config_key, config_value
|
||||
FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND config_group = 'employee_sync'
|
||||
AND config_key = 'TABLE_NAME'
|
||||
AND config_group = 'employee_sync'
|
||||
"""),
|
||||
{"tenant_id": self.tenant_id}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else self.DEFAULT_TABLE_NAME
|
||||
except Exception:
|
||||
return self.DEFAULT_TABLE_NAME
|
||||
rows = result.fetchall()
|
||||
|
||||
config = {}
|
||||
for key, value in rows:
|
||||
config[key] = value
|
||||
|
||||
self._dingtalk_config = config
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"获取钉钉配置失败: {e}")
|
||||
return {}
|
||||
|
||||
def _get_db_url_from_env(self) -> str:
|
||||
"""从环境变量获取数据库连接URL"""
|
||||
import os
|
||||
|
||||
# 优先使用环境变量中的完整URL
|
||||
db_url = os.environ.get('EMPLOYEE_SYNC_DB_URL', '')
|
||||
|
||||
if db_url:
|
||||
return db_url
|
||||
|
||||
# 向后兼容:如果没有配置环境变量,使用默认值
|
||||
logger.warning("EMPLOYEE_SYNC_DB_URL 环境变量未配置,使用默认数据源")
|
||||
return "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4"
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
self._db_url = self._get_db_url_from_env()
|
||||
self.table_name = await self._get_table_name_from_db()
|
||||
|
||||
self.external_engine = create_async_engine(
|
||||
self._db_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600
|
||||
)
|
||||
# 预加载钉钉配置
|
||||
await self._get_dingtalk_config()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口"""
|
||||
if self.external_engine:
|
||||
await self.external_engine.dispose()
|
||||
pass
|
||||
|
||||
async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从钉钉员工表获取在职员工数据
|
||||
从钉钉 API 获取在职员工数据
|
||||
|
||||
Returns:
|
||||
员工数据列表
|
||||
"""
|
||||
logger.info(f"开始从员工表 {self.table_name} 获取数据...")
|
||||
config = await self._get_dingtalk_config()
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
员工姓名,
|
||||
手机号,
|
||||
邮箱,
|
||||
所属部门,
|
||||
职位,
|
||||
工号,
|
||||
是否领导,
|
||||
是否在职,
|
||||
钉钉用户ID,
|
||||
入职日期,
|
||||
工作地点
|
||||
FROM {self.table_name}
|
||||
WHERE 是否在职 = 1
|
||||
ORDER BY 员工姓名
|
||||
"""
|
||||
corp_id = config.get('CORP_ID')
|
||||
client_id = config.get('CLIENT_ID')
|
||||
client_secret = config.get('CLIENT_SECRET')
|
||||
|
||||
async with self.external_engine.connect() as conn:
|
||||
result = await conn.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
employees = []
|
||||
for row in rows:
|
||||
employees.append({
|
||||
'full_name': row[0],
|
||||
'phone': row[1],
|
||||
'email': row[2],
|
||||
'department': row[3],
|
||||
'position': row[4],
|
||||
'employee_no': row[5],
|
||||
'is_leader': bool(row[6]),
|
||||
'is_active': bool(row[7]),
|
||||
'dingtalk_id': row[8],
|
||||
'join_date': row[9],
|
||||
'work_location': row[10]
|
||||
})
|
||||
|
||||
logger.info(f"获取到 {len(employees)} 条在职员工数据")
|
||||
return employees
|
||||
if not all([corp_id, client_id, client_secret]):
|
||||
raise Exception("钉钉 API 配置不完整,请先配置 CorpId、ClientId、ClientSecret")
|
||||
|
||||
from app.services.dingtalk_service import DingTalkService
|
||||
|
||||
dingtalk = DingTalkService(
|
||||
corp_id=corp_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret
|
||||
)
|
||||
|
||||
employees = await dingtalk.get_all_employees()
|
||||
|
||||
# 过滤在职员工
|
||||
active_employees = [emp for emp in employees if emp.get('is_active', True)]
|
||||
logger.info(f"获取到 {len(active_employees)} 条在职员工数据")
|
||||
|
||||
return active_employees
|
||||
|
||||
def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user