Files
wecom_it_smart_desk/backend/app/services/meetingroom_service.py
T
Simon bea288e414 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
2026-07-11 23:13:10 +08:00

513 lines
19 KiB
Python
Raw 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