Files
wecom_it_smart_desk/backend/app/services/meetingroom_service.py
Simon 449c6d4875 feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS

## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS

## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)

## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code

## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)

## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过

## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务

## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记已实施
- 新增架构图/时序图/类图(mermaid)

## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
2026-07-13 02:17:03 +08:00

560 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 会议室预定业务服务
# =============================================================================
# 说明:作为企微会议室API的薄代理层,提供:
# 1. Redis缓存管理(减少企微API调用)
# 2. 企微API代理(列表/预定状态/预定/取消/详情)
# 3. 实时状态计算(综合判断空闲/使用中/即将开始)
# 4. 状态变更后WebSocket通知终端
#
# 设计决策:
# - 企微API为唯一数据源,不在本地维护预定数据
# - 读操作加Redis短缓存(30秒),写操作直接转发企微API
# - 预定/取消成功后立即清除缓存并推送WS通知
# =============================================================================
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import redis.asyncio as aioredis
from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
class MeetingroomService:
"""会议室预定业务服务 — 企微API代理 + Redis缓存。
所有预定数据以企微API为唯一数据源,本服务仅做代理+缓存+鉴权。
terminal_room_binding 表仅存终端↔会议室映射关系,不存预定数据。
"""
# Redis 缓存 key 前缀与 TTL
CACHE_KEY_ROOM_LIST = "meetingroom:room_list" # 会议室列表 (TTL=600s)
CACHE_KEY_BOOKING_INFO = "meetingroom:booking:{room_id}:{date}" # 预定状态 (TTL=30s)
CACHE_KEY_BOOKING_DETAIL = "meetingroom:detail:{booking_id}" # 预定详情 (TTL=300s)
CACHE_KEY_STATUS = "meetingroom:status:{room_id}" # 实时状态 (TTL=10s)
# 缓存 TTL(秒)
TTL_ROOM_LIST = 600
TTL_BOOKING_INFO = 30
TTL_BOOKING_DETAIL = 300
TTL_STATUS = 10
def __init__(
self,
wecom_service: WecomService,
redis_client: Optional[aioredis.Redis] = None,
) -> None:
"""初始化会议室预定服务。
Args:
wecom_service: 企微API服务实例
redis_client: Redis 异步客户端(可为 None)
"""
self.wecom = wecom_service
self.redis = redis_client
# ==========================================================================
# 会议室列表
# ==========================================================================
async def get_room_list(
self,
city: Optional[str] = None,
building: Optional[str] = None,
floor: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""获取会议室列表(缓存10分钟)。
代理调用企微API获取会议室列表,结果缓存到Redis。
Args:
city: 城市名称(可选过滤)
building: 楼宇名称(可选过滤)
floor: 楼层名称(可选过滤)
Returns:
List[Dict[str, Any]]: 会议室列表
"""
# 构建缓存key(含过滤条件)
filter_parts = []
if city:
filter_parts.append(f"city={city}")
if building:
filter_parts.append(f"building={building}")
if floor:
filter_parts.append(f"floor={floor}")
filter_str = ":".join(filter_parts) if filter_parts else "all"
cache_key = f"{self.CACHE_KEY_ROOM_LIST}:{filter_str}"
# 1. 尝试从缓存获取
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug(f"从缓存获取会议室列表: filter={filter_str}")
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis 读取会议室列表缓存失败: {e}")
# 2. 调用企微API
try:
room_list = await self.wecom.get_meetingroom_list(city, building, floor)
except Exception as e:
logger.error(f"获取会议室列表失败: {e}")
raise
# 3. 缓存到Redis
if self.redis:
try:
await self.redis.setex(cache_key, self.TTL_ROOM_LIST, json.dumps(room_list, ensure_ascii=False))
except Exception as e:
logger.warning(f"Redis 写入会议室列表缓存失败: {e}")
return room_list
# ==========================================================================
# 预定状态查询
# ==========================================================================
async def get_room_status(
self,
meetingroom_id: int,
date: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""获取指定日期的预定状态(缓存30秒)。
Args:
meetingroom_id: 企微会议室ID
date: 查询日期(YYYY-MM-DD格式,默认今天)
Returns:
List[Dict[str, Any]]: 预定记录列表
"""
# 默认今天
if not date:
date = datetime.now().strftime("%Y-%m-%d")
cache_key = self.CACHE_KEY_BOOKING_INFO.format(room_id=meetingroom_id, date=date)
# 1. 尝试从缓存获取
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug(f"从缓存获取预定状态: room_id={meetingroom_id}, date={date}")
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis 读取预定状态缓存失败: {e}")
# 2. 构建查询时间范围(当天 00:00 ~ 23:59,带时区)
from app.config import settings
tz_offset = "+08:00"
start_time = f"{date}T00:00:00{tz_offset}"
end_time = f"{date}T23:59:59{tz_offset}"
# 3. 调用企微API
try:
booking_list = await self.wecom.get_booking_info(meetingroom_id, start_time, end_time)
except Exception as e:
logger.error(f"获取预定状态失败: room_id={meetingroom_id}, date={date}, error={e}")
raise
# 4. 缓存到Redis
if self.redis:
try:
await self.redis.setex(cache_key, self.TTL_BOOKING_INFO, json.dumps(booking_list, ensure_ascii=False))
except Exception as e:
logger.warning(f"Redis 写入预定状态缓存失败: {e}")
return booking_list
# ==========================================================================
# 实时状态计算
# ==========================================================================
async def get_current_status(self, meetingroom_id: int) -> Dict[str, Any]:
"""获取当前实时状态(综合判断空闲/使用中/即将开始)。
缓存10秒(极短缓存防刷),过期后重新计算。
状态判断逻辑:
- busy: 当前时间在某个预定的 start_time ~ end_time 之间
- starting_soon: 当前时间距下一个预定开始时间 ≤ 15分钟
- free: 其他情况
Args:
meetingroom_id: 企微会议室ID
Returns:
Dict[str, Any]: 包含 status/current_meeting/next_meeting/minutes_to_next/bookings
"""
cache_key = self.CACHE_KEY_STATUS.format(room_id=meetingroom_id)
# 1. 尝试从缓存获取
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug(f"从缓存获取实时状态: room_id={meetingroom_id}")
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis 读取实时状态缓存失败: {e}")
# 2. 获取当日预定列表
today = datetime.now().strftime("%Y-%m-%d")
bookings = await self.get_room_status(meetingroom_id, today)
# 3. 计算当前状态
now = datetime.now()
current_meeting: Optional[Dict[str, Any]] = None
next_meeting: Optional[Dict[str, Any]] = None
status = "free"
minutes_to_next: Optional[int] = None
# 遍历预定记录,查找当前进行中的会议和下一个会议
active_bookings = []
for booking in bookings:
# 跳过已取消的预定
if booking.get("status", 0) != 0:
continue
# 解析时间(企微返回的时间戳或ISO字符串)
start_time = self._parse_booking_time(booking.get("start_time"))
end_time = self._parse_booking_time(booking.get("end_time"))
if not start_time or not end_time:
continue
active_bookings.append(booking)
# 检查是否当前进行中
if start_time <= now <= end_time:
current_meeting = self._format_booking(booking)
status = "busy"
# 检查是否是下一个即将开始的
elif start_time > now:
if next_meeting is None or start_time < self._parse_booking_time(next_meeting.get("start_time")):
next_meeting = self._format_booking(booking)
minutes_to_next = int((start_time - now).total_seconds() / 60)
# 如果当前没有进行中的会议,但下一个会议在15分钟内开始
if status == "free" and next_meeting and minutes_to_next is not None and minutes_to_next <= 15:
status = "starting_soon"
result: Dict[str, Any] = {
"status": status,
"current_meeting": current_meeting,
"next_meeting": next_meeting,
"minutes_to_next": minutes_to_next,
"bookings": [self._format_booking(b) for b in active_bookings],
}
# 4. 缓存到Redis(极短TTL
if self.redis:
try:
await self.redis.setex(cache_key, self.TTL_STATUS, json.dumps(result, ensure_ascii=False))
except Exception as e:
logger.warning(f"Redis 写入实时状态缓存失败: {e}")
return result
# ==========================================================================
# 预定操作
# ==========================================================================
async def book_room(
self,
meetingroom_id: int,
subject: str,
start_time: str,
end_time: str,
booker: str,
attendees: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""预定会议室(成功后清除该会议室的预定状态缓存)。
Args:
meetingroom_id: 企微会议室ID
subject: 会议主题
start_time: 开始时间(ISO 8601
end_time: 结束时间(ISO 8601
booker: 预定人userid
attendees: 参与人userid列表(可选)
Returns:
Dict[str, Any]: 预定结果,含 booking_id
Raises:
Exception: 预定失败
"""
try:
result = await self.wecom.book_meetingroom(
meetingroom_id=meetingroom_id,
subject=subject,
start_time=start_time,
end_time=end_time,
booker=booker,
attendees=attendees,
)
except Exception as e:
logger.error(f"预定会议室失败: room_id={meetingroom_id}, error={e}")
raise
# 预定成功后清除缓存
await self.invalidate_room_cache(meetingroom_id)
logger.info(f"预定成功: room_id={meetingroom_id}, booking_id={result.get('booking_id')}")
return result
# ==========================================================================
# 取消预定
# ==========================================================================
async def cancel_booking(self, booking_id: str, meetingroom_id: int) -> Dict[str, Any]:
"""取消预定(成功后清除缓存)。
Args:
booking_id: 企微预定ID
meetingroom_id: 企微会议室ID
Returns:
Dict[str, Any]: 取消结果
Raises:
Exception: 取消失败
"""
try:
result = await self.wecom.cancel_booking(booking_id, meetingroom_id)
except Exception as e:
logger.error(f"取消预定失败: booking_id={booking_id}, error={e}")
raise
# 取消成功后清除缓存
await self.invalidate_room_cache(meetingroom_id)
logger.info(f"取消成功: booking_id={booking_id}, room_id={meetingroom_id}")
return result
# ==========================================================================
# 预定详情
# ==========================================================================
async def get_booking_detail(self, meetingroom_id: int, booking_id: str) -> Dict[str, Any]:
"""获取预定详情(缓存5分钟)。
Args:
meetingroom_id: 企微会议室ID
booking_id: 企微预定ID
Returns:
Dict[str, Any]: 预定详情
"""
cache_key = self.CACHE_KEY_BOOKING_DETAIL.format(booking_id=booking_id)
# 1. 尝试从缓存获取
if self.redis:
try:
cached = await self.redis.get(cache_key)
if cached:
logger.debug(f"从缓存获取预定详情: booking_id={booking_id}")
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis 读取预定详情缓存失败: {e}")
# 2. 调用企微API
try:
detail = await self.wecom.get_booking_detail(meetingroom_id, booking_id)
except Exception as e:
logger.error(f"获取预定详情失败: booking_id={booking_id}, error={e}")
raise
# 3. 缓存到Redis
if self.redis:
try:
await self.redis.setex(cache_key, self.TTL_BOOKING_DETAIL, json.dumps(detail, ensure_ascii=False))
except Exception as e:
logger.warning(f"Redis 写入预定详情缓存失败: {e}")
return detail
# ==========================================================================
# 缓存管理
# ==========================================================================
async def invalidate_room_cache(
self,
meetingroom_id: int,
date: Optional[str] = None,
) -> None:
"""清除指定会议室的缓存(预定/取消后调用)。
清除以下缓存:
- meetingroom:booking:{room_id}:{date} — 预定状态
- meetingroom:status:{room_id} — 实时状态
Args:
meetingroom_id: 企微会议室ID
date: 日期(默认今天)
"""
if not self.redis:
return
if not date:
date = datetime.now().strftime("%Y-%m-%d")
keys_to_delete = [
self.CACHE_KEY_BOOKING_INFO.format(room_id=meetingroom_id, date=date),
self.CACHE_KEY_STATUS.format(room_id=meetingroom_id),
]
try:
for key in keys_to_delete:
await self.redis.delete(key)
logger.info(f"已清除会议室缓存: room_id={meetingroom_id}, date={date}")
except Exception as e:
logger.warning(f"清除会议室缓存失败: {e}")
# ==========================================================================
# WebSocket 通知
# ==========================================================================
async def notify_terminal_update(
self,
terminal_sn: str,
meetingroom_id: int,
) -> None:
"""状态变更后通过WebSocket通知终端。
获取最新状态后,通过ConnectionManager推送给绑定的终端。
Args:
terminal_sn: 终端序列号
meetingroom_id: 企微会议室ID
"""
try:
from app.services.ws_manager import manager as ws_manager
# 获取最新状态
status_data = await self.get_current_status(meetingroom_id)
# 构建推送消息
message = {
"type": "room_status_update",
"data": {
"meetingroom_id": meetingroom_id,
"status": status_data.get("status"),
"current_meeting": status_data.get("current_meeting"),
"next_meeting": status_data.get("next_meeting"),
"minutes_to_next": status_data.get("minutes_to_next"),
},
}
# 推送给终端
await ws_manager.send_to_terminal(terminal_sn, message)
logger.info(f"已推送状态更新到终端: sn={terminal_sn}, room_id={meetingroom_id}")
except Exception as e:
logger.warning(f"推送终端状态更新失败: sn={terminal_sn}, error={e}")
# ==========================================================================
# 辅助方法
# ==========================================================================
@staticmethod
def _parse_booking_time(time_value: Any) -> Optional[datetime]:
"""解析预定时间(支持时间戳和ISO字符串两种格式)。
企微API返回的时间可能是:
- 整数时间戳(秒)
- ISO 8601 字符串
Args:
time_value: 时间值
Returns:
Optional[datetime]: 解析后的datetime对象
"""
if not time_value:
return None
try:
if isinstance(time_value, (int, float)):
return datetime.fromtimestamp(time_value)
if isinstance(time_value, str):
# 尝试解析ISO格式
if "T" in time_value:
return datetime.fromisoformat(time_value)
# 尝试解析时间戳字符串
return datetime.fromtimestamp(int(time_value))
except (ValueError, TypeError) as e:
logger.warning(f"解析预定时间失败: value={time_value}, error={e}")
return None
@staticmethod
def _format_booking(booking: Dict[str, Any]) -> Dict[str, Any]:
"""格式化预定记录,统一时间格式为ISO字符串。
Args:
booking: 原始预定记录
Returns:
Dict[str, Any]: 格式化后的预定记录
"""
result = dict(booking)
# 转换时间戳为ISO字符串
for field in ("start_time", "end_time"):
value = booking.get(field)
dt = MeetingroomService._parse_booking_time(value)
if dt:
result[field] = dt.isoformat()
return result
# ==========================================================================
# 操作指南查询
# ==========================================================================
@staticmethod
async def get_guides(
db,
category: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""获取操作指南列表。
从数据库查询启用的操作指南,按 sort_order 排序。
可按设备类型过滤。
Args:
db: 数据库会话
category: 设备类型过滤(可选)
Returns:
List[Dict[str, Any]]: 指南列表
"""
from sqlalchemy import select as sa_select
from app.models.meetingroom_guide import MeetingroomGuide
stmt = (
sa_select(MeetingroomGuide)
.where(MeetingroomGuide.is_active == True) # noqa: E712
.order_by(MeetingroomGuide.sort_order, MeetingroomGuide.id)
)
if category:
stmt = stmt.where(MeetingroomGuide.category == category)
result = await db.execute(stmt)
guides = result.scalars().all()
return [
{
"id": g.id,
"category": g.category,
"title": g.title,
"brief": g.brief,
"detail_url": g.detail_url,
"icon": g.icon,
}
for g in guides
]