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:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+212 -14
View File
@@ -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)