bea288e414
== 已部署上线 (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
197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
# =============================================================================
|
||
# 企微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)}")
|