WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
+507
-16
@@ -44,6 +44,7 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.utils.env_gating import is_production
|
||||
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
|
||||
@@ -83,18 +84,18 @@ _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)跳过检测,方便调试。
|
||||
仅生产环境强制校验(env_gating.is_production()):
|
||||
非企微环境的 OAuth2 请求直接拒绝。
|
||||
本地开发 / dev / test 环境跳过检测,方便调试。
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 对象,用于读取 User-Agent 和 Host
|
||||
request: FastAPI Request 对象,用于读取 User-Agent
|
||||
|
||||
Raises:
|
||||
AppException: 非企微环境时抛出 403 错误
|
||||
AppException: 非企微环境且处于生产环境时抛出 4003 错误
|
||||
"""
|
||||
# 本地开发跳过检测
|
||||
host = request.headers.get("host", "")
|
||||
if host.startswith("localhost") or host.startswith("127.0.0.1"):
|
||||
# 仅生产环境强制校验;非生产环境(dev/test/本地)一律放行
|
||||
if not is_production():
|
||||
return
|
||||
|
||||
ua = request.headers.get("user-agent", "")
|
||||
@@ -253,11 +254,11 @@ async def get_oauth_authorize_url(
|
||||
elif request_host:
|
||||
# 从 Host 头构造回调地址(支持 http 和 https)
|
||||
scheme = "https" # 企微H5应用通常使用 https
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itportal/", safe="")
|
||||
encoded_redirect = quote(f"{scheme}://{request_host}/itdesk/", 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="")
|
||||
encoded_redirect = quote(f"{default_origin}/itdesk/", safe="")
|
||||
|
||||
# 构造企微OAuth2静默授权URL(snsapi_base:用户无感知)
|
||||
# 企业微信 OAuth2 地址(注意是 open.work.weixin.qq.com)
|
||||
@@ -362,11 +363,14 @@ async def oauth_callback(
|
||||
employee.name = employee_name
|
||||
employee.department = department
|
||||
employee.position = position
|
||||
# 【FE-UA-005 优化】每次登录强制更新头像URL,确保获取最新头像
|
||||
# 【FE-UA-005 优化】每次登录强制更新头像URL + 清缓存(统一走 avatar_service)
|
||||
# 头像更新失败不阻塞登录(sync_employee_avatar 内部已容错)
|
||||
if avatar:
|
||||
employee.avatar = avatar
|
||||
employee.avatar_updated_at = datetime.utcnow()
|
||||
logger.info(f"更新员工头像: employee_id={employee_id}, avatar={avatar[:50] if avatar else '(空)'}...")
|
||||
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(
|
||||
@@ -437,6 +441,137 @@ async def oauth_callback(
|
||||
raise AppException(2007, f"OAuth2授权失败: {e}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/mock-login — Mock 登录(测试阶段,跳过 OAuth2)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -856,8 +991,75 @@ async def h5_send_message(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
# GET /api/h5/conversations/current/messages — 用户获取消息列表(历史消息)
|
||||
# --------------------------------------------------------------------------
|
||||
@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})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/conversations/current/messages/poll — 用户轮询新消息
|
||||
@@ -1019,18 +1221,307 @@ async def shake(
|
||||
except Exception as e:
|
||||
logger.warning(f"举手话术推送失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}")
|
||||
# 5. 自动分配空闲坐席
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.models.agent import Agent
|
||||
|
||||
# 5. 返回会话信息和话术
|
||||
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. 返回会话信息和话术
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(
|
||||
data={
|
||||
"conversation": conv_data,
|
||||
"funny_phrase": phrase,
|
||||
"assign_result": assign_result,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 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,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
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服务",
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/h5/approval-links — 获取审批流程链接
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user