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
238 lines
10 KiB
Python
238 lines
10 KiB
Python
# =============================================================================
|
||
# 员工目录解析服务
|
||
# =============================================================================
|
||
# 说明:角色分配时,将管理员输入的「员工账号 或 姓名」解析为企微 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)
|
||
|
||
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
|
||
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2])
|
||
dept_map: Dict[int, str] = {}
|
||
try:
|
||
departments = await wecom.get_department_list()
|
||
dept_map = {
|
||
dept.get("id"): dept.get("name", "")
|
||
for dept in departments
|
||
if dept.get("id") is not None
|
||
}
|
||
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
|
||
except Exception as e:
|
||
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
|
||
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
|
||
|
||
directory = [
|
||
{
|
||
"employee_id": m.get("userid", ""),
|
||
"name": m.get("name", "") or "",
|
||
# 优先用部门名映射,映射不到时降级为 ID 字符串
|
||
"department": ",".join(
|
||
dept_map.get(d, 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 表(仅登录过的员工)
|
||
# 注意:department 字段存储的是部门ID JSON数组(如 "[1,2]"),降级时无法解析为部门名;
|
||
# 但在 DEV_MODE 下可直接存部门名(如 "研发一部"),使组织架构树在本地开发时也有数据
|
||
try:
|
||
result = await db.execute(
|
||
select(Employee.employee_id, Employee.name, Employee.department).where(Employee.employee_id != "")
|
||
)
|
||
rows = result.all()
|
||
directory = []
|
||
for r in rows:
|
||
dept_raw = r[2] or ""
|
||
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]")
|
||
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
|
||
dept_name = ""
|
||
if dept_raw:
|
||
try:
|
||
import json as _json
|
||
parsed = _json.loads(dept_raw)
|
||
if isinstance(parsed, list) and parsed:
|
||
# 部门ID列表:取第一个ID(降级时无法解析ID为名称,留空)
|
||
dept_name = ""
|
||
else:
|
||
dept_name = str(parsed)
|
||
except (ValueError, TypeError):
|
||
# 不是 JSON 格式,直接作为部门名使用(DEV_MODE 场景)
|
||
dept_name = dept_raw
|
||
directory.append({
|
||
"employee_id": r[0],
|
||
"name": r[1] or "",
|
||
"department": dept_name,
|
||
})
|
||
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": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
|
||
}
|