Files
wecom_it_smart_desk/backend/app/services/employee_directory.py
T

637 lines
25 KiB
Python
Raw Normal View History

# =============================================================================
# 员工目录解析服务
# =============================================================================
# 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 UserID,
# 并校验该员工确实属于企微组织架构(需求:分配角色时按姓名/账号自动转换 + 校验)。
#
# 数据源优先级(自动适配,无需改代码即可在权限开通后升级):
# 1. 企微通讯录(实时):
# - get_user_info(userid) 校验账号是否为组织内真实员工
# - get_department_members(1, 1) 拉取全组织架构,用于「姓名 -> 账号」匹配
# - 需要企微应用具备「通讯录读取」权限;权限不足(errcode 60011)时自动降级
# 2. 本地 employees 表(仅登录过的员工):作为降级目录,保证功能在缺权限时仍可用
#
# 设计目标:无论企微权限是否齐全,分配功能都可用;权限齐全时自动获得全公司
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
# =============================================================================
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.employee import Employee
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
# 组织目录 Redis 缓存 key 与 TTL(30 分钟,减少企微通讯录 API 调用频率)
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
ORG_DIRECTORY_CACHE_TTL = 1800
# 部门列表 Redis 缓存 key 与 TTL(含 parentid 层级信息,供 org tree 端点复用)
DEPT_LIST_CACHE_KEY = "wecom:dept_list"
DEPT_LIST_CACHE_TTL = 1800
# 组织架构树 Redis 缓存 key 前缀与 TTL(按端点区分,树构建是纯计算,数据源变了才需重建)
ORG_TREE_CACHE_KEY_PREFIX = "wecom:org_tree"
ORG_TREE_CACHE_TTL = 1800
async def get_org_directory(
db: AsyncSession,
redis: Optional[aioredis.Redis],
) -> Tuple[List[Dict[str, Any]], bool]:
"""获取「组织目录」(员工账号 + 姓名 + 部门ID列表),用于姓名 -> 账号匹配和组织架构树构建。
优先返回缓存;缓存未命中时并行从企微通讯录拉取全组织成员和部门列表(需通讯录读取权限)。
若企微权限不足或调用失败,降级到本地 employees 表。
Returns:
(directory, full_directory)
- directory: [{"employee_id": str, "name": str, "department": str, "dept_ids": [int, ...]}, ...]
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
"""
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
if redis:
try:
raw = await redis.get(ORG_DIRECTORY_CACHE_KEY)
if raw:
logger.debug("命中组织目录缓存")
return json.loads(raw.decode("utf-8")), True
except Exception as e:
logger.warning(f"读取组织目录缓存失败(降级): {e}")
# 2. 尝试从企微通讯录拉全组织(并行调用两个 API,减少串行等待时间)
wecom = WecomService(redis_client=redis)
try:
# 并行拉取部门成员和部门列表(return_exceptions=True 防止单个失败影响整体)
results = await asyncio.gather(
wecom.get_department_members(1, 1),
wecom.get_department_list(),
return_exceptions=True,
)
members_result = results[0]
dept_result = results[1]
# 部门成员获取失败(权限不足等)→ 抛出异常触发降级到本地 employees 表
if isinstance(members_result, Exception):
raise members_result
members = members_result
# 部门列表获取失败时不阻塞主流程,降级使用部门ID字符串
departments: List[Dict[str, Any]] = (
dept_result if not isinstance(dept_result, Exception) else []
)
if isinstance(dept_result, Exception):
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {dept_result}")
# 构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2]
dept_map: Dict[int, str] = {}
if departments:
dept_map = {
dept.get("id"): dept.get("name", "")
for dept in departments
if dept.get("id") is not None
}
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
# 缓存部门列表(含 parentid 层级信息),供 org tree 端点复用
if redis:
try:
await redis.setex(
DEPT_LIST_CACHE_KEY,
DEPT_LIST_CACHE_TTL,
json.dumps(departments, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入部门列表缓存失败: {e}")
directory = [
{
"employee_id": m.get("userid", ""),
"name": m.get("name", "") or "",
# 优先用部门名映射,映射不到时降级为 ID 字符串
"department": ",".join(
dept_map.get(d, str(d)) for d in (m.get("department") or [])
),
# 保留原始部门 ID 列表,供 org tree 端点构建层级树使用
"dept_ids": list(m.get("department") or []),
}
for m in members
if m.get("userid")
]
# 写入缓存(仅全组织结果缓存,降级结果不缓存以免长期误用)
if redis:
try:
await redis.setex(
ORG_DIRECTORY_CACHE_KEY,
ORG_DIRECTORY_CACHE_TTL,
json.dumps(directory, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入组织目录缓存失败: {e}")
logger.info(f"组织目录来自企微全组织,共 {len(directory)}")
return directory, True
except Exception as e:
err_text = str(e)
if "60011" in err_text or "privilege" in err_text.lower():
logger.warning("企微通讯录部门读取权限不足,降级到本地 employees 表")
else:
logger.warning(f"企微通讯录获取失败,降级本地: {err_text}")
# 3. 降级:本地 employees 表(仅登录过的员工)
# 注意:department 字段存储的是部门ID JSON数组(如 "[1,2]"),降级时无法解析为部门名;
# 但在 DEV_MODE 下可直接存部门名(如 "研发一部"),使组织架构树在本地开发时也有数据
try:
result = await db.execute(
select(Employee.employee_id, Employee.name, Employee.department).where(Employee.employee_id != "")
)
rows = result.all()
directory = []
for r in rows:
dept_raw = r[2] or ""
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]"
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
dept_name = ""
dept_ids: List[int] = []
if dept_raw:
try:
parsed = json.loads(dept_raw)
if isinstance(parsed, list) and parsed:
# 部门ID列表:降级时无法解析ID为名称,留空;但保留ID供树构建
dept_ids = [int(d) for d in parsed if d is not None]
dept_name = ""
else:
dept_name = str(parsed)
except (ValueError, TypeError):
# 不是 JSON 格式,直接作为部门名使用(DEV_MODE 场景)
dept_name = dept_raw
directory.append({
"employee_id": r[0],
"name": r[1] or "",
"department": dept_name,
"dept_ids": dept_ids,
})
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)}")
return directory, False
except Exception as e:
logger.error(f"本地 employees 表查询失败: {e}")
return [], False
async def resolve_target(
target: str,
db: AsyncSession,
redis: Optional[aioredis.Redis] = None,
) -> Dict[str, Any]:
"""将输入的「员工账号 或 姓名」解析为企微 UserID,并校验组织内存在性。
Returns(结构化结果,由调用方翻译为响应/异常):
{"found": True, "employee_id": str, "name": str, "source": str}
{"found": False, "reason": str, "suggestion": str}
{"ambiguous": True, "candidates": [{"employee_id","name","department"}, ...]}
"""
target = (target or "").strip()
if not target:
return {
"found": False,
"reason": "请输入员工账号或姓名",
"suggestion": "请填写企微员工账号或姓名后重试",
}
wecom = WecomService(redis_client=redis)
# 1) 先尝试按 userid 实时校验(企微组织内真实员工)
try:
info = await wecom.get_user_info(target)
# 成功 -> target 本身就是有效 userid
return {
"found": True,
"employee_id": info.get("userid") or target,
"name": info.get("name") or "",
"source": "wecom_userid",
}
except Exception as e:
logger.debug(f"get_user_info('{target}') 未命中(将尝试按姓名解析): {e}")
# 2) 按姓名(包含)解析
directory, full = await get_org_directory(db, redis)
ql = target.lower()
# 精确 userid 匹配优先(目录里可能存在)
exact = [m for m in directory if m["employee_id"] and m["employee_id"].lower() == ql]
# 姓名包含匹配
name_hits = [m for m in directory if m["name"] and ql in m["name"].lower()]
matches = exact if exact else name_hits
if len(matches) == 1:
m = matches[0]
# 二次实时校验该 userid 确实在组织内(网络可用时)
try:
info = await wecom.get_user_info(m["employee_id"])
return {
"found": True,
"employee_id": info.get("userid") or m["employee_id"],
"name": info.get("name") or m.get("name", ""),
"source": "wecom_name" if full else "local_name",
}
except Exception:
# 实时校验失败(网络/权限),但目录里有 -> 仍可用
return {
"found": True,
"employee_id": m["employee_id"],
"name": m.get("name", ""),
"source": "local_name",
}
if len(matches) > 1:
return {
"ambiguous": True,
"candidates": [
{
"employee_id": m["employee_id"],
"name": m["name"],
"department": m.get("department", ""),
}
for m in matches[:10]
],
}
# 未找到
if full:
return {
"found": False,
"reason": f"企微组织架构中未找到匹配「{target}」的员工",
"suggestion": "请确认姓名/账号拼写,或改为输入员工账号",
}
return {
"found": False,
"reason": f"未找到匹配「{target}」的员工",
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
}
# =============================================================================
# 组织架构树构建(利用企微 department/list 的 parentid 构建真正的层级树)
# =============================================================================
# 说明:以下函数用于将扁平的员工目录 + 部门列表转换为层级组织架构树。
# - build_org_tree() 纯函数:根据 directory + dept_list 构建层级树
# - get_cached_dept_list() 从 Redis 缓存读取部门列表(含 parentid)
# - filter_user_from_tree() 从树中递归过滤掉指定用户
# - count_tree_employees() 统计树中的员工总数
# - get_org_tree_cached() 获取组织架构树(含独立缓存 + 用户过滤)
# =============================================================================
async def get_cached_dept_list(
redis: Optional[aioredis.Redis],
) -> List[Dict[str, Any]]:
"""从缓存获取部门列表(含 parentid 层级信息)。
优先从 Redis 缓存读取;缓存未命中时调用企微 API 获取并缓存。
供 org tree 端点复用,避免重复解析。
Args:
redis: Redis 客户端(可选)
Returns:
部门列表,每项含 id(部门ID)、name(部门名称)、parentid(父部门ID)
"""
if redis:
try:
raw = await redis.get(DEPT_LIST_CACHE_KEY)
if raw:
return json.loads(raw.decode("utf-8"))
except Exception as e:
logger.warning(f"读取部门列表缓存失败: {e}")
# 缓存未命中,调用企微 API 获取
wecom = WecomService(redis_client=redis)
try:
departments = await wecom.get_department_list()
if redis and departments:
try:
await redis.setex(
DEPT_LIST_CACHE_KEY,
DEPT_LIST_CACHE_TTL,
json.dumps(departments, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入部门列表缓存失败: {e}")
return departments
except Exception as e:
logger.warning(f"获取部门列表失败: {e}")
return []
def build_org_tree(
directory: List[Dict[str, Any]],
dept_list: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""根据部门列表的 parentid 层级信息和员工目录构建真正的层级组织架构树。
不再按部门名扁平分组,而是利用企微 department/list 返回的 parentid 字段
构建真正的层级树。部门ID加 ``dept_`` 前缀作为唯一 key,避免同名部门合并。
员工可以出现在其所属的所有部门下(不只取第一个)。
Args:
directory: 员工目录列表(每项含 employee_id, name, department, dept_ids
dept_list: 部门列表(每项含 id, name, parentid
Returns:
层级树节点列表,每个节点为:
- 部门节点: {id, label, dept_id, parentid, children: [...]}
- 员工节点: {id, label, isLeaf: true, department: str}
"""
# 部门列表为空时(权限不足或降级模式),退化为按部门名扁平分组
if not dept_list:
return _build_flat_tree_by_name(directory)
# 1. 构建部门映射:dept_id → 部门信息
dept_map: Dict[int, Dict[str, Any]] = {}
for dept in dept_list:
dept_id = dept.get("id")
if dept_id is not None:
dept_map[dept_id] = dept
# 2. 构建父部门→子部门ID列表映射
children_map: Dict[int, List[int]] = {}
for dept_id, dept in dept_map.items():
parent_id = dept.get("parentid", 0)
if parent_id not in children_map:
children_map[parent_id] = []
children_map[parent_id].append(dept_id)
# 3. 构建部门→员工映射(一个员工可属于多个部门)
dept_employees: Dict[int, List[Dict[str, Any]]] = {}
unassigned_employees: List[Dict[str, Any]] = []
for emp in directory:
emp_id = emp.get("employee_id", "")
emp_name = emp.get("name", "")
dept_ids = emp.get("dept_ids") or []
if not dept_ids:
# 没有部门ID的员工归到"未分配部门"
unassigned_employees.append({
"id": emp_id,
"label": emp_name,
"isLeaf": True,
"department": emp.get("department", ""),
})
continue
for did in dept_ids:
if did not in dept_employees:
dept_employees[did] = []
# 同一员工可能属于多个部门,在每个部门下都出现
dept_employees[did].append({
"id": emp_id,
"label": emp_name,
"isLeaf": True,
"department": emp.get("department", ""),
})
# 4. 递归构建部门子树
def build_dept_node(dept_id: int) -> Optional[Dict[str, Any]]:
"""递归构建单个部门的树节点(含子部门和员工)。"""
dept = dept_map.get(dept_id)
if dept is None:
return None
dept_name = dept.get("name", "") or f"部门{dept_id}"
node: Dict[str, Any] = {
"id": f"dept_{dept_id}",
"label": dept_name,
"dept_id": dept_id,
"parentid": dept.get("parentid", 0),
"children": [],
}
# 添加子部门(按名称排序)
child_ids = children_map.get(dept_id, [])
for child_id in sorted(
child_ids,
key=lambda cid: (dept_map.get(cid, {}).get("name", "") or ""),
):
child_node = build_dept_node(child_id)
if child_node:
node["children"].append(child_node)
# 添加该部门下的员工(按姓名排序)
emps = dept_employees.get(dept_id, [])
emps.sort(key=lambda e: e.get("label", ""))
node["children"].extend(emps)
return node
# 5. 确定根部门作为顶层节点
# 优先取 parentid=0 的部门;若无,则取 parentid=1 的部门
# 孤儿部门(parentid 不在 dept_map 中)也作为顶层节点
root_dept_ids: List[int] = []
root_id_set: set = set()
has_parentid_0 = any(d.get("parentid") == 0 for d in dept_map.values())
for did, dept in dept_map.items():
parent_id = dept.get("parentid", 0)
if has_parentid_0 and parent_id == 0:
root_id_set.add(did)
elif not has_parentid_0 and parent_id == 1:
root_id_set.add(did)
elif parent_id not in dept_map:
# 孤儿部门(父部门不存在于部门列表中)也作为根
root_id_set.add(did)
# 按部门名称排序
root_dept_ids = sorted(
root_id_set,
key=lambda did: (dept_map.get(did, {}).get("name", "") or ""),
)
tree: List[Dict[str, Any]] = []
for dept_id in root_dept_ids:
node = build_dept_node(dept_id)
if node:
tree.append(node)
# 6. 添加"未分配部门"(仅在没有部门信息的极少数员工时出现)
if unassigned_employees:
unassigned_employees.sort(key=lambda e: e.get("label", ""))
tree.append({
"id": "dept_unassigned",
"label": "未分配部门",
"dept_id": None,
"parentid": 0,
"children": unassigned_employees,
})
return tree
def _build_flat_tree_by_name(
directory: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""按部门名扁平分组构建树(降级模式:部门列表不可用时使用)。
保留原有逻辑:按 department 名称分组,多部门取第一个,空部门归"未分配部门"
部门按名称排序,员工按姓名排序。
Args:
directory: 员工目录列表
Returns:
扁平树节点列表(一级部门 + 员工叶子节点)
"""
dept_groups: Dict[str, List[Dict[str, Any]]] = {}
for emp in directory:
dept = (emp.get("department") or "").strip()
if not dept:
dept = "未分配部门"
else:
dept = dept.split(",")[0].strip()
if not dept:
dept = "未分配部门"
if dept not in dept_groups:
dept_groups[dept] = []
dept_groups[dept].append(emp)
tree: List[Dict[str, Any]] = []
for dept_name in sorted(dept_groups.keys()):
employees = dept_groups[dept_name]
if not employees:
continue
employees.sort(key=lambda e: e.get("name", ""))
tree.append({
"id": f"dept_name_{dept_name}",
"label": dept_name,
"dept_id": None,
"parentid": 0,
"children": [
{
"id": emp.get("employee_id", ""),
"label": emp.get("name", ""),
"isLeaf": True,
"department": dept_name,
}
for emp in employees
],
})
return tree
def filter_user_from_tree(
tree: List[Dict[str, Any]],
user_id: str,
) -> List[Dict[str, Any]]:
"""从组织架构树中递归过滤掉指定用户,并移除过滤后变空的部门节点。
用于 org tree 端点排除当前登录用户。由于树缓存包含所有员工,
读取后需过滤掉当前用户再返回。
Args:
tree: 完整的组织架构树(包含所有员工)
user_id: 要排除的用户 UserID
Returns:
过滤后的树(不含目标用户,也不含因此变空的部门节点)
"""
result: List[Dict[str, Any]] = []
for node in tree:
# 员工叶子节点:跳过目标用户
if node.get("isLeaf"):
if node.get("id") == user_id:
continue
result.append(node)
continue
# 部门节点:递归过滤子节点
children = node.get("children")
if children is not None:
new_children = filter_user_from_tree(children, user_id)
if not new_children:
# 过滤后部门为空,跳过该部门节点
continue
new_node = dict(node)
new_node["children"] = new_children
result.append(new_node)
else:
result.append(node)
return result
def count_tree_employees(tree: List[Dict[str, Any]]) -> int:
"""统计组织架构树中的员工总数(递归计算叶子节点数)。
Args:
tree: 组织架构树
Returns:
员工总数
"""
count = 0
for node in tree:
if node.get("isLeaf"):
count += 1
elif "children" in node:
count += count_tree_employees(node["children"])
return count
async def get_org_tree_cached(
db: AsyncSession,
redis: Optional[aioredis.Redis],
endpoint: str,
exclude_user_id: str,
) -> List[Dict[str, Any]]:
"""获取组织架构树(含独立缓存 + 排除当前用户)。
树构建是纯计算,数据源变了才需重建,因此独立缓存(TTL 30 分钟)。
缓存中包含所有员工,读取后过滤掉当前用户再返回。
缓存策略:
1. 优先读取 org tree 独立缓存
2. 缓存未命中时,从 directory 缓存 + dept_list 缓存构建树
3. 构建完成后写入 org tree 缓存
Args:
db: 数据库会话(用于 get_org_directory 降级)
redis: Redis 客户端
endpoint: 端点标识("agent""h5"),用于区分缓存 key
exclude_user_id: 要排除的用户 UserID(当前登录用户)
Returns:
组织架构树节点列表(已排除当前用户)
"""
cache_key = f"{ORG_TREE_CACHE_KEY_PREFIX}:{endpoint}"
# 1. 尝试命中树缓存
tree: Optional[List[Dict[str, Any]]] = None
if redis:
try:
raw = await redis.get(cache_key)
if raw:
tree = json.loads(raw.decode("utf-8"))
logger.debug(f"命中组织架构树缓存: {endpoint}")
except Exception as e:
logger.warning(f"读取组织架构树缓存失败: {e}")
# 2. 缓存未命中,构建树
if tree is None:
# 获取员工目录(含 Redis 缓存 + 本地降级)
directory, _ = await get_org_directory(db, redis)
# 获取部门列表(含 parentid 层级信息,从缓存或 API 获取)
dept_list = await get_cached_dept_list(redis)
# 构建层级树
tree = build_org_tree(directory, dept_list)
# 写入树缓存
if redis and tree:
try:
await redis.setex(
cache_key,
ORG_TREE_CACHE_TTL,
json.dumps(tree, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入组织架构树缓存失败: {e}")
# 3. 过滤掉当前用户(缓存包含所有员工,需按请求者排除)
return filter_user_from_tree(tree, exclude_user_id)