# ============================================================================= # 企微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)}")