feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (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
This commit is contained in:
+212
-14
@@ -24,8 +24,9 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
@@ -56,7 +57,7 @@ from app.schemas.h5 import (
|
||||
ShakeRequest,
|
||||
SoftwareDownloadResponse,
|
||||
)
|
||||
from app.schemas.conversation import ConversationResponse, JoinConversationRequest
|
||||
from app.schemas.conversation import ConversationResponse, InviteParticipantRequest, JoinConversationRequest
|
||||
from app.schemas.message import MessageResponse
|
||||
import asyncio
|
||||
|
||||
@@ -64,6 +65,7 @@ from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.employee_directory import get_org_directory
|
||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1166,12 +1168,8 @@ async def shake(
|
||||
db.add(system_msg)
|
||||
await db.flush()
|
||||
|
||||
# 4. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||
if wecom_service:
|
||||
try:
|
||||
await wecom_service.send_text_message(employee_id, phrase)
|
||||
except Exception as e:
|
||||
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
||||
# 4. 消息仅存储到数据库,由前端通过 WebSocket/轮询在 H5 页面内展示
|
||||
# (不再通过企微应用消息推送,避免出现在通知栏)
|
||||
|
||||
# 5. 自动分配空闲坐席
|
||||
from app.services.session_service import SessionService
|
||||
@@ -1332,12 +1330,8 @@ async def call_agent(
|
||||
)
|
||||
db.add(system_msg)
|
||||
|
||||
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
||||
if wecom_service:
|
||||
try:
|
||||
await wecom_service.send_text_message(employee_id, system_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
||||
# 7. 消息仅存储到数据库,由前端通过 WebSocket/轮询在 H5 页面内展示
|
||||
# (不再通过企微应用消息推送,避免出现在通知栏)
|
||||
|
||||
# 8. 如果分配了坐席,通知坐席有新会话
|
||||
if assigned_agent and wecom_service:
|
||||
@@ -1667,3 +1661,207 @@ async def h5_get_participants(
|
||||
|
||||
participants = conversation.participants or []
|
||||
return success_response(data={"participants": participants})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# H5 员工端目录与邀请功能(P0-09~P0-11 扩展)
|
||||
# ==========================================================================
|
||||
# 说明:为 H5 员工端提供员工搜索、组织架构树、邀请参与者接口
|
||||
# 认证:使用 _get_current_employee 依赖,验证 Bearer Token
|
||||
# 复用:get_org_directory() 服务(与坐席端 agent_directory 共享同一数据源)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("/h5/employees/search")
|
||||
async def h5_search_employees(
|
||||
keyword: str = Query("", description="搜索关键词(姓名或工号,模糊匹配)"),
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: Optional[aioredis.Redis] = Depends(dep_redis),
|
||||
):
|
||||
"""H5 员工端模糊搜索员工(按姓名或工号)。
|
||||
|
||||
复用 get_org_directory() 获取组织目录(优先企微全组织,降级本地 employees 表),
|
||||
在内存中做大小写不敏感的模糊匹配。排除当前登录员工自己。
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键词(为空时返回空列表,避免无意义全量返回)
|
||||
employee_id: 当前登录员工ID(从 Token 认证获取)
|
||||
db: 数据库会话
|
||||
redis: Redis 客户端(可选,用于企微目录缓存读取)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 为员工列表
|
||||
[{ "id": "userid", "name": "姓名", "department": "部门" }, ...]
|
||||
"""
|
||||
kw = (keyword or "").strip()
|
||||
if not kw:
|
||||
return success_response(data=[])
|
||||
|
||||
try:
|
||||
# 获取组织目录(含 10 分钟 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") == employee_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"H5员工搜索: keyword='{kw}', 命中 {len(results)} 人")
|
||||
return success_response(data=results)
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"H5员工搜索异常: {e}", exc_info=True)
|
||||
raise AppException(1005, f"搜索失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/h5/org/tree")
|
||||
async def h5_get_org_tree(
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: Optional[aioredis.Redis] = Depends(dep_redis),
|
||||
):
|
||||
"""H5 员工端获取组织架构树(部门层级 + 每个部门下的员工列表)。
|
||||
|
||||
复用 get_org_directory() 获取员工列表(已含 department 字段),
|
||||
在服务端按 department 分组构建树结构。排除当前登录员工自己。
|
||||
|
||||
树结构示例:
|
||||
[
|
||||
{
|
||||
"id": "研发一部",
|
||||
"label": "研发一部",
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
规则:
|
||||
- department 为空的员工归到"未分配部门"分组
|
||||
- 企微返回多部门(逗号分隔)时,取第一个作为主部门
|
||||
- 部门按名称排序,部门内员工按姓名排序
|
||||
|
||||
Args:
|
||||
employee_id: 当前登录员工ID
|
||||
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") == employee_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,
|
||||
"department": dept_name,
|
||||
}
|
||||
for emp in employees
|
||||
],
|
||||
})
|
||||
|
||||
total_employees = sum(len(node["children"]) for node in tree)
|
||||
logger.info(f"H5组织架构树: {len(tree)} 个部门, 共 {total_employees} 人")
|
||||
return success_response(data=tree)
|
||||
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"H5组织架构树获取异常: {e}", exc_info=True)
|
||||
raise AppException(1005, f"获取组织架构树失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/h5/conversations/{conversation_id}/invite-participant")
|
||||
async def h5_invite_participant(
|
||||
conversation_id: str,
|
||||
body: InviteParticipantRequest,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wecom_service: WecomService = Depends(dep_wecom_service),
|
||||
):
|
||||
"""H5 员工端邀请参与者加入会话(P0-09 扩展)。
|
||||
|
||||
做什么:会话发起人(员工)邀请其他员工参与当前会话
|
||||
为什么:复杂IT问题可能需要业务方同事补充信息,员工可自行邀请
|
||||
认证:Bearer Token → employee_id,作为 inviter_agent_id 传入
|
||||
权限:后端 session_service.invite_participants 已改为允许主责坐席或会话发起人
|
||||
|
||||
副作用:
|
||||
- 向被邀请人发送企微卡片通知(含「加入会话」按钮)
|
||||
- 在会话中创建系统消息
|
||||
- WebSocket 广播参与者变更
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 邀请请求(含被邀请人列表 + 历史共享模式)
|
||||
employee_id: 当前登录员工ID(从 Token 认证获取,作为邀请人)
|
||||
db: 数据库会话
|
||||
wecom_service: 共享企微服务(DI 注入,发送卡片通知用)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db, wecom_service=wecom_service)
|
||||
conversation = await session_service.invite_participants(
|
||||
conversation_id=conversation_id,
|
||||
inviter_agent_id=employee_id,
|
||||
participants=[p.model_dump() for p in body.participants],
|
||||
history_mode=body.history_mode,
|
||||
)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
Reference in New Issue
Block a user