Files
wecom_it_smart_desk/backend/app/api/agent_directory.py
T
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

197 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 坐席端员工目录 API
# =============================================================================
# 说明:为坐席端"邀请参与者"弹窗提供员工搜索和组织架构树接口
# 1. GET /agent/employees/search?keyword=xxx — 模糊搜索员工(姓名/工号)
# 2. GET /agent/org/tree — 获取组织架构树(部门→员工层级)
# 两个端点均需坐席认证(get_current_agent),复用 employee_directory 服务
#
# 路由说明:
# 后端路由不带 /api 前缀(nginx / Vite proxy 负责 strip /api),
# 实际对外 URL 为 /api/agent/employees/search 和 /api/agent/org/tree
# =============================================================================
import logging
from collections import OrderedDict
from typing import Any, Dict, List, Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.agents import get_current_agent
from app.database import get_db
from app.dependencies import dep_redis
from app.models.agent import Agent
from app.services.employee_directory import get_org_directory
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器(与 agents.py 一致:路由直接挂在根路径,不加额外 prefix)
router = APIRouter()
# --------------------------------------------------------------------------
# GET /agent/employees/search — 模糊搜索员工
# --------------------------------------------------------------------------
@router.get("/agent/employees/search")
async def search_employees(
keyword: str = Query("", description="搜索关键词(姓名或工号,模糊匹配)"),
current_agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
redis: Optional[aioredis.Redis] = Depends(dep_redis),
):
"""模糊搜索员工(按姓名或工号)。
复用 get_org_directory() 获取组织目录(优先企微全组织,降级本地 employees 表),
在内存中做大小写不敏感的模糊匹配。排除当前登录坐席自己。
Args:
keyword: 搜索关键词(为空时返回空列表,避免无意义全量返回)
current_agent: 当前坐席(通过 get_current_agent 认证依赖注入)
db: 数据库会话
redis: Redis 客户端(可选,用于企微目录缓存读取)
Returns:
Dict: 统一响应格式,data 为员工列表
[{ "id": "userid", "name": "姓名", "department": "部门" }, ...]
"""
# 去除首尾空格,空关键词直接返回空列表
kw = (keyword or "").strip()
if not kw:
return success_response(data=[])
try:
# 获取组织目录(含 10 分钟 Redis 缓存 + 本地降级,无需修改 employee_directory.py
directory, _ = await get_org_directory(db, redis)
kw_lower = kw.lower()
results: List[Dict[str, Any]] = []
for emp in directory:
# 排除当前坐席自己(避免邀请自己加入会话)
if emp.get("employee_id") == current_agent.user_id:
continue
name = emp.get("name", "") or ""
emp_id = emp.get("employee_id", "") or ""
# 模糊匹配:姓名 或 工号(employee_id)包含关键词(大小写不敏感)
if kw_lower in name.lower() or kw_lower in emp_id.lower():
results.append({
"id": emp_id,
"name": name,
"department": emp.get("department", ""),
})
logger.info(f"员工搜索: keyword='{kw}', 命中 {len(results)}")
return success_response(data=results)
except AppException:
# 业务异常直接抛出(如认证失败)
raise
except Exception as e:
logger.error(f"员工搜索异常: {e}", exc_info=True)
raise AppException(1005, f"搜索失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /agent/org/tree — 获取组织架构树
# --------------------------------------------------------------------------
@router.get("/agent/org/tree")
async def get_org_tree(
current_agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
redis: Optional[aioredis.Redis] = Depends(dep_redis),
):
"""获取组织架构树(部门层级 + 每个部门下的员工列表)。
复用 get_org_directory() 获取员工列表(已含 department 字段),
在服务端按 department 分组构建树结构。排除当前登录坐席自己。
树结构示例:
[
{
"id": "研发一部",
"label": "研发一部",
"children": [
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
]
}
]
规则:
- department 为空的员工归到"未分配部门"分组
- 企微返回多部门(逗号分隔)时,取第一个作为主部门
- 部门按名称排序,部门内员工按姓名排序
Args:
current_agent: 当前坐席
db: 数据库会话
redis: Redis 客户端
Returns:
Dict: 统一响应格式,data 为树节点列表
"""
try:
# 获取组织目录(含 Redis 缓存 + 本地降级)
directory, _ = await get_org_directory(db, redis)
# 按部门分组(OrderedDict 保持稳定插入顺序,后续再排序)
dept_groups: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
for emp in directory:
# 排除当前坐席自己
if emp.get("employee_id") == current_agent.user_id:
continue
# 取部门名:为空则归"未分配部门";多部门(逗号分隔)取第一个
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": dept_name,
"label": dept_name,
"children": [
{
"id": emp.get("employee_id", ""),
"label": emp.get("name", ""),
# isLeaf=true 标记为叶子节点(员工),前端 el-tree 据此区分部门/员工
"isLeaf": True,
"department": dept_name,
}
for emp in employees
],
})
total_employees = sum(len(node["children"]) for node in tree)
logger.info(f"组织架构树: {len(tree)} 个部门, 共 {total_employees}")
return success_response(data=tree)
except AppException:
raise
except Exception as e:
logger.error(f"组织架构树获取异常: {e}", exc_info=True)
raise AppException(1005, f"获取组织架构树失败: {str(e)}")