Files
wecom_it_smart_desk/backend/app/services/meetingroom_service.py
T

560 lines
20 KiB
Python
Raw Normal View History

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