# ============================================================================= # 企微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 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 ( count_tree_employees, get_org_directory, get_org_tree_cached, ) 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: # 获取组织目录(含 30 分钟 Redis 缓存 + 本地降级) 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), ): """获取组织架构树(部门层级 + 每个部门下的员工列表)。 利用企微 department/list 返回的 parentid 字段构建真正的层级树, 不再按部门名扁平分组。部门ID加 ``dept_`` 前缀作为唯一 key, 避免同名部门合并。员工可以出现在其所属的所有部门下。 树结构示例(多层级): [ { "id": "dept_1", "label": "公司", "dept_id": 1, "parentid": 0, "children": [ { "id": "dept_2", "label": "研发一部", "dept_id": 2, "parentid": 1, "children": [ {"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"} ] } ] } ] 性能优化: - 树构建结果独立缓存(key: wecom:org_tree:agent,TTL 30 分钟) - 缓存中包含所有员工,读取后过滤掉当前登录坐席自己 Args: current_agent: 当前坐席 db: 数据库会话 redis: Redis 客户端 Returns: Dict: 统一响应格式,data 为树节点列表 """ try: # 获取组织架构树(含独立缓存 + 排除当前坐席) tree = await get_org_tree_cached(db, redis, "agent", current_agent.user_id) total_employees = count_tree_employees(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)}")