# ============================================================================= # 员工目录解析服务 # ============================================================================= # 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 UserID, # 并校验该员工确实属于企微组织架构(需求:分配角色时按姓名/账号自动转换 + 校验)。 # # 数据源优先级(自动适配,无需改代码即可在权限开通后升级): # 1. 企微通讯录(实时): # - get_user_info(userid) 校验账号是否为组织内真实员工 # - get_department_members(1, 1) 拉取全组织架构,用于「姓名 -> 账号」匹配 # - 需要企微应用具备「通讯录读取」权限;权限不足(errcode 60011)时自动降级 # 2. 本地 employees 表(仅登录过的员工):作为降级目录,保证功能在缺权限时仍可用 # # 设计目标:无论企微权限是否齐全,分配功能都可用;权限齐全时自动获得全公司 # 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。 # ============================================================================= import json import logging from typing import Any, Dict, List, Optional, Tuple import redis.asyncio as aioredis from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.employee import Employee from app.services.wecom_service import WecomService logger = logging.getLogger(__name__) # 组织目录 Redis 缓存 key 与 TTL(10 分钟,避免频繁调用企微通讯录 API) ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory" ORG_DIRECTORY_CACHE_TTL = 600 async def get_org_directory( db: AsyncSession, redis: Optional[aioredis.Redis], ) -> Tuple[List[Dict[str, Any]], bool]: """获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。 优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。 若企微权限不足或调用失败,降级到本地 employees 表。 Returns: (directory, full_directory) - directory: [{"employee_id": str, "name": str, "department": str}, ...] - full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工 """ # 1. 尝试命中缓存(缓存一定来自企微全组织,full=True) if redis: try: raw = await redis.get(ORG_DIRECTORY_CACHE_KEY) if raw: logger.debug("命中组织目录缓存") return json.loads(raw.decode("utf-8")), True except Exception as e: logger.warning(f"读取组织目录缓存失败(降级): {e}") # 2. 尝试从企微通讯录拉全组织 wecom = WecomService(redis_client=redis) try: members = await wecom.get_department_members(1, 1) directory = [ { "employee_id": m.get("userid", ""), "name": m.get("name", "") or "", "department": ",".join(str(d) for d in (m.get("department") or [])), } for m in members if m.get("userid") ] # 写入缓存(仅全组织结果缓存,降级结果不缓存以免长期误用) if redis: try: await redis.setex( ORG_DIRECTORY_CACHE_KEY, ORG_DIRECTORY_CACHE_TTL, json.dumps(directory, ensure_ascii=False), ) except Exception as e: logger.warning(f"写入组织目录缓存失败: {e}") logger.info(f"组织目录来自企微全组织,共 {len(directory)} 人") return directory, True except Exception as e: err_text = str(e) if "60011" in err_text or "privilege" in err_text.lower(): logger.warning("企微通讯录部门读取权限不足,降级到本地 employees 表") else: logger.warning(f"企微通讯录获取失败,降级本地: {err_text}") # 3. 降级:本地 employees 表(仅登录过的员工) try: result = await db.execute( select(Employee.employee_id, Employee.name).where(Employee.employee_id != "") ) rows = result.all() directory = [ {"employee_id": r[0], "name": r[1] or "", "department": ""} for r in rows ] logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)} 人") return directory, False except Exception as e: logger.error(f"本地 employees 表查询失败: {e}") return [], False async def resolve_target( target: str, db: AsyncSession, redis: Optional[aioredis.Redis] = None, ) -> Dict[str, Any]: """将输入的「员工账号 或 姓名」解析为企微 UserID,并校验组织内存在性。 Returns(结构化结果,由调用方翻译为响应/异常): {"found": True, "employee_id": str, "name": str, "source": str} {"found": False, "reason": str, "suggestion": str} {"ambiguous": True, "candidates": [{"employee_id","name","department"}, ...]} """ target = (target or "").strip() if not target: return { "found": False, "reason": "请输入员工账号或姓名", "suggestion": "请填写企微员工账号或姓名后重试", } wecom = WecomService(redis_client=redis) # 1) 先尝试按 userid 实时校验(企微组织内真实员工) try: info = await wecom.get_user_info(target) # 成功 -> target 本身就是有效 userid return { "found": True, "employee_id": info.get("userid") or target, "name": info.get("name") or "", "source": "wecom_userid", } except Exception as e: logger.debug(f"get_user_info('{target}') 未命中(将尝试按姓名解析): {e}") # 2) 按姓名(包含)解析 directory, full = await get_org_directory(db, redis) ql = target.lower() # 精确 userid 匹配优先(目录里可能存在) exact = [m for m in directory if m["employee_id"] and m["employee_id"].lower() == ql] # 姓名包含匹配 name_hits = [m for m in directory if m["name"] and ql in m["name"].lower()] matches = exact if exact else name_hits if len(matches) == 1: m = matches[0] # 二次实时校验该 userid 确实在组织内(网络可用时) try: info = await wecom.get_user_info(m["employee_id"]) return { "found": True, "employee_id": info.get("userid") or m["employee_id"], "name": info.get("name") or m.get("name", ""), "source": "wecom_name" if full else "local_name", } except Exception: # 实时校验失败(网络/权限),但目录里有 -> 仍可用 return { "found": True, "employee_id": m["employee_id"], "name": m.get("name", ""), "source": "local_name", } if len(matches) > 1: return { "ambiguous": True, "candidates": [ { "employee_id": m["employee_id"], "name": m["name"], "department": m.get("department", ""), } for m in matches[:10] ], } # 未找到 if full: return { "found": False, "reason": f"企微组织架构中未找到匹配「{target}」的员工", "suggestion": "请确认姓名/账号拼写,或改为输入员工账号", } return { "found": False, "reason": f"未找到匹配「{target}」的员工", "suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司", }