v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -28,23 +29,31 @@ from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API)
|
||||
# 组织目录 Redis 缓存 key 与 TTL(30 分钟,减少企微通讯录 API 调用频率)
|
||||
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
|
||||
ORG_DIRECTORY_CACHE_TTL = 600
|
||||
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}, ...]
|
||||
- directory: [{"employee_id": str, "name": str, "department": str, "dept_ids": [int, ...]}, ...]
|
||||
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
|
||||
"""
|
||||
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
|
||||
@@ -57,25 +66,51 @@ async def get_org_directory(
|
||||
except Exception as e:
|
||||
logger.warning(f"读取组织目录缓存失败(降级): {e}")
|
||||
|
||||
# 2. 尝试从企微通讯录拉全组织
|
||||
# 2. 尝试从企微通讯录拉全组织(并行调用两个 API,减少串行等待时间)
|
||||
wecom = WecomService(redis_client=redis)
|
||||
try:
|
||||
members = await wecom.get_department_members(1, 1)
|
||||
# 并行拉取部门成员和部门列表(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]
|
||||
|
||||
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||||
# 部门成员获取失败(权限不足等)→ 抛出异常触发降级到本地 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] = {}
|
||||
try:
|
||||
departments = await wecom.get_department_list()
|
||||
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)} 个部门")
|
||||
except Exception as e:
|
||||
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
|
||||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
|
||||
|
||||
# 缓存部门列表(含 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 = [
|
||||
{
|
||||
@@ -85,6 +120,8 @@ async def get_org_directory(
|
||||
"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")
|
||||
@@ -122,12 +159,13 @@ async def get_org_directory(
|
||||
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]")
|
||||
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
|
||||
dept_name = ""
|
||||
dept_ids: List[int] = []
|
||||
if dept_raw:
|
||||
try:
|
||||
import json as _json
|
||||
parsed = _json.loads(dept_raw)
|
||||
parsed = json.loads(dept_raw)
|
||||
if isinstance(parsed, list) and parsed:
|
||||
# 部门ID列表:取第一个ID(降级时无法解析ID为名称,留空)
|
||||
# 部门ID列表:降级时无法解析ID为名称,留空;但保留ID供树构建
|
||||
dept_ids = [int(d) for d in parsed if d is not None]
|
||||
dept_name = ""
|
||||
else:
|
||||
dept_name = str(parsed)
|
||||
@@ -138,6 +176,7 @@ async def get_org_directory(
|
||||
"employee_id": r[0],
|
||||
"name": r[1] or "",
|
||||
"department": dept_name,
|
||||
"dept_ids": dept_ids,
|
||||
})
|
||||
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人")
|
||||
return directory, False
|
||||
@@ -235,3 +274,363 @@ async def resolve_target(
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user