# ============================================================================= # 企微IT智能服务台 — H5 用户端 API # ============================================================================= # 说明:H5 用户端的接口,包括: # 1. GET /api/h5/oauth/authorize — 获取企微OAuth2授权URL # 2. POST /api/h5/oauth/callback — OAuth2回调,返回token+用户信息 # 3. GET /api/h5/me — 获取当前用户详细信息 # 4. GET /api/h5/user — 获取当前用户信息(兼容旧接口) # 5. GET /api/h5/conversations/current — 获取当前会话 # 6. POST /api/h5/conversations/current/messages — 用户发送消息 # 7. GET /api/h5/conversations/current/messages/poll — 用户轮询新消息 # 8. POST /api/h5/conversations/current/shake — 举手(敲桌子呼叫坐席) # 9. GET /api/h5/approval-links — 获取审批流程链接 # 10. GET /api/h5/software-downloads — 获取软件下载列表 # # 重构记录(2026-06): # - 移除 _get_redis() 手动创建 Redis 模式,改用 DI 共享实例 # - 移除本地打招呼/呼叫人工检测逻辑,改用 AIHandler 统一处理 # - 移除本地 AI 调用/计数/降级逻辑,改用 AIHandler 统一处理 # - 所有服务实例通过 FastAPI Depends 注入,不再手动创建/关闭 # ============================================================================= import json import logging import re import secrets from datetime import datetime from typing import Optional from urllib.parse import quote from uuid import UUID import redis.asyncio as aioredis from fastapi import APIRouter, Depends, Header, Query, Request from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession # 速率限制器实例 # 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数 # python-dotenv 已在应用启动时处理 .env 文件 limiter = Limiter(key_func=get_remote_address) from app.config import settings from app.database import get_db from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler from app.models.approval_link import ApprovalLink from app.models.conversation import Conversation from app.models.message import Message from app.models.software_download import SoftwareDownload from app.schemas.h5 import ( ApprovalLinkResponse, OAuthCallbackRequest, ShakeRequest, SoftwareDownloadResponse, ) from app.schemas.conversation import ConversationResponse, JoinConversationRequest from app.schemas.message import MessageResponse from app.services.ai_handler import AIHandler from app.services.funny_phrase_service import FunnyPhraseService from app.services.ws_manager import manager as ws_manager from app.services.wecom_service import WecomService from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response logger = logging.getLogger(__name__) # 创建路由器 router = APIRouter() # H5 员工端 token TTL:8小时(与坐席端一致) EMPLOYEE_TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时 # -------------------------------------------------------------------------- # 辅助:检测请求是否来自企微 WebView(后端第二道防线) # -------------------------------------------------------------------------- # 企微桌面端 UA 示例:Mozilla/5.0 ... wxwork/4.1.22 ... # 企微移动端 UA 示例:Mozilla/5.0 (iPhone ... MicroMessenger/7.x ... wxwork/3.x ... _WEWORK_UA_RE = re.compile(r"wxwork", re.IGNORECASE) def _require_wework_ua(request: Request) -> None: """校验请求 User-Agent 是否来自企微 WebView。 生产环境下,非企微环境的 OAuth2 请求直接拒绝。 本地开发(localhost / 127.0.0.1)跳过检测,方便调试。 Args: request: FastAPI Request 对象,用于读取 User-Agent 和 Host Raises: AppException: 非企微环境时抛出 403 错误 """ # 本地开发跳过检测 host = request.headers.get("host", "") if host.startswith("localhost") or host.startswith("127.0.0.1"): return ua = request.headers.get("user-agent", "") if not _WEWORK_UA_RE.search(ua): raise AppException(4003, "请在企业微信中访问此服务") # -------------------------------------------------------------------------- # 辅助:从请求头获取员工ID(旧版,仅作为过渡期兼容) # -------------------------------------------------------------------------- def _get_employee_id( x_employee_id: Optional[str] = Header(None, alias="X-Employee-Id"), ) -> str: """从请求头获取员工ID(旧版兼容,已废弃)。 安全警告:此方法直接信任请求头中的明文员工ID,任何人可伪造身份。 仅在本地开发环境(mock_login_enabled=true)下允许使用。 生产环境必须使用 _get_current_employee(Bearer Token 认证)。 Args: x_employee_id: 请求头中的员工ID Returns: str: 员工企微 UserID Raises: AppException: 未提供员工ID 或 生产环境禁用 """ # 生产环境禁止使用明文头认证(可被任意伪造) if not settings.mock_login_enabled: raise AppException( 4001, "X-Employee-Id 认证方式已在生产环境禁用,请使用 OAuth2 登录" ) if not x_employee_id: raise ERR_UNAUTHORIZED return x_employee_id # -------------------------------------------------------------------------- # 辅助:从 Bearer Token 获取当前员工ID(新版,替换 _get_employee_id) # -------------------------------------------------------------------------- async def _get_current_employee( authorization: Optional[str] = Header(None, alias="Authorization"), x_employee_id: Optional[str] = Header(None, alias="X-Employee-Id"), redis_client: Optional[aioredis.Redis] = Depends(dep_redis), ) -> str: """从请求头中提取员工身份(认证依赖)。 认证优先级: 1. Bearer Token(生产环境):从 Redis 查找对应的 employee_id 2. X-Employee-Id 头(开发降级):直接读取 employee_id(仅本地开发使用) Token 存储格式: Redis key: employee:token:{token} Redis value: employee_id (企微 UserID) 重构说明:不再手动创建/关闭 Redis 客户端,改用 DI 注入共享实例。 Args: authorization: 请求头中的 Authorization 字段(格式:Bearer token) x_employee_id: 请求头中的 X-Employee-Id 字段(开发降级用) redis_client: 共享 Redis 客户端(DI 注入) Returns: str: 员工企微 UserID Raises: AppException: 未授权(无有效认证) """ # ===================================================================== # 方式1:Bearer Token 认证(生产环境) # ===================================================================== if authorization: token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization if token and redis_client: try: employee_id_bytes = await redis_client.get(f"employee:token:{token}") if employee_id_bytes: # Redis 返回 bytes,需要解码 return employee_id_bytes.decode("utf-8") if isinstance(employee_id_bytes, bytes) else employee_id_bytes except AppException: raise except Exception as e: logger.error(f"Redis 读取失败: {e}") # ===================================================================== # 方式2:X-Employee-Id 明文头(仅开发环境,生产环境禁用) # ===================================================================== # 安全说明:X-Employee-Id 可被任意 HTTP 客户端伪造,不能用于身份认证 # 仅在 MOCK_LOGIN_ENABLED=true 时允许,方便本地开发调试 if x_employee_id and settings.mock_login_enabled: return x_employee_id raise ERR_UNAUTHORIZED # -------------------------------------------------------------------------- # GET /api/h5/oauth/authorize — 获取企微OAuth2授权URL # -------------------------------------------------------------------------- @router.get("/h5/oauth/authorize") async def get_oauth_authorize_url( request: Request, redirect_uri: Optional[str] = Query(None, description="OAuth2回调地址(可选,默认使用请求来源域名/h5/)"), request_host: Optional[str] = Header(None, alias="Host"), ): """获取企微OAuth2授权URL。 前端调用此接口获取完整的企微OAuth2授权链接, 然后跳转到该链接进行静默授权。 授权流程: 1. 前端请求此接口获取授权URL 2. 前端跳转到授权URL 3. 企微自动重定向到 redirect_uri?code=CODE&state=STATE 4. 前端拿到code,调用 POST /api/h5/oauth/callback Args: request: FastAPI Request 对象(用于 UA 检测) redirect_uri: 自定义回调地址(可选) request_host: 请求的 Host 头(自动获取,用于构造默认回调地址) Returns: Dict: 统一响应格式,包含 authorize_url 字段 """ # 后端第二道防线:非企微环境拒绝授权 _require_wework_ua(request) corp_id = settings.wecom_corp_id # 确定回调地址:优先使用参数传入的,否则根据 Host 头构造 if redirect_uri: encoded_redirect = quote(redirect_uri, safe="") elif request_host: # 从 Host 头构造回调地址(支持 http 和 https) scheme = "https" # 企微H5应用通常使用 https encoded_redirect = quote(f"{scheme}://{request_host}/itportal/", safe="") else: # 最终降级:使用配置中的 CORS 源地址 default_origin = settings.cors_origins_list[0] if settings.cors_origins_list else "https://localhost" encoded_redirect = quote(f"{default_origin}/itportal/", safe="") # 构造企微OAuth2静默授权URL(snsapi_base:用户无感知) authorize_url = ( f"https://open.weixin.qq.com/connect/oauth2/authorize" f"?appid={corp_id}" f"&redirect_uri={encoded_redirect}" f"&response_type=code" f"&scope=snsapi_base" f"&state=STATE" f"#wechat_redirect" ) return success_response(data={"authorize_url": authorize_url}) # -------------------------------------------------------------------------- # POST /api/h5/oauth/callback — OAuth2 回调 # -------------------------------------------------------------------------- @router.post("/h5/oauth/callback") @limiter.limit("20/minute") # OAuth 回调限流:正常用户不会频繁触发 async def oauth_callback( request: Request, body: OAuthCallbackRequest, db: AsyncSession = Depends(get_db), redis_client: Optional[aioredis.Redis] = Depends(dep_redis), wecom_service: WecomService = Depends(dep_wecom_service), ): """企微 OAuth2 授权回调。 H5 页面通过企微 OAuth2 静默授权获取 code,后端用 code 换取员工身份。 成功后生成 Bearer Token 存入 Redis,返回 token + 员工信息。 重构说明:不再手动创建/关闭 Redis 和 WecomService,改用 DI 注入共享实例。 流程: 1. 前端跳转企微授权页面 2. 企微回调到 H5 页面并携带 code 3. H5 前端将 code 发给后端 4. 后端用 code 调用企微 API 换取员工 UserID 5. 后端获取员工详细信息(姓名、部门、岗位等) 6. 生成 Bearer Token 存入 Redis 7. 返回 token + 员工信息 Args: request: FastAPI Request 对象(用于 UA 检测) body: OAuth2 回调请求体(包含 code) db: 数据库会话 redis_client: 共享 Redis 客户端(DI 注入) wecom_service: 共享企微服务(DI 注入) Returns: Dict: 统一响应格式,包含 token 和员工信息 """ # 后端第二道防线:非企微环境拒绝回调 _require_wework_ua(request) try: # 1. 用 code 换取员工身份 user_info = await wecom_service.get_oauth_user_info(body.code) employee_id = user_info.get("userid", "") if not employee_id: raise AppException(2007, "OAuth2授权失败:未获取到员工ID") # 2. 获取员工详细信息 employee_name = "" department = "" position = "" avatar = "" try: detail = await wecom_service.get_user_info(employee_id) employee_name = detail.get("name", "") # department 返回的是部门ID列表,取第一个部门名称需要额外API调用 # 简化处理:将部门ID列表转为逗号分隔的字符串 dept_ids = detail.get("department", []) department = ",".join(str(d) for d in dept_ids) if dept_ids else "" position = detail.get("position", "") avatar = detail.get("avatar", "") except Exception: logger.warning(f"获取员工详细信息失败: employee_id={employee_id}") # 3. 生成 Bearer Token(与坐席端一致:secrets.token_urlsafe(32)) token = secrets.token_urlsafe(32) # 4. Token 存入 Redis(key: employee:token:{token}, value: employee_id, TTL 8小时) if redis_client: try: await redis_client.setex( f"employee:token:{token}", EMPLOYEE_TOKEN_TTL_SECONDS, employee_id, ) except Exception as e: logger.warning(f"Redis 写入失败(token 不会持久化): {e}") # 5. 缓存员工基本信息到 Redis(用于快速读取,避免频繁调用企微API) employee_info_cache = { "employee_id": employee_id, "employee_name": employee_name, "department": department, "position": position, "avatar": avatar, } try: await redis_client.setex( f"employee:info:{employee_id}", EMPLOYEE_TOKEN_TTL_SECONDS, json.dumps(employee_info_cache, ensure_ascii=False), ) except Exception as e: logger.warning(f"员工信息缓存写入失败(不阻塞流程): {e}") logger.info(f"OAuth2授权成功: employee_id={employee_id}, name={employee_name}") # 6. 返回 token + 员工信息 return success_response( data={ "employee_id": employee_id, "employee_name": employee_name, "token": token, "department": department, "position": position, "avatar": avatar, } ) except AppException: raise except Exception as e: logger.error(f"OAuth2回调处理失败: {e}") raise AppException(2007, f"OAuth2授权失败: {e}") # -------------------------------------------------------------------------- # POST /api/h5/mock-login — Mock 登录(测试阶段,跳过 OAuth2) # -------------------------------------------------------------------------- @router.post("/h5/mock-login") @limiter.limit("5/minute") # Mock 登录严格限流:每IP每分钟最多5次 async def mock_login( request: Request, body: dict, redis_client: Optional[aioredis.Redis] = Depends(dep_redis), ): """Mock 登录(测试阶段使用,跳过企微 OAuth2)。 仅当后端配置 MOCK_LOGIN_ENABLED=true 时可用。 直接通过员工 ID 生成 Bearer Token,并存入 Redis。 返回格式与 OAuth2 回调完全一致。 Args: body: 请求体 { employee_id: str, employee_name: str } redis_client: 共享 Redis 客户端(DI 注入) Returns: Dict: 统一响应格式,包含 token 和员工信息 """ if not settings.mock_login_enabled: raise AppException(2007, "Mock 登录未启用,请联系管理员") employee_id = body.get("employee_id", "").strip() employee_name = body.get("employee_name", "测试用户").strip() if not employee_id: raise AppException(2007, "请提供 employee_id") # 生成 Bearer Token token = secrets.token_urlsafe(32) # Token 存入 Redis(key: employee:token:{token}, value: employee_id, TTL 8小时) if redis_client: try: await redis_client.setex( f"employee:token:{token}", EMPLOYEE_TOKEN_TTL_SECONDS, employee_id, ) except Exception as e: logger.warning(f"Redis 写入失败(token 不会持久化): {e}") # 缓存员工基本信息到 Redis employee_info_cache = { "employee_id": employee_id, "employee_name": employee_name, "department": "IT部", "position": "测试岗位", "avatar": "", } try: await redis_client.setex( f"employee:info:{employee_id}", EMPLOYEE_TOKEN_TTL_SECONDS, json.dumps(employee_info_cache, ensure_ascii=False), ) except Exception as e: logger.warning(f"员工信息缓存写入失败(不阻塞流程): {e}") logger.info(f"Mock 登录成功: employee_id={employee_id}, name={employee_name}") return success_response( data={ "employee_id": employee_id, "employee_name": employee_name, "token": token, "department": "IT部", "position": "测试岗位", "avatar": "", } ) # -------------------------------------------------------------------------- # GET /api/h5/me — 获取当前用户详细信息 # -------------------------------------------------------------------------- @router.get("/h5/me") async def get_current_employee_info( employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), redis_client: Optional[aioredis.Redis] = Depends(dep_redis), wecom_service: WecomService = Depends(dep_wecom_service), ): """获取当前登录员工的详细信息。 需要在请求头中携带有效的 Bearer token。 优先从 Redis 缓存读取,缓存不存在则调用企微API获取。 重构说明:不再手动创建/关闭 Redis 和 WecomService,改用 DI 注入共享实例。 Args: employee_id: 员工企微 UserID(通过认证依赖注入) db: 数据库会话 redis_client: 共享 Redis 客户端(DI 注入) wecom_service: 共享企微服务(DI 注入) Returns: Dict: 统一响应格式,包含员工详细信息 """ # 1. 优先从 Redis 缓存读取 if redis_client: try: cached_info = await redis_client.get(f"employee:info:{employee_id}") if cached_info: info_str = cached_info.decode("utf-8") if isinstance(cached_info, bytes) else cached_info info = json.loads(info_str) # 补充 is_vip 字段 info["is_vip"] = False return success_response(data=info) except Exception as e: logger.warning(f"从Redis读取员工信息缓存失败: {e}") # 2. 缓存不存在,调用企微API获取 try: detail = await wecom_service.get_user_info(employee_id) employee_name = detail.get("name", "") dept_ids = detail.get("department", []) department = ",".join(str(d) for d in dept_ids) if dept_ids else "" position = detail.get("position", "") avatar = detail.get("avatar", "") mobile = detail.get("mobile", "") email = detail.get("email", "") # 写入缓存 employee_info = { "employee_id": employee_id, "employee_name": employee_name, "department": department, "position": position, "mobile": mobile, "email": email, "avatar": avatar, "is_vip": False, } if redis_client: try: await redis_client.setex( f"employee:info:{employee_id}", EMPLOYEE_TOKEN_TTL_SECONDS, json.dumps(employee_info, ensure_ascii=False), ) except Exception: pass return success_response(data=employee_info) except AppException: raise except Exception as e: logger.error(f"获取员工信息失败: employee_id={employee_id}, error={e}") raise AppException(2006, f"获取员工信息失败: {e}") # -------------------------------------------------------------------------- # GET /api/h5/user — 获取当前用户信息(兼容旧接口) # -------------------------------------------------------------------------- @router.get("/h5/user") async def get_current_user( employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """获取当前用户信息。 通过 Bearer Token 认证后获取员工信息。 Args: employee_id: 员工企微 UserID(通过认证依赖注入) db: 数据库会话 Returns: Dict: 统一响应格式,包含员工信息 """ # 尝试从会话记录获取员工信息 stmt = select(Conversation).where( Conversation.employee_id == employee_id ).order_by(Conversation.created_at.desc()) result = await db.execute(stmt) latest_conv = result.scalars().first() user_info = { "employee_id": employee_id, "employee_name": latest_conv.employee_name if latest_conv else "", "department": latest_conv.department if latest_conv else "", "position": latest_conv.position if latest_conv else "", "is_vip": latest_conv.is_vip if latest_conv else False, } return success_response(data=user_info) # -------------------------------------------------------------------------- # GET /api/h5/conversations/current — 获取当前会话 # -------------------------------------------------------------------------- @router.get("/h5/conversations/current") async def get_current_conversation( employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """获取当前用户的活跃会话。 查找员工当前状态为 ai_handling、queued 或 serving 的会话。 如果没有活跃会话,返回空数据。 Args: employee_id: 员工企微 UserID db: 数据库会话 Returns: Dict: 统一响应格式,包含会话信息 """ stmt = select(Conversation).where( Conversation.employee_id == employee_id, Conversation.status.in_(["ai_handling", "queued", "serving"]), ).order_by(Conversation.created_at.desc()) result = await db.execute(stmt) conversation = result.scalars().first() if not conversation: return success_response(data=None) conv_data = ConversationResponse.model_validate(conversation).model_dump() # 附加「是否可以呼叫坐席」标志(AI实质性回复 >= 3) conv_data["can_call_agent"] = conversation.ai_substantive_reply_count >= 3 conv_data["ai_substantive_reply_count"] = conversation.ai_substantive_reply_count return success_response(data=conv_data) # -------------------------------------------------------------------------- # POST /api/h5/conversations/current/messages — 用户发送消息 # -------------------------------------------------------------------------- # 消息处理逻辑(2026-06 重构,使用 AIHandler 统一处理): # 1. AIHandler 检测打招呼 → 引导描述问题,不计数 # 2. AIHandler 检测呼叫人工 → 拦截引导,不计数 # 3. AIHandler 调用 Dify API 获取 AI 回复 # - 命中 → AI 回复,ai_substantive_reply_count +1 # - 未命中 → 转 queued,返回转人工提示 # - 异常 → 降级模板回复,不计数,不转人工 # 4. 计数 >= 3 时,前端自动显示「呼叫坐席」按钮 # -------------------------------------------------------------------------- @router.post("/h5/conversations/current/messages") async def h5_send_message( body: dict, employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ai_handler: AIHandler = Depends(dep_ai_handler), ): """H5 用户发送消息(含 AI 回复与计数)。 重构说明:AI 调用逻辑已统一至 AIHandler,此接口仅负责: 1. 会话管理(查找/创建) 2. 消息持久化 3. 根据 AIHandler 返回结果更新会话状态和计数 4. 返回响应 Args: body: 消息请求体(包含 content) employee_id: 员工企微 UserID db: 数据库会话 ai_handler: AI 处理器(DI 注入,统一 AI 调用逻辑) Returns: Dict: 统一响应格式,包含用户消息和 AI 回复 """ content = body.get("content", "") if not content: raise AppException(1001, "消息内容不能为空") # 支持非文本消息类型(image/file 等) msg_type = body.get("msg_type", "text") # 消息内容类型:text/image/file media_url = body.get("media_url") # 图片/文件 URL file_name = body.get("file_name") # 文件名 file_size = body.get("file_size") # 文件大小(字节) # 1. 查找或创建会话(新会话默认 ai_handling) stmt = select(Conversation).where( Conversation.employee_id == employee_id, Conversation.status.in_(["ai_handling", "queued", "serving"]), ).order_by(Conversation.created_at.desc()) result = await db.execute(stmt) conversation = result.scalars().first() if not conversation: conversation = Conversation( employee_id=employee_id, status="ai_handling", # 先让 AI 尝试回答 urgency_score=1, tags={}, ai_substantive_reply_count=0, last_message_at=datetime.now(), last_message_summary=content[:256], ) db.add(conversation) await db.flush() # 2. 创建用户消息记录 message = Message( conversation_id=conversation.id, sender_type="employee", sender_id=employee_id, content=content, msg_type=msg_type, media_url=media_url, file_name=file_name, file_size=file_size, is_read=False, ) db.add(message) # 更新会话信息 conversation.last_message_at = datetime.now() conversation.last_message_summary = content[:256] conversation.updated_at = datetime.now() db.add(conversation) await db.flush() # 3. 调用 AIHandler 统一处理(打招呼检测 → 呼叫人工拦截 → AI 调用) ai_result = await ai_handler.handle_message( content=content, dify_conversation_id=conversation.dify_conversation_id, user_id=employee_id, ) # 4. 根据 AIHandler 返回结果更新会话状态 # 更新 Dify 会话ID(多轮对话上下文) if ai_result.dify_conversation_id: conversation.dify_conversation_id = ai_result.dify_conversation_id # 更新 AI 实质性回复计数(仅 AI 命中时 +1) if ai_result.should_count: conversation.ai_substantive_reply_count += 1 # 更新会话状态(未命中转人工时改为 queued) if ai_result.should_transfer: conversation.status = "queued" db.add(conversation) # 5. 创建 AI 回复消息 ai_message = Message( conversation_id=conversation.id, sender_type="ai", sender_id="ai_bot", sender_name="AI智能助手", content=ai_result.content, msg_type="text", is_read=True, ) db.add(ai_message) await db.flush() # 6. WebSocket 广播:通知坐席端有新消息 # 做什么:向所有在线坐席广播 new_message 事件,携带用户消息和 AI 回复 # 为什么:坐席端需要实时看到员工的新消息和 AI 回复, # 仅靠3秒轮询会有延迟,WS 推送更实时 try: # 广播用户消息 await ws_manager.broadcast({ "type": "new_message", "data": { "conversation_id": str(conversation.id), "message_id": str(message.id), "sender_type": "employee", "sender_id": employee_id, "sender_name": "", "content": content, "msg_type": msg_type, "urgency_score": conversation.urgency_score, "tags": conversation.tags, }, }) # 广播 AI 回复 await ws_manager.broadcast({ "type": "new_message", "data": { "conversation_id": str(conversation.id), "message_id": str(ai_message.id), "sender_type": "ai", "sender_id": "ai_bot", "sender_name": "AI智能助手", "content": ai_result.content, "msg_type": "text", }, }) # 如果会话状态变更(如新会话创建或转人工),也广播状态变更 await ws_manager.broadcast({ "type": "conversation_updated", "data": { "conversation_id": str(conversation.id), "status": conversation.status, "assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None, }, }) except Exception as ws_err: # WS 广播失败不阻塞消息存储,只记录 warning logger.warning(f"WS 广播新消息失败(消息已存储): {ws_err}") # 7. 返回用户消息 + AI 回复 user_msg_data = MessageResponse.model_validate(message).model_dump() ai_msg_data = MessageResponse.model_validate(ai_message).model_dump() return success_response( data={ "user_message": user_msg_data, "ai_reply": ai_msg_data, "is_guidance": ai_result.is_guidance, "ai_reply_count": conversation.ai_substantive_reply_count, "can_call_agent": conversation.ai_substantive_reply_count >= 3, "conversation_status": conversation.status, } ) # -------------------------------------------------------------------------- # GET /api/h5/conversations/current/messages/poll — 用户轮询新消息 # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- # GET /api/h5/conversations/current/messages/poll — 用户轮询新消息 # -------------------------------------------------------------------------- @router.get("/h5/conversations/current/messages/poll") async def h5_poll_messages( after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"), employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """H5 用户轮询新消息。 前端定时调用获取坐席回复的新消息。 Args: after_message_id: 上次轮询的最后一消息ID employee_id: 员工企微 UserID db: 数据库会话 Returns: Dict: 统一响应格式,包含新消息列表 """ # 查找当前会话 stmt = select(Conversation).where( Conversation.employee_id == employee_id, Conversation.status.in_(["ai_handling", "queued", "serving"]), ).order_by(Conversation.created_at.desc()) result = await db.execute(stmt) conversation = result.scalars().first() if not conversation: return success_response(data={"items": [], "has_more": False}) # 查询新消息 msg_stmt = select(Message).where( Message.conversation_id == conversation.id ).order_by(Message.created_at.asc()) if after_message_id: # 转换为UUID类型查询,确保和数据库UUID字段类型匹配 from uuid import UUID as UUIDType try: msg_uuid = UUIDType(after_message_id) except ValueError: # 无效的UUID格式,返回空列表 items = [] return success_response(data={"items": items, "has_more": False}) after_stmt = select(Message.created_at).where( Message.id == msg_uuid ) after_result = await db.execute(after_stmt) after_time = after_result.scalar_one_or_none() if after_time: msg_stmt = msg_stmt.where(Message.created_at > after_time) msg_result = await db.execute(msg_stmt) messages = list(msg_result.scalars().all()) items = [MessageResponse.model_validate(m).model_dump() for m in messages] return success_response(data={"items": items, "has_more": False}) # -------------------------------------------------------------------------- # POST /api/h5/conversations/current/shake — 举手/敲桌子呼叫坐席 # -------------------------------------------------------------------------- @router.post("/h5/conversations/current/shake") async def shake( body: ShakeRequest, db: AsyncSession = Depends(get_db), wecom_service: Optional[WecomService] = Depends(dep_wecom_service), ): """举手(敲桌子呼叫坐席)。 前端按钮从「摇人🔔」改为「敲桌子👊👊」,后端端点保持不变。 重构说明:不再手动创建/关闭 Redis 和 WecomService,改用 DI 注入共享实例。 流程: 1. 查找或创建会话 2. 设置举手标记 3. 获取趣味话术 4. 发送系统消息 5. 通过企微 API 发送话术给员工 6. 返回会话信息和话术 Args: body: 举手请求体(包含 employee_id 和 employee_name) db: 数据库会话 wecom_service: 共享企微服务(DI 注入) Returns: Dict: 统一响应格式,包含会话信息和趣味话术 """ employee_id = body.employee_id employee_name = body.employee_name # 1. 查找或创建会话 stmt = select(Conversation).where( Conversation.employee_id == employee_id, Conversation.status.in_(["ai_handling", "queued", "serving"]), ).order_by(Conversation.created_at.desc()) result = await db.execute(stmt) conversation = result.scalars().first() if not conversation: # 无活跃会话 → 拒绝,必须先与 AI 互动(前端按钮此时不应出现,这是后端兜底) raise AppException( 1003, "请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~" ) # 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席 if conversation.ai_substantive_reply_count < 3: raise AppException( 1003, "请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~" ) # 更新员工姓名 if employee_name and not conversation.employee_name: conversation.employee_name = employee_name # 设置举手标记 tags = dict(conversation.tags) if conversation.tags else {} tags["hand_raise"] = True conversation.tags = tags conversation.urgency_score = max(conversation.urgency_score, 2) conversation.last_message_at = datetime.now() conversation.updated_at = datetime.now() db.add(conversation) await db.flush() # 2. 获取趣味话术 funny_phrase_service = FunnyPhraseService(db) is_vip = conversation.is_vip phrase = await funny_phrase_service.get_phrase("shake", is_vip=is_vip) # 3. 创建系统消息 system_msg = Message( conversation_id=conversation.id, sender_type="system", sender_id="system", sender_name="系统", content=phrase, msg_type="system", is_read=True, ) db.add(system_msg) await db.flush() # 4. 通过企微 API 发送话术给员工(使用共享 WecomService) if wecom_service: try: await wecom_service.send_text_message(employee_id, phrase) except Exception as e: logger.warning(f"举手话术推送失败(不阻塞流程): {e}") logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}") # 5. 返回会话信息和话术 conv_data = ConversationResponse.model_validate(conversation).model_dump() return success_response( data={ "conversation": conv_data, "funny_phrase": phrase, } ) # -------------------------------------------------------------------------- # GET /api/h5/approval-links — 获取审批流程链接 # -------------------------------------------------------------------------- @router.get("/h5/approval-links") async def get_approval_links( category: Optional[str] = Query(None, description="按分类过滤: IT/HR/行政/财务"), db: AsyncSession = Depends(get_db), ): """获取审批流程链接。 从 approval_links 表读取,支持按分类过滤。 用于 H5 用户端 AI 助手面板。 Args: category: 按分类过滤(可选) db: 数据库会话 Returns: Dict: 统一响应格式,包含审批链接列表 """ stmt = select(ApprovalLink).order_by(ApprovalLink.sort_order) if category: stmt = stmt.where(ApprovalLink.category == category) result = await db.execute(stmt) links = list(result.scalars().all()) items = [ApprovalLinkResponse.model_validate(link).model_dump() for link in links] return success_response(data={"items": items}) # -------------------------------------------------------------------------- # GET /api/h5/software-downloads — 获取软件下载列表 # -------------------------------------------------------------------------- @router.get("/h5/software-downloads") async def get_software_downloads( category: Optional[str] = Query(None, description="按分类过滤: 办公/开发/安全/工具"), db: AsyncSession = Depends(get_db), ): """获取软件下载列表。 从 software_downloads 表读取,支持按分类过滤。 用于 H5 用户端 AI 助手面板。 Args: category: 按分类过滤(可选) db: 数据库会话 Returns: Dict: 统一响应格式,包含软件下载列表 """ stmt = select(SoftwareDownload).order_by(SoftwareDownload.sort_order) if category: stmt = stmt.where(SoftwareDownload.category == category) result = await db.execute(stmt) downloads = list(result.scalars().all()) items = [SoftwareDownloadResponse.model_validate(d).model_dump() for d in downloads] return success_response(data={"items": items}) # ========================================================================== # 邀请功能 H5 专用端点(P0-09~P0-11) # ========================================================================== # 说明:为 H5 员工端提供带认证的参与者管理接口 # 认证:使用 _get_current_employee 依赖,验证 Bearer Token # 安全:校验 token 对应的 employee_id 与请求体一致,防止冒充 # -------------------------------------------------------------------------- from app.services.session_service import SessionService @router.post("/h5/conversations/{conversation_id}/join") async def h5_join_conversation( conversation_id: str, employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """H5员工加入会话(带认证)。 做什么:被邀请人点击企微卡片链接后,通过此接口加入会话 为什么:需要验证请求者身份,防止冒充其他员工加入会话 认证:Bearer Token → employee_id,与请求体中的 employee_id 校验一致性 副作用: - 更新参与者的 joined 状态 - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID employee_id: 当前登录员工ID(从 Token 认证获取) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.join_conversation( conversation_id=conversation_id, employee_id=employee_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) @router.post("/h5/conversations/{conversation_id}/leave-participant") async def h5_leave_participant( conversation_id: str, employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """H5员工退出会话(带认证)。 做什么:参与者主动退出会话 为什么:需要验证请求者身份,防止冒充其他员工退出 认证:Bearer Token → employee_id 副作用: - 在会话中创建系统消息 - WebSocket 广播参与者变更 Args: conversation_id: 会话ID employee_id: 当前登录员工ID(从 Token 认证获取) db: 数据库会话 Returns: Dict: 统一响应格式,包含更新后的会话信息 """ session_service = SessionService(db) conversation = await session_service.leave_as_participant( conversation_id=conversation_id, employee_id=employee_id, ) response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) @router.get("/h5/conversations/{conversation_id}/participants") async def h5_get_participants( conversation_id: str, employee_id: str = Depends(_get_current_employee), db: AsyncSession = Depends(get_db), ): """获取会话参与者列表(带认证 + 参与者权限校验)。 做什么:返回指定会话的所有参与者信息 为什么:H5员工端需要查看参与者面板 认证:Bearer Token → employee_id 权限:仅会话发起人(employee_id)或已加入的参与者可查看,防止越权读取他会话信息 P0 安全修复(2026-06-14 评审): 此前仅校验"用户已登录",未校验"是否属于本会话",存在数据泄露风险—— 任意已登录员工可枚举 conversation_id 读取其他会话的参与者名单。 Args: conversation_id: 会话ID employee_id: 当前登录员工ID(从 Token 认证获取) db: 数据库会话 Returns: Dict: 统一响应格式,包含参与者列表 Raises: ERR_CONVERSATION_NOT_FOUND: 会话不存在 AppException(4003): 当前员工不是会话发起人/参与者 """ stmt = select(Conversation).where(Conversation.id == conversation_id) result = await db.execute(stmt) conversation = result.scalars().first() if not conversation: from app.utils.response import ERR_CONVERSATION_NOT_FOUND raise ERR_CONVERSATION_NOT_FOUND # P0-1 修复:校验当前员工是否有权查看本会话的参与者 # 权限规则:会话发起人 OR 已加入的参与者 is_creator = conversation.employee_id == employee_id is_participant = any( p.get("id") == employee_id for p in (conversation.participants or []) ) if not (is_creator or is_participant): raise AppException(4003, "您不是该会话的参与者,无权查看") participants = conversation.participants or [] return success_response(data={"participants": participants})