# ============================================================================= # 企微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 ]