Files
wecom_it_smart_desk/backend/app/api/meetingroom.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

639 lines
24 KiB
Python

# =============================================================================
# 企微IT智能服务台 — 会议室预定 REST API 路由
# =============================================================================
# 说明:提供会议室预定的REST API,供终端前端和H5端调用
#
# API 端点列表:
# GET /itportal/meetingroom/list — 会议室列表
# GET /itportal/meetingroom/{meetingroom_id}/booking — 预定状态查询
# GET /itportal/meetingroom/{meetingroom_id}/status — 当前实时状态
# POST /itportal/meetingroom/book — 预定会议室
# DELETE /itportal/meetingroom/booking/{booking_id} — 取消预定
# GET /itportal/meetingroom/booking/{booking_id}/detail — 预定详情
# GET /itportal/meetingroom/terminal/{terminal_sn}/binding — 终端绑定查询
# POST /itportal/meetingroom/repair — 提交设备报修
# GET /itportal/meetingroom/guides — 操作指南列表
# GET /itportal/meetingroom/guides/{category} — 按类型获取指南
# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码
# =============================================================================
import io
import logging
from typing import Optional
import qrcode
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, UserInfo
from app.models.terminal_room_binding import TerminalRoomBinding
from app.schemas.meetingroom import (
BookRequest,
BookResponse,
BookingDetailResponse,
BookingInfoResponse,
BookingItem,
GuideItem,
MeetingroomItem,
MeetingroomListResponse,
RepairRequest,
RepairResponse,
RoomStatusResponse,
TerminalBindingResponse,
)
from app.services.meetingroom_service import MeetingroomService
from app.services.wecom_service import WecomService
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter(prefix="/itportal/meetingroom", tags=["会议室预定"])
def _get_meetingroom_service(
redis_client=None,
) -> MeetingroomService:
"""构造 MeetingroomService 实例。
Args:
redis_client: Redis 客户端(可选)
Returns:
MeetingroomService: 会议室预定服务实例
"""
wecom_service = WecomService(redis_client or settings.create_redis_client())
return MeetingroomService(wecom_service, redis_client or settings.create_redis_client())
# =============================================================================
# GET /itportal/meetingroom/list — 会议室列表
# =============================================================================
@router.get("/list", response_model=None)
async def get_meetingroom_list(
city: Optional[str] = Query(None, description="城市名称过滤"),
building: Optional[str] = Query(None, description="楼宇名称过滤"),
floor: Optional[str] = Query(None, description="楼层名称过滤"),
):
"""获取会议室列表。
无需认证(终端页面访客可查看)。
代理调用企微API获取会议室列表,结果缓存10分钟。
Args:
city: 城市名称(可选过滤)
building: 楼宇名称(可选过滤)
floor: 楼层名称(可选过滤)
Returns:
Dict: 统一响应格式,data 含 rooms 列表
"""
try:
service = _get_meetingroom_service()
room_list = await service.get_room_list(city, building, floor)
# 格式化响应
rooms = [
MeetingroomItem(
meetingroom_id=room.get("meetingroom_id", 0),
name=room.get("name", ""),
capacity=room.get("capacity", 0),
location=room.get("location", ""),
devices=room.get("equipment", room.get("devices", [])),
need_approval=room.get("need_approval", 0),
).model_dump()
for room in room_list
]
return success_response(data={"rooms": rooms})
except Exception as e:
logger.error(f"获取会议室列表异常: {e}", exc_info=True)
raise AppException(2001, f"获取会议室列表失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/{meetingroom_id}/booking — 预定状态查询
# =============================================================================
@router.get("/{meetingroom_id}/booking", response_model=None)
async def get_booking_info(
meetingroom_id: int,
date: Optional[str] = Query(None, description="查询日期 YYYY-MM-DD(默认今天)"),
):
"""获取指定日期的预定状态。
无需认证(终端页面访客可查看)。
代理调用企微API获取预定记录,结果缓存30秒。
Args:
meetingroom_id: 企微会议室ID
date: 查询日期(YYYY-MM-DD,默认今天)
Returns:
Dict: 统一响应格式,data 含 bookings 列表
"""
try:
service = _get_meetingroom_service()
booking_list = await service.get_room_status(meetingroom_id, date)
# 格式化预定记录
bookings = []
for booking in booking_list:
formatted = service._format_booking(booking)
bookings.append(BookingItem(
booking_id=str(formatted.get("booking_id", "")),
subject=formatted.get("subject", ""),
booker=formatted.get("booker", ""),
booker_name=formatted.get("booker_name", ""),
start_time=formatted.get("start_time", ""),
end_time=formatted.get("end_time", ""),
status=formatted.get("status", 0),
).model_dump())
return success_response(data={"bookings": bookings})
except Exception as e:
logger.error(f"获取预定状态异常: room_id={meetingroom_id}, error={e}", exc_info=True)
raise AppException(2001, f"获取预定状态失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/{meetingroom_id}/status — 当前实时状态
# =============================================================================
@router.get("/{meetingroom_id}/status", response_model=None)
async def get_current_status(meetingroom_id: int):
"""获取当前实时状态(综合判断空闲/使用中/即将开始)。
无需认证(终端页面访客可查看)。
结果缓存10秒(极短缓存防刷)。
Args:
meetingroom_id: 企微会议室ID
Returns:
Dict: 统一响应格式,data 含 status/current_meeting/next_meeting/bookings
"""
try:
service = _get_meetingroom_service()
status_data = await service.get_current_status(meetingroom_id)
# 格式化响应
current_meeting = None
if status_data.get("current_meeting"):
cm = status_data["current_meeting"]
current_meeting = BookingItem(
booking_id=str(cm.get("booking_id", "")),
subject=cm.get("subject", ""),
booker=cm.get("booker", ""),
booker_name=cm.get("booker_name", ""),
start_time=cm.get("start_time", ""),
end_time=cm.get("end_time", ""),
status=cm.get("status", 0),
).model_dump()
next_meeting = None
if status_data.get("next_meeting"):
nm = status_data["next_meeting"]
next_meeting = BookingItem(
booking_id=str(nm.get("booking_id", "")),
subject=nm.get("subject", ""),
booker=nm.get("booker", ""),
booker_name=nm.get("booker_name", ""),
start_time=nm.get("start_time", ""),
end_time=nm.get("end_time", ""),
status=nm.get("status", 0),
).model_dump()
bookings = []
for b in status_data.get("bookings", []):
bookings.append(BookingItem(
booking_id=str(b.get("booking_id", "")),
subject=b.get("subject", ""),
booker=b.get("booker", ""),
booker_name=b.get("booker_name", ""),
start_time=b.get("start_time", ""),
end_time=b.get("end_time", ""),
status=b.get("status", 0),
).model_dump())
response_data = RoomStatusResponse(
status=status_data.get("status", "free"),
current_meeting=current_meeting,
next_meeting=next_meeting,
minutes_to_next=status_data.get("minutes_to_next"),
bookings=bookings,
).model_dump()
return success_response(data=response_data)
except Exception as e:
logger.error(f"获取实时状态异常: room_id={meetingroom_id}, error={e}", exc_info=True)
raise AppException(2001, f"获取实时状态失败: {str(e)}")
# =============================================================================
# POST /itportal/meetingroom/book — 预定会议室
# =============================================================================
@router.post("/book", response_model=None)
async def book_meetingroom(
body: BookRequest,
current_user: UserInfo = Depends(get_current_user),
):
"""预定会议室。
需要认证(Bearer Token),预定人使用当前登录用户userid。
Args:
body: 预定请求参数
current_user: 当前登录用户
Returns:
Dict: 统一响应格式,data 含 booking_id
"""
try:
service = _get_meetingroom_service()
# 使用当前登录用户作为预定人
result = await service.book_room(
meetingroom_id=body.meetingroom_id,
subject=body.subject,
start_time=body.start_time,
end_time=body.end_time,
booker=current_user.employee_id,
attendees=body.attendees,
)
booking_id = result.get("booking_id", "")
# 通过WS通知绑定该会议室的终端
try:
from sqlalchemy import select as sa_select
from app.database import _get_session_factory
session_factory = _get_session_factory()
async with session_factory() as notify_db:
stmt = select(TerminalRoomBinding).where(
TerminalRoomBinding.meetingroom_id == body.meetingroom_id,
TerminalRoomBinding.is_active == True, # noqa: E712
)
result_bindings = await notify_db.execute(stmt)
bindings = result_bindings.scalars().all()
for binding in bindings:
await service.notify_terminal_update(binding.terminal_sn, body.meetingroom_id)
except Exception as notify_err:
logger.warning(f"预定后WS通知终端失败(不影响主流程): {notify_err}")
return success_response(data=BookResponse(booking_id=booking_id).model_dump())
except Exception as e:
logger.error(f"预定会议室异常: {e}", exc_info=True)
error_msg = str(e)
# 常见错误码映射
if "时间冲突" in error_msg or "冲突" in error_msg:
raise AppException(3102, "该时段已被预定(时间冲突)")
raise AppException(2001, f"预定会议室失败: {error_msg}")
# =============================================================================
# DELETE /itportal/meetingroom/booking/{booking_id} — 取消预定
# =============================================================================
@router.delete("/booking/{booking_id}", response_model=None)
async def cancel_booking(
booking_id: str,
meetingroom_id: int = Query(..., description="企微会议室ID"),
current_user: UserInfo = Depends(get_current_user),
):
"""取消预定。
需要认证(Bearer Token)。
Args:
booking_id: 企微预定ID
meetingroom_id: 企微会议室ID
current_user: 当前登录用户
Returns:
Dict: 统一响应格式
"""
try:
service = _get_meetingroom_service()
await service.cancel_booking(booking_id, meetingroom_id)
# 通过WS通知绑定该会议室的终端
try:
# 查询绑定该会议室的终端
from sqlalchemy import select as sa_select
from app.database import _get_session_factory
session_factory = _get_session_factory()
async with session_factory() as notify_db:
stmt = select(TerminalRoomBinding).where(
TerminalRoomBinding.meetingroom_id == meetingroom_id,
TerminalRoomBinding.is_active == True, # noqa: E712
)
result = await notify_db.execute(stmt)
bindings = result.scalars().all()
for binding in bindings:
await service.notify_terminal_update(binding.terminal_sn, meetingroom_id)
except Exception as notify_err:
logger.warning(f"取消预定后WS通知终端失败(不影响主流程): {notify_err}")
return success_response(data={})
except Exception as e:
logger.error(f"取消预定异常: booking_id={booking_id}, error={e}", exc_info=True)
error_msg = str(e)
if "非预定人" in error_msg or "权限" in error_msg:
raise AppException(3105, "非预定人无法取消")
raise AppException(2001, f"取消预定失败: {error_msg}")
# =============================================================================
# GET /itportal/meetingroom/booking/{booking_id}/detail — 预定详情
# =============================================================================
@router.get("/booking/{booking_id}/detail", response_model=None)
async def get_booking_detail(
booking_id: str,
meetingroom_id: int = Query(..., description="企微会议室ID"),
):
"""获取预定详情。
无需认证(终端页面访客可查看)。
Args:
booking_id: 企微预定ID
meetingroom_id: 企微会议室ID
Returns:
Dict: 统一响应格式,data 含预定详情
"""
try:
service = _get_meetingroom_service()
detail = await service.get_booking_detail(meetingroom_id, booking_id)
response_data = BookingDetailResponse(
booking_id=str(detail.get("booking_id", booking_id)),
subject=detail.get("subject", ""),
booker=detail.get("booker", ""),
booker_name=detail.get("booker_name", ""),
attendees=detail.get("attendees", []),
start_time=detail.get("start_time", ""),
end_time=detail.get("end_time", ""),
).model_dump()
return success_response(data=response_data)
except Exception as e:
logger.error(f"获取预定详情异常: booking_id={booking_id}, error={e}", exc_info=True)
raise AppException(2001, f"获取预定详情失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/terminal/{terminal_sn}/binding — 终端绑定查询
# =============================================================================
@router.get("/terminal/{terminal_sn}/binding", response_model=None)
async def get_terminal_binding(
terminal_sn: str,
db: AsyncSession = Depends(get_db),
):
"""根据终端SN查询绑定的会议室信息。
无需认证(终端页面加载时调用)。
Args:
terminal_sn: 终端序列号
db: 数据库会话
Returns:
Dict: 统一响应格式,data 含绑定信息(未绑定时返回 null)
"""
try:
stmt = select(TerminalRoomBinding).where(
TerminalRoomBinding.terminal_sn == terminal_sn,
TerminalRoomBinding.is_active == True, # noqa: E712
)
result = await db.execute(stmt)
binding = result.scalar_one_or_none()
if not binding:
return success_response(data=None)
response_data = TerminalBindingResponse(
id=binding.id,
terminal_sn=binding.terminal_sn,
terminal_name=binding.terminal_name,
meetingroom_id=binding.meetingroom_id,
meetingroom_name=binding.meetingroom_name,
location=binding.location,
is_active=binding.is_active,
created_at=binding.created_at.isoformat() if binding.created_at else "",
updated_at=binding.updated_at.isoformat() if binding.updated_at else "",
).model_dump()
return success_response(data=response_data)
except Exception as e:
logger.error(f"查询终端绑定异常: sn={terminal_sn}, error={e}", exc_info=True)
raise AppException(1005, f"查询终端绑定失败: {str(e)}")
# =============================================================================
# POST /itportal/meetingroom/repair — 提交报修
# =============================================================================
@router.post("/repair", response_model=None)
async def submit_repair(
body: RepairRequest,
db: AsyncSession = Depends(get_db),
):
"""提交会议室设备报修。
无需认证(终端公共设备,支持匿名报修)。
报修后自动创建IT工单会话 + 企微消息通知管理员 + WS通知坐席。
Args:
body: 报修请求参数
db: 数据库会话
Returns:
Dict: 统一响应格式,data 含 repair_id 和 conversation_id
"""
try:
from app.services.repair_service import RepairService
wecom_service = WecomService(settings.create_redis_client())
service = RepairService(db, wecom_service)
result = await service.submit_repair(
terminal_sn=body.terminal_sn,
meetingroom_id=body.meetingroom_id,
meetingroom_name=body.meetingroom_name,
device_type=body.device_type,
fault_description=body.fault_description,
reporter_name=body.reporter_name,
reporter_userid=body.reporter_userid,
)
return success_response(data=RepairResponse(
repair_id=result["repair_id"],
conversation_id=result["conversation_id"],
status=0,
).model_dump())
except Exception as e:
logger.error(f"提交报修异常: {e}", exc_info=True)
raise AppException(3100, f"提交报修失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/guides — 获取操作指南列表
# =============================================================================
@router.get("/guides", response_model=None)
async def get_guides(
category: Optional[str] = Query(None, description="设备类型过滤"),
db: AsyncSession = Depends(get_db),
):
"""获取操作指南列表。
无需认证(终端公共设备,访客可查看)。
Args:
category: 设备类型过滤(可选):projector/video_conf/aircon/phone/other
db: 数据库会话
Returns:
Dict: 统一响应格式,data 含 guides 列表
"""
try:
guides = await MeetingroomService.get_guides(db, category)
guide_items = [
GuideItem(
id=g["id"],
category=g["category"],
title=g["title"],
brief=g["brief"],
detail_url=g["detail_url"],
icon=g["icon"],
).model_dump()
for g in guides
]
return success_response(data={"guides": guide_items})
except Exception as e:
logger.error(f"获取操作指南异常: {e}", exc_info=True)
raise AppException(3101, f"获取操作指南失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/guides/{category} — 按设备类型获取指南
# =============================================================================
@router.get("/guides/{category}", response_model=None)
async def get_guides_by_category(
category: str,
db: AsyncSession = Depends(get_db),
):
"""按设备类型获取操作指南。
无需认证。
Args:
category: 设备类型
db: 数据库会话
Returns:
Dict: 统一响应格式,data 含 guides 列表
"""
try:
guides = await MeetingroomService.get_guides(db, category)
guide_items = [
GuideItem(
id=g["id"],
category=g["category"],
title=g["title"],
brief=g["brief"],
detail_url=g["detail_url"],
icon=g["icon"],
).model_dump()
for g in guides
]
return success_response(data={"guides": guide_items})
except Exception as e:
logger.error(f"按类型获取操作指南异常: category={category}, error={e}", exc_info=True)
raise AppException(3101, f"获取操作指南失败: {str(e)}")
# =============================================================================
# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码
# =============================================================================
# 用于 NE2005 等不支持 H5 应用的老型号终端:
# 管理员打印此二维码贴在终端上,用户用手机扫码访问终端页面
# =============================================================================
# 终端页面基础 URL(从 settings 读取,默认使用生产域名)
_TERMINAL_BASE_URL = getattr(settings, 'terminal_base_url', None) or 'https://itsupport.servyou.com.cn/itterminal/'
@router.get("/terminal/{terminal_sn}/qrcode")
async def get_terminal_qrcode(
terminal_sn: str,
size: int = Query(300, ge=100, le=800, description="二维码图片尺寸(像素)"),
):
"""生成终端访问二维码(PNG 图片)。
用于 NE2005 等不支持 H5 应用的老型号终端:
- 管理员可调用此接口获取二维码图片,打印后贴在终端上
- 用户用企业微信/微信扫码后在手机上打开终端页面
无需认证(二维码内容为公开的终端页面 URL)。
Args:
terminal_sn: 终端序列号
size: 二维码图片尺寸(像素),默认 300,范围 100-800
Returns:
StreamingResponse: PNG 图片,Content-Type: image/png
"""
try:
# 构建终端页面 URL
url = f"{_TERMINAL_BASE_URL}{terminal_sn}/"
# 生成二维码
qr = qrcode.QRCode(
version=None, # 自动选择版本
error_correction=qrcode.constants.ERROR_CORRECT_M, # 中等容错
box_size=10,
border=2,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 转为 PNG 字节流
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
logger.info(f"生成终端二维码: sn={terminal_sn}, url={url}, size={size}")
return StreamingResponse(
buf,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=3600", # 缓存 1 小时
"X-Terminal-URL": url, # 调试用:响应头返回 URL
},
)
except Exception as e:
logger.error(f"生成终端二维码异常: sn={terminal_sn}, error={e}", exc_info=True)
raise AppException(3102, f"生成二维码失败: {str(e)}")