1156 lines
43 KiB
Python
1156 lines
43 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微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})
|