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)}")
|