2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微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
|
|
|
|
|
|
|
2026-07-04 21:01:39 +08:00
|
|
|
|
import redis
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
2026-07-07 21:52:11 +08:00
|
|
|
|
from app.utils.env_gating import is_production
|
2026-07-09 11:47:16 +08:00
|
|
|
|
from app.dependencies import dep_redis, dep_wecom_service
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
2026-07-09 11:47:16 +08:00
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
from app.tasks.h5_ai_task import process_h5_ai_reply
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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。
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
仅生产环境强制校验(env_gating.is_production()):
|
|
|
|
|
|
非企微环境的 OAuth2 请求直接拒绝。
|
|
|
|
|
|
本地开发 / dev / test 环境跳过检测,方便调试。
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-07-07 21:52:11 +08:00
|
|
|
|
request: FastAPI Request 对象,用于读取 User-Agent
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
Raises:
|
2026-07-07 21:52:11 +08:00
|
|
|
|
AppException: 非企微环境且处于生产环境时抛出 4003 错误
|
2026-06-14 16:49:18 +08:00
|
|
|
|
"""
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# 仅生产环境强制校验;非生产环境(dev/test/本地)一律放行
|
|
|
|
|
|
if not is_production():
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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
|
2026-07-04 21:01:39 +08:00
|
|
|
|
if token:
|
|
|
|
|
|
if 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 redis.exceptions.TimeoutError as e:
|
|
|
|
|
|
logger.error(f"Redis 连接超时,无法验证 Bearer Token: {e}")
|
|
|
|
|
|
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
|
|
|
|
|
if x_employee_id and settings.mock_login_enabled:
|
|
|
|
|
|
logger.warning("Redis 超时,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
|
|
|
|
|
return x_employee_id
|
|
|
|
|
|
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
|
|
|
|
|
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Redis 读取失败,无法验证 Bearer Token: {e}")
|
|
|
|
|
|
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
|
|
|
|
|
if x_employee_id and settings.mock_login_enabled:
|
|
|
|
|
|
logger.warning("Redis 异常,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
|
|
|
|
|
return x_employee_id
|
|
|
|
|
|
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
|
|
|
|
|
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# redis_client 为 None:dep_redis 返回了 None(Redis 连接创建失败)
|
|
|
|
|
|
logger.error("Redis 客户端不可用,无法验证 Bearer Token")
|
|
|
|
|
|
if x_employee_id and settings.mock_login_enabled:
|
|
|
|
|
|
logger.warning("Redis 不可用,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
|
|
|
|
|
return x_employee_id
|
|
|
|
|
|
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# =====================================================================
|
|
|
|
|
|
# 方式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
|
2026-07-07 21:52:11 +08:00
|
|
|
|
encoded_redirect = quote(f"{scheme}://{request_host}/itdesk/", safe="")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 最终降级:使用配置中的 CORS 源地址
|
|
|
|
|
|
default_origin = settings.cors_origins_list[0] if settings.cors_origins_list else "https://localhost"
|
2026-07-07 21:52:11 +08:00
|
|
|
|
encoded_redirect = quote(f"{default_origin}/itdesk/", safe="")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 构造企微OAuth2静默授权URL(snsapi_base:用户无感知)
|
2026-07-05 17:03:36 +08:00
|
|
|
|
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
authorize_url = (
|
2026-07-05 17:03:36 +08:00
|
|
|
|
f"https://open.work.weixin.qq.com/connect/oauth2/authorize"
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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 = ""
|
|
|
|
|
|
|
2026-07-05 17:03:36 +08:00
|
|
|
|
# 2.1 获取员工详细信息(包含头像)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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", "")
|
2026-07-05 17:03:36 +08:00
|
|
|
|
|
|
|
|
|
|
# 调试日志:检查企微API返回的完整数据
|
|
|
|
|
|
logger.info(f"企微用户详情返回: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 2.2 将员工信息保存到数据库(包含头像)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.models.employee import Employee
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
stmt = select(Employee).where(
|
|
|
|
|
|
Employee.employee_id == employee_id,
|
|
|
|
|
|
Employee.corp_id == settings.wecom_corp_id
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
employee = result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if employee:
|
|
|
|
|
|
# 更新已有记录
|
|
|
|
|
|
employee.name = employee_name
|
|
|
|
|
|
employee.department = department
|
|
|
|
|
|
employee.position = position
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# 【FE-UA-005 优化】每次登录强制更新头像URL + 清缓存(统一走 avatar_service)
|
|
|
|
|
|
# 头像更新失败不阻塞登录(sync_employee_avatar 内部已容错)
|
2026-07-05 17:03:36 +08:00
|
|
|
|
if avatar:
|
2026-07-07 21:52:11 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from app.services.avatar_service import sync_employee_avatar
|
|
|
|
|
|
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
2026-07-05 17:03:36 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 创建新记录
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
corp_id=settings.wecom_corp_id,
|
|
|
|
|
|
employee_id=employee_id,
|
|
|
|
|
|
name=employee_name,
|
|
|
|
|
|
department=department,
|
|
|
|
|
|
position=position,
|
|
|
|
|
|
avatar=avatar,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
logger.info(f"创建员工记录(含头像): employee_id={employee_id}")
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
|
|
|
|
|
|
# 不阻塞登录流程,继续执行
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# 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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# GET /api/h5/oauth/sns-callback — 企微 OAuth2 静默授权回调(302 重定向版)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/h5/oauth/sns-callback")
|
|
|
|
|
|
async def oauth_sns_callback(
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
code: str = Query(..., description="企微 OAuth2 授权码"),
|
|
|
|
|
|
state: Optional[str] = Query(None, description="透传参数(保留兼容,未使用)"),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
redis_client: Optional[aioredis.Redis] = Depends(dep_redis),
|
|
|
|
|
|
wecom_service: WecomService = Depends(dep_wecom_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""企微 OAuth2 静默授权回调(snsapi_base → 302 带 ?token=)。
|
|
|
|
|
|
|
|
|
|
|
|
适用于 snsapi_base 静默授权:企微回调到此端点并携带 code,
|
|
|
|
|
|
后端用 code 换取员工身份 → 生成 Bearer Token → 302 重定向到
|
|
|
|
|
|
H5 前端页面,并在 URL 上附带 ?token=,供前端镜像到 localStorage。
|
|
|
|
|
|
|
|
|
|
|
|
仅生产环境强制 UA 校验(与 _require_wework_ua 一致,使用 env_gating)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
code: 企微授权码
|
|
|
|
|
|
state: 透传参数(未使用,保留兼容)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
redis_client: 共享 Redis 客户端(DI 注入)
|
|
|
|
|
|
wecom_service: 共享企微服务(DI 注入)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
RedirectResponse -> {scheme}://{host}/itdesk/?token={token}
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 仅生产环境强制 UA 校验
|
|
|
|
|
|
if is_production():
|
|
|
|
|
|
ua = request.headers.get("user-agent", "")
|
|
|
|
|
|
if not _WEWORK_UA_RE.search(ua):
|
|
|
|
|
|
raise AppException(4003, "请在企业微信中访问此服务")
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 用 code 换取员工身份
|
|
|
|
|
|
user_info = await wecom_service.get_oauth_user_info(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", "")
|
|
|
|
|
|
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 as e:
|
|
|
|
|
|
logger.warning(f"获取员工详细信息失败: employee_id={employee_id}, error={e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 落库 / 更新员工信息(含头像)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.models.employee import Employee
|
|
|
|
|
|
|
|
|
|
|
|
stmt = select(Employee).where(
|
|
|
|
|
|
Employee.employee_id == employee_id,
|
|
|
|
|
|
Employee.corp_id == settings.wecom_corp_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
employee = result.scalars().first()
|
|
|
|
|
|
if employee:
|
|
|
|
|
|
employee.name = employee_name
|
|
|
|
|
|
employee.department = department
|
|
|
|
|
|
employee.position = position
|
|
|
|
|
|
if avatar:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.avatar_service import sync_employee_avatar
|
|
|
|
|
|
await sync_employee_avatar(db, redis_client, employee_id, avatar)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"同步员工头像失败(不阻塞登录): employee_id={employee_id}, error={e}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
corp_id=settings.wecom_corp_id,
|
|
|
|
|
|
employee_id=employee_id,
|
|
|
|
|
|
name=employee_name,
|
|
|
|
|
|
department=department,
|
|
|
|
|
|
position=position,
|
|
|
|
|
|
avatar=avatar,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"保存员工信息到数据库失败: employee_id={employee_id}, error={e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 生成 Bearer Token 并写入 Redis
|
|
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
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 sns-callback 授权成功: employee_id={employee_id}, name={employee_name}")
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 302 重定向到 H5 前端页面,附带 ?token= 供前端镜像到 localStorage
|
|
|
|
|
|
from fastapi.responses import RedirectResponse
|
|
|
|
|
|
|
|
|
|
|
|
host = request.headers.get("host", "")
|
|
|
|
|
|
scheme = "https"
|
|
|
|
|
|
landing = "/itdesk/"
|
|
|
|
|
|
redirect_url = f"{scheme}://{host}{landing}?token={token}"
|
|
|
|
|
|
return RedirectResponse(url=redirect_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""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()
|
|
|
|
|
|
|
2026-07-09 11:47:16 +08:00
|
|
|
|
# 3. 广播用户消息给坐席端(员工端靠乐观更新已显示自己消息)
|
|
|
|
|
|
# 为什么:坐席端需实时看到员工新消息,仅依赖 3 秒轮询会有延迟
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as ws_err:
|
|
|
|
|
|
# WS 广播失败不阻塞消息存储,只记录 warning
|
2026-07-09 11:47:16 +08:00
|
|
|
|
logger.warning(f"WS 广播用户消息失败(消息已存储): {ws_err}")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
2026-07-09 11:47:16 +08:00
|
|
|
|
# 4. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
|
|
|
|
|
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
|
|
|
|
|
# 发送接口瞬时返回,前端不再卡"发送中"
|
|
|
|
|
|
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
|
|
|
|
|
asyncio.create_task(
|
|
|
|
|
|
process_h5_ai_reply(
|
|
|
|
|
|
conversation_id=str(conversation.id),
|
|
|
|
|
|
employee_id=employee_id,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
dify_conversation_id=conversation.dify_conversation_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 立即返回用户消息(AI 回复经 WS 异步推送,不在此同步返回)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
user_msg_data = MessageResponse.model_validate(message).model_dump()
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data={
|
|
|
|
|
|
"user_message": user_msg_data,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
"ai_reply": None,
|
|
|
|
|
|
"is_guidance": False,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
"ai_reply_count": conversation.ai_substantive_reply_count,
|
|
|
|
|
|
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
|
|
|
|
|
"conversation_status": conversation.status,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# GET /api/h5/conversations/current/messages — 用户获取消息列表(历史消息)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
2026-07-07 21:52:11 +08:00
|
|
|
|
@router.get("/h5/conversations/current/messages")
|
|
|
|
|
|
async def h5_get_messages(
|
|
|
|
|
|
limit: int = Query(50, description="每页消息数量,默认50"),
|
|
|
|
|
|
before: Optional[str] = Query(None, description="获取此消息ID之前的消息(向上翻页)"),
|
|
|
|
|
|
employee_id: str = Depends(_get_current_employee),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""H5 用户获取消息列表(历史消息)。
|
|
|
|
|
|
|
|
|
|
|
|
前端在进入会话或切换会话时调用,获取完整的消息历史记录。
|
|
|
|
|
|
支持分页向上翻页(通过 before 参数)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
limit: 每页消息数量(默认50)
|
|
|
|
|
|
before: 消息ID,获取此消息之前的消息(向上翻页)
|
|
|
|
|
|
employee_id: 员工企微 UserID
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 统一响应格式,包含消息列表和 has_more 标志
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 查找当前会话
|
|
|
|
|
|
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.desc()).limit(limit)
|
|
|
|
|
|
|
|
|
|
|
|
# 如果指定了 before,获取此消息之前的消息
|
|
|
|
|
|
if before:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from uuid import UUID as UUIDType
|
|
|
|
|
|
UUIDType(before) # 仅校验格式
|
|
|
|
|
|
|
|
|
|
|
|
# 查询 before 消息的创建时间
|
|
|
|
|
|
before_stmt = select(Message.created_at).where(
|
|
|
|
|
|
Message.id == str(before)
|
|
|
|
|
|
)
|
|
|
|
|
|
before_result = await db.execute(before_stmt)
|
|
|
|
|
|
before_time = before_result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
if before_time:
|
|
|
|
|
|
msg_stmt = msg_stmt.where(Message.created_at < before_time)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass # 无效的UUID格式,忽略 before 参数
|
|
|
|
|
|
|
|
|
|
|
|
msg_result = await db.execute(msg_stmt)
|
|
|
|
|
|
messages = list(msg_result.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
# 反转顺序(按时间正序返回)
|
|
|
|
|
|
messages.reverse()
|
|
|
|
|
|
|
|
|
|
|
|
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
|
|
|
|
|
# 判断是否还有更多:查询的消息数是否等于 limit
|
|
|
|
|
|
has_more = len(messages) == limit
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={"items": items, "has_more": has_more})
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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:
|
2026-06-16 10:07:42 +08:00
|
|
|
|
# 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型不匹配)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from uuid import UUID as UUIDType
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-06-16 10:07:42 +08:00
|
|
|
|
UUIDType(after_message_id) # 仅校验
|
2026-06-14 16:49:18 +08:00
|
|
|
|
except ValueError:
|
2026-06-16 10:07:42 +08:00
|
|
|
|
# 无效的UUID格式,返回空列表
|
2026-06-14 16:49:18 +08:00
|
|
|
|
items = []
|
|
|
|
|
|
return success_response(data={"items": items, "has_more": False})
|
|
|
|
|
|
|
2026-06-16 10:07:42 +08:00
|
|
|
|
# 必须用字符串比较,Message.id 在 DB 里是 String(36)/VARCHAR,
|
|
|
|
|
|
# 传 UUID 对象会被 SQLAlchemy 推断成 UUID 类型 → PostgreSQL 报
|
|
|
|
|
|
# "operator does not exist: character varying = uuid"
|
2026-06-14 16:49:18 +08:00
|
|
|
|
after_stmt = select(Message.created_at).where(
|
2026-06-16 10:07:42 +08:00
|
|
|
|
Message.id == str(after_message_id)
|
2026-06-14 16:49:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
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,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
2026-06-14 16:49:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席
|
|
|
|
|
|
if conversation.ai_substantive_reply_count < 3:
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
1003,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
2026-06-14 16:49:18 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 更新员工姓名
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# 5. 自动分配空闲坐席
|
|
|
|
|
|
from app.services.session_service import SessionService
|
|
|
|
|
|
from app.services.ws_manager import manager as ws_manager
|
|
|
|
|
|
from app.models.agent import Agent
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
assigned_agent_id: Optional[str] = None
|
|
|
|
|
|
assign_result: str = "queued"
|
|
|
|
|
|
|
|
|
|
|
|
# 查找在线且未满负荷的坐席(按当前负载升序,取第一个)
|
|
|
|
|
|
stmt = select(Agent).where(
|
|
|
|
|
|
Agent.status == "online",
|
|
|
|
|
|
Agent.current_load < Agent.max_load
|
|
|
|
|
|
).order_by(Agent.current_load).limit(1)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
available_agent = result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if available_agent:
|
|
|
|
|
|
# 找到空闲坐席,分配给该会话
|
|
|
|
|
|
try:
|
|
|
|
|
|
session_service = SessionService(db, wecom_service)
|
|
|
|
|
|
await session_service.assign_agent(conversation.id, available_agent.user_id)
|
|
|
|
|
|
assigned_agent_id = available_agent.user_id
|
|
|
|
|
|
assign_result = "assigned"
|
|
|
|
|
|
logger.info(f"自动分配坐席: conv_id={conversation.id}, agent={assigned_agent_id}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"自动分配坐席失败: {e}")
|
|
|
|
|
|
assign_result = "assign_failed"
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 无空闲坐席,进入排队(会话状态保持 queued,由 AI 未命中时自动处理)
|
|
|
|
|
|
assign_result = "queued"
|
|
|
|
|
|
logger.info(f"无空闲坐席,会话进入排队: conv_id={conversation.id}")
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 广播 new_conversation 事件通知所有坐席
|
|
|
|
|
|
try:
|
|
|
|
|
|
await ws_manager.broadcast({
|
|
|
|
|
|
"type": "new_conversation",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"conversation_id": str(conversation.id),
|
|
|
|
|
|
"employee_id": employee_id,
|
|
|
|
|
|
"employee_name": employee_name or "未知用户",
|
|
|
|
|
|
"urgency_score": conversation.urgency_score,
|
|
|
|
|
|
"hand_raise": True,
|
|
|
|
|
|
"assigned_agent_id": assigned_agent_id,
|
|
|
|
|
|
"assign_result": assign_result,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}")
|
|
|
|
|
|
|
|
|
|
|
|
# 7. 返回会话信息和话术
|
2026-06-14 16:49:18 +08:00
|
|
|
|
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data={
|
|
|
|
|
|
"conversation": conv_data,
|
|
|
|
|
|
"funny_phrase": phrase,
|
2026-07-07 21:52:11 +08:00
|
|
|
|
"assign_result": assign_result,
|
|
|
|
|
|
"assigned_agent_id": assigned_agent_id,
|
2026-06-14 16:49:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 21:52:11 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# POST /api/h5/conversations/current/call-agent — 摇人按钮触发转人工
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/h5/conversations/current/call-agent")
|
|
|
|
|
|
async def call_agent(
|
|
|
|
|
|
body: ShakeRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
wecom_service: Optional[WecomService] = Depends(dep_wecom_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""摇人按钮 - 呼叫坐席。
|
|
|
|
|
|
|
|
|
|
|
|
用户点击摇人按钮后,触发转人工流程:
|
|
|
|
|
|
1. 查找当前会话
|
|
|
|
|
|
2. 校验AI回复次数 >= 3(与shake一致)
|
|
|
|
|
|
3. 将会话状态改为 queued(排队中)
|
|
|
|
|
|
4. 尝试分配空闲坐席
|
|
|
|
|
|
5. 发送系统消息通知用户
|
|
|
|
|
|
6. 通过企微消息通知坐席
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 呼叫坐席请求体(包含 employee_id 和 employee_name)
|
|
|
|
|
|
db: 数据库会话
|
|
|
|
|
|
wecom_service: 共享企微服务(DI 注入)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 包含会话信息和排队状态
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.services.session_service import SessionService
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
code=1003,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
2026-07-07 21:52:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
|
|
|
|
|
if conversation.ai_substantive_reply_count < 3:
|
|
|
|
|
|
raise AppException(
|
|
|
|
|
|
code=1003,
|
2026-07-09 11:47:16 +08:00
|
|
|
|
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
2026-07-07 21:52:11 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 更新员工姓名
|
|
|
|
|
|
if employee_name and not conversation.employee_name:
|
|
|
|
|
|
conversation.employee_name = employee_name
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 将会话状态改为 queued(排队中)
|
|
|
|
|
|
conversation.status = "queued"
|
|
|
|
|
|
conversation.last_message_at = datetime.now()
|
|
|
|
|
|
conversation.updated_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
# 设置紧急度加分
|
|
|
|
|
|
tags = dict(conversation.tags) if conversation.tags else {}
|
|
|
|
|
|
tags["user_called_agent"] = True # 标记用户主动呼叫
|
|
|
|
|
|
conversation.tags = tags
|
|
|
|
|
|
db.add(conversation)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 尝试分配空闲坐席
|
|
|
|
|
|
session_service = SessionService(db)
|
|
|
|
|
|
assigned_agent = await session_service.auto_assign_agent(conversation.id)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 获取趣味话术
|
|
|
|
|
|
funny_phrase_service = FunnyPhraseService(db)
|
|
|
|
|
|
is_vip = conversation.is_vip
|
|
|
|
|
|
phrase = await funny_phrase_service.get_phrase("transfer", is_vip=is_vip)
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 创建系统消息
|
|
|
|
|
|
system_content = phrase
|
|
|
|
|
|
if assigned_agent:
|
|
|
|
|
|
system_content = f"{phrase}\n\n为您服务的是:{assigned_agent.name}"
|
|
|
|
|
|
conversation.status = "serving"
|
|
|
|
|
|
conversation.assigned_agent_id = assigned_agent.user_id
|
|
|
|
|
|
|
|
|
|
|
|
system_msg = Message(
|
|
|
|
|
|
conversation_id=conversation.id,
|
|
|
|
|
|
sender_type="system",
|
|
|
|
|
|
sender_id="system",
|
|
|
|
|
|
sender_name="系统",
|
|
|
|
|
|
content=system_content,
|
|
|
|
|
|
msg_type="system",
|
|
|
|
|
|
is_read=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(system_msg)
|
|
|
|
|
|
|
|
|
|
|
|
# 7. 通过企微 API 发送话术给员工(使用共享 WecomService)
|
|
|
|
|
|
if wecom_service:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await wecom_service.send_text_message(employee_id, system_content)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"呼叫坐席话术推送失败(不阻塞流程): {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 8. 如果分配了坐席,通知坐席有新会话
|
|
|
|
|
|
if assigned_agent and wecom_service:
|
|
|
|
|
|
try:
|
|
|
|
|
|
notify_phrase = f"新会话:{employee_name} 呼叫人工服务,请及时接单"
|
|
|
|
|
|
# 获取坐席的userid并发送通知(需要坐席绑定企微)
|
|
|
|
|
|
# 此处简化处理,仅记录日志
|
|
|
|
|
|
logger.info(f"分配坐席: agent_id={assigned_agent.id}, employee_id={employee_id}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"坐席通知失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"呼叫坐席: employee_id={employee_id}, conv_id={conversation.id}, agent_id={assigned_agent.id if assigned_agent else 'None'}")
|
|
|
|
|
|
|
|
|
|
|
|
# 9. 返回结果
|
|
|
|
|
|
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
|
|
|
|
|
return success_response(
|
|
|
|
|
|
data={
|
|
|
|
|
|
"conversation": conv_data,
|
|
|
|
|
|
"status": conversation.status,
|
|
|
|
|
|
"queue_position": 1 if not assigned_agent else None,
|
|
|
|
|
|
"estimated_wait_seconds": 30 if not assigned_agent else 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# GET /api/h5/conversations/current/queue-status — 查询排队状态
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/h5/conversations/current/queue-status")
|
|
|
|
|
|
async def get_queue_status(
|
|
|
|
|
|
employee_id: str = Query(..., description="员工ID"),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""查询当前排队状态。
|
|
|
|
|
|
|
|
|
|
|
|
返回当前会话的排队位置和预计等待时间。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
employee_id: 员工ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 排队状态信息
|
|
|
|
|
|
"""
|
|
|
|
|
|
from sqlalchemy import select, func
|
|
|
|
|
|
from app.models.conversation import Conversation
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 查找该员工的排队会话
|
|
|
|
|
|
stmt = select(Conversation).where(
|
|
|
|
|
|
Conversation.employee_id == employee_id,
|
|
|
|
|
|
Conversation.status == "queued",
|
|
|
|
|
|
).order_by(Conversation.created_at.asc())
|
|
|
|
|
|
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
conversation = result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not conversation:
|
|
|
|
|
|
# 不在排队中,可能是已分配或无会话
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"in_queue": False,
|
|
|
|
|
|
"status": None,
|
|
|
|
|
|
"queue_position": None,
|
|
|
|
|
|
"estimated_wait_seconds": 0,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 计算排队位置(按创建时间排序)
|
|
|
|
|
|
count_stmt = select(func.count(Conversation.id)).where(
|
|
|
|
|
|
Conversation.status == "queued",
|
|
|
|
|
|
Conversation.created_at < conversation.created_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
count_result = await db.execute(count_stmt)
|
|
|
|
|
|
queue_position = count_result.scalar() or 0
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 计算预计等待时间(基于平均处理时长5分钟)
|
|
|
|
|
|
estimated_wait_seconds = queue_position * 300 # 5分钟/人
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"in_queue": True,
|
|
|
|
|
|
"status": conversation.status,
|
|
|
|
|
|
"queue_position": queue_position + 1,
|
|
|
|
|
|
"estimated_wait_seconds": estimated_wait_seconds,
|
|
|
|
|
|
"conversation_id": str(conversation.id),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# POST /api/h5/conversations/current/cancel-queue — 取消排队
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/h5/conversations/current/cancel-queue")
|
|
|
|
|
|
async def cancel_queue(
|
|
|
|
|
|
body: ShakeRequest,
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""取消排队。
|
|
|
|
|
|
|
|
|
|
|
|
用户主动取消排队,释放排队位置。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body: 包含 employee_id
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 操作结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
employee_id = body.employee_id
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 查找排队中的会话
|
|
|
|
|
|
stmt = select(Conversation).where(
|
|
|
|
|
|
Conversation.employee_id == employee_id,
|
|
|
|
|
|
Conversation.status == "queued",
|
|
|
|
|
|
)
|
|
|
|
|
|
result = await db.execute(stmt)
|
|
|
|
|
|
conversation = result.scalars().first()
|
|
|
|
|
|
|
|
|
|
|
|
if not conversation:
|
|
|
|
|
|
raise AppException(code=1004, message="您当前不在排队中")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 将会话状态改回 ai_handling
|
|
|
|
|
|
conversation.status = "ai_handling"
|
|
|
|
|
|
conversation.updated_at = datetime.now()
|
|
|
|
|
|
|
|
|
|
|
|
# 移除用户主动呼叫标记
|
|
|
|
|
|
tags = dict(conversation.tags) if conversation.tags else {}
|
|
|
|
|
|
tags.pop("user_called_agent", None)
|
|
|
|
|
|
conversation.tags = tags
|
|
|
|
|
|
|
|
|
|
|
|
db.add(conversation)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"取消排队: employee_id={employee_id}, conv_id={conversation.id}")
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={
|
|
|
|
|
|
"message": "已取消排队,会话将继续由AI服务",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 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})
|