chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交 (5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。 一、docs 结构整改(整改 #14) 根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入, 随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录 树并存于 docs/,共 791 文件、双分类体系冲突。 修复动作: - b2 同名异主题文件改名迁移保全 9 个 - C 类 39 个孤立文件按主题正确归类 - A/B1 类 222 个重复文件删除(新结构已有内容副本) - 9 个旧独有空目录删除 - 270 处内部引用按 verified 映射改写 - 整改记录 #14 登记于 04-运维文档/部署运维 结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。 残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。 二、compose 双目录对齐(消除踩坑 A) - docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist 改为 src/frontend-*/dist(h5 / agent / admin / terminal) - docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/ - 效果:本地 docker compose up 不再把根目录 stale dist 挂回, 与线上一致,分叉隐患消除(已 docker compose config 校验通过) 防复发铁律: - 重构须提交;仓库修复须 git stash -u 或先 commit - 新结构须 git add 并提交,避免再次 untracked 复活 - H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — API 包初始化
|
||||
# =============================================================================
|
||||
# 说明:将 api/ 目录标记为 Python 包
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,215 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 坐席备注 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的备注管理接口,包括:
|
||||
# 1. GET /api/agent-notes/{employee_id} — 获取员工的所有备注
|
||||
# 2. POST /api/agent-notes — 添加备注
|
||||
# 3. PUT /api/agent-notes/{id} — 更新备注
|
||||
# 4. DELETE /api/agent-notes/{id} — 删除备注
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent_note import AgentNote
|
||||
from app.models.conversation import Conversation
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/agent-notes/{employee_id} — 获取员工的所有备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/agent-notes/{employee_id}")
|
||||
async def list_agent_notes(
|
||||
employee_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取员工的所有备注。
|
||||
|
||||
通过员工ID查找其所有会话的备注。
|
||||
用于坐席端用户信息面板展示。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含备注列表
|
||||
"""
|
||||
# 查找该员工所有会话的备注
|
||||
stmt = (
|
||||
select(AgentNote)
|
||||
.join(Conversation, AgentNote.conversation_id == Conversation.id)
|
||||
.where(Conversation.employee_id == employee_id)
|
||||
.order_by(AgentNote.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
notes = list(result.scalars().all())
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
for note in notes
|
||||
]
|
||||
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/agent-notes — 添加备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/agent-notes")
|
||||
async def create_agent_note(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""添加坐席备注。
|
||||
|
||||
Args:
|
||||
body: 备注请求体(包含 conversation_id, agent_id, content)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的备注
|
||||
"""
|
||||
conversation_id = body.get("conversation_id", "")
|
||||
agent_id = body.get("agent_id", "")
|
||||
content = body.get("content", "")
|
||||
|
||||
if not conversation_id or not agent_id or not content:
|
||||
raise AppException(1001, "缺少必要参数: conversation_id, agent_id, content")
|
||||
|
||||
# 校验会话存在
|
||||
try:
|
||||
conv_uuid = UUID(conversation_id)
|
||||
except ValueError:
|
||||
raise AppException(1001, "无效的 conversation_id 格式")
|
||||
|
||||
conv_stmt = select(Conversation).where(Conversation.id == conv_uuid)
|
||||
conv_result = await db.execute(conv_stmt)
|
||||
if not conv_result.scalars().first():
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 创建备注
|
||||
note = AgentNote(
|
||||
conversation_id=conv_uuid,
|
||||
agent_id=agent_id,
|
||||
content=content,
|
||||
)
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"添加坐席备注: conv_id={conversation_id}, agent={agent_id}")
|
||||
|
||||
note_data = {
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
|
||||
return success_response(data=note_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/agent-notes/{id} — 更新备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/agent-notes/{note_id}")
|
||||
async def update_agent_note(
|
||||
note_id: UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新坐席备注。
|
||||
|
||||
Args:
|
||||
note_id: 备注ID
|
||||
body: 更新请求体(包含 content)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的备注
|
||||
"""
|
||||
# 查找备注
|
||||
stmt = select(AgentNote).where(AgentNote.id == note_id)
|
||||
result = await db.execute(stmt)
|
||||
note = result.scalars().first()
|
||||
|
||||
if not note:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 更新内容
|
||||
content = body.get("content")
|
||||
if content is not None:
|
||||
note.content = content
|
||||
note.updated_at = datetime.now()
|
||||
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"更新坐席备注: id={note_id}")
|
||||
|
||||
note_data = {
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
|
||||
return success_response(data=note_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DELETE /api/agent-notes/{id} — 删除备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/agent-notes/{note_id}")
|
||||
async def delete_agent_note(
|
||||
note_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除坐席备注。
|
||||
|
||||
Args:
|
||||
note_id: 备注ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 查找备注
|
||||
stmt = select(AgentNote).where(AgentNote.id == note_id)
|
||||
result = await db.execute(stmt)
|
||||
note = result.scalars().first()
|
||||
|
||||
if not note:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 物理删除
|
||||
await db.delete(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"删除坐席备注: id={note_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
@@ -0,0 +1,315 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 坐席管理 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的管理接口,包括:
|
||||
# 1. POST /api/agents/login — 坐席登录(用户名密码,返回JWT token)
|
||||
# 2. GET /api/agents/me — 获取当前坐席信息
|
||||
# 3. PUT /api/agents/me/status — 更新坐席状态(online/busy/offline)
|
||||
# 4. GET /api/agents — 获取坐席列表(用于转接选择)
|
||||
# 坐席认证使用 JWT,token 存 Redis(TTL 8小时)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
# JWT 简化版:使用随机 token 存 Redis,TTL 8 小时
|
||||
# 为什么不用标准 JWT:第一步简化实现,token 存 Redis 更容易实现登出和状态管理
|
||||
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
|
||||
|
||||
|
||||
def _get_redis() -> aioredis.Redis:
|
||||
"""获取 Redis 客户端。"""
|
||||
return aioredis.from_url(settings.redis_url)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 坐席认证依赖
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_current_agent(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Agent:
|
||||
"""从请求头中提取坐席身份(认证依赖)。
|
||||
|
||||
从 Authorization 头提取 token,从 Redis 查找对应的坐席信息。
|
||||
|
||||
Args:
|
||||
authorization: 请求头中的 Authorization 字段(格式:Bearer token)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Agent: 当前坐席对象
|
||||
|
||||
Raises:
|
||||
AppException: 未授权(token 缺失、无效或过期)
|
||||
"""
|
||||
if not authorization:
|
||||
raise ERR_UNAUTHORIZED
|
||||
|
||||
# 提取 token(支持 "Bearer xxx" 格式)
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
|
||||
if not token:
|
||||
raise ERR_UNAUTHORIZED
|
||||
|
||||
# 从 Redis 查找坐席ID
|
||||
redis_client = _get_redis()
|
||||
try:
|
||||
agent_user_id = await redis_client.get(f"agent:token:{token}")
|
||||
if not agent_user_id:
|
||||
raise ERR_UNAUTHORIZED
|
||||
|
||||
# 从数据库查找坐席
|
||||
# agent_user_id 可能是 bytes(Redis 返回)或 str
|
||||
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
|
||||
stmt = select(Agent).where(Agent.user_id == uid)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
raise ERR_UNAUTHORIZED
|
||||
|
||||
return agent
|
||||
|
||||
except AppException:
|
||||
# 业务异常直接抛出(如 ERR_UNAUTHORIZED)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Redis 连接失败等底层异常
|
||||
logger.error(f"Redis 读取失败: {e}")
|
||||
raise ERR_UNAUTHORIZED
|
||||
finally:
|
||||
try:
|
||||
await redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/agents/login — 坐席登录
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/agents/login")
|
||||
async def agent_login(
|
||||
body: AgentLogin,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席登录。
|
||||
|
||||
第一步使用简单的用户名密码登录。
|
||||
登录成功后生成 token 存入 Redis(TTL 8小时)。
|
||||
|
||||
流程:
|
||||
1. 查找坐席记录(按 user_id),不存在则自动创建
|
||||
2. 生成随机 token
|
||||
3. token 存 Redis(key: agent:token:{token}, value: user_id)
|
||||
4. 更新坐席状态为 online
|
||||
5. 返回坐席信息和 token
|
||||
|
||||
Args:
|
||||
body: 登录请求体(包含 user_id 和 name)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含坐席信息和 token
|
||||
"""
|
||||
try:
|
||||
# 0. 企微通讯录身份验证(防止任意 user_id 冒充坐席)
|
||||
# 调用企微API校验 user_id 是否存在于通讯录中
|
||||
# 如果企微API不可达(网络问题),降级为仅警告,不阻断登录
|
||||
wecom_verified = False
|
||||
try:
|
||||
redis_client_verify = _get_redis()
|
||||
try:
|
||||
wecom_service = WecomService(redis_client_verify)
|
||||
user_info = await wecom_service.get_user_info(body.user_id)
|
||||
# 验证通过:用户存在于企微通讯录
|
||||
wecom_verified = True
|
||||
# 用企微返回的真实姓名覆盖前端传入的姓名(防止冒用他人身份)
|
||||
real_name = user_info.get("name", "")
|
||||
if real_name:
|
||||
body.name = real_name
|
||||
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
|
||||
finally:
|
||||
try:
|
||||
await redis_client_verify.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await wecom_service.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as wecom_err:
|
||||
# 企微API不可达时降级处理:记录警告但允许登录
|
||||
# 原因:网络故障不应阻断坐席工作,登录后仍可通过token认证
|
||||
logger.warning(
|
||||
f"企微通讯录验证失败(降级放行): user_id={body.user_id}, "
|
||||
f"error={wecom_err}"
|
||||
)
|
||||
|
||||
# 1. 查找或创建坐席记录
|
||||
stmt = select(Agent).where(Agent.user_id == body.user_id)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
# 首次登录,创建坐席记录
|
||||
agent = Agent(
|
||||
user_id=body.user_id,
|
||||
name=body.name,
|
||||
status="online",
|
||||
current_load=0,
|
||||
max_load=5,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"新坐席注册: user_id={body.user_id}, name={body.name}")
|
||||
else:
|
||||
# 更新坐席名称(可能改名了)
|
||||
agent.name = body.name
|
||||
agent.status = "online"
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||
|
||||
# 2. 生成随机 token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# 3. token 存 Redis(如果 Redis 不可用,仍允许登录但 token 不持久化)
|
||||
redis_client = _get_redis()
|
||||
try:
|
||||
await redis_client.setex(
|
||||
f"agent:token:{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
body.user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
# Redis 连接失败时:不阻断登录,但 token 无法持久化(页面刷新后需重新登录)
|
||||
logger.warning(f"Redis 写入失败(token 不会持久化): {e}")
|
||||
finally:
|
||||
try:
|
||||
await redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 返回坐席信息和 token
|
||||
agent_data = AgentResponse.model_validate(agent).model_dump()
|
||||
agent_data["token"] = token
|
||||
|
||||
return success_response(data=agent_data)
|
||||
|
||||
except AppException:
|
||||
# 业务异常直接抛出
|
||||
raise
|
||||
except Exception as e:
|
||||
# 未预期的异常:记录日志,返回友好错误
|
||||
logger.error(f"登录异常: {e}", exc_info=True)
|
||||
raise AppException(1005, f"登录失败: {str(e)}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/agents/me — 获取当前坐席信息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/agents/me")
|
||||
async def get_agent_me(
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""获取当前坐席信息。
|
||||
|
||||
需要在请求头中携带有效的 token。
|
||||
|
||||
Args:
|
||||
agent: 当前坐席(通过认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含坐席信息
|
||||
"""
|
||||
agent_data = AgentResponse.model_validate(agent).model_dump()
|
||||
return success_response(data=agent_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/agents/me/status — 更新坐席状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/agents/me/status")
|
||||
async def update_agent_status(
|
||||
body: AgentStatusUpdate,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新坐席状态。
|
||||
|
||||
坐席可以切换为 online/busy/offline。
|
||||
- online: 在线,可以接收新会话
|
||||
- busy: 忙碌,不接收新会话但继续处理已有的
|
||||
- offline: 离线,不接收任何会话
|
||||
|
||||
Args:
|
||||
body: 状态更新请求体
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的坐席信息
|
||||
"""
|
||||
agent.status = body.status
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"坐席状态更新: agent={agent.user_id}, status={body.status}")
|
||||
|
||||
agent_data = AgentResponse.model_validate(agent).model_dump()
|
||||
return success_response(data=agent_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/agents — 获取坐席列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/agents")
|
||||
async def list_agents(
|
||||
status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取坐席列表。
|
||||
|
||||
用于转接选择时展示可用的坐席列表。
|
||||
|
||||
Args:
|
||||
status: 按状态过滤(可选)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含坐席列表
|
||||
"""
|
||||
stmt = select(Agent).order_by(Agent.name)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(Agent.status == status)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
agents = list(result.scalars().all())
|
||||
|
||||
items = [AgentResponse.model_validate(a).model_dump() for a in agents]
|
||||
return success_response(data={"items": items})
|
||||
@@ -0,0 +1,514 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 会话管理 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的会话管理接口,包括:
|
||||
# 1. GET /api/conversations — 坐席获取会话列表(支持状态过滤、排序)
|
||||
# 2. GET /api/conversations/{id} — 获取会话详情
|
||||
# 3. POST /api/conversations/{id}/assign — 接单(坐席接入会话)
|
||||
# 4. POST /api/conversations/{id}/resolve — 结单
|
||||
# 5. POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||||
# 6. POST /api/conversations/{id}/todo — 代办/取消代办
|
||||
# 7. POST /api/conversations/{id}/transfer — 转接
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.conversation import (
|
||||
ConversationAssign,
|
||||
ConversationInvite,
|
||||
ConversationListResponse,
|
||||
ConversationResponse,
|
||||
ConversationStatusUpdate,
|
||||
)
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
# 坐席认证依赖(从 agents.py 导入)
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversations — 获取坐席会话列表(全局可见)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
|
||||
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
|
||||
page: int = Query(1, ge=1, description="页码(从1开始)"),
|
||||
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席获取会话列表(全局可见)。
|
||||
|
||||
返回所有活跃会话,每个会话增加字段:
|
||||
- is_mine: 是否为当前坐席的会话
|
||||
- assigned_agent_name: 分配的坐席姓名(其他坐席会话显示用)
|
||||
- can_grab: 是否可以接手(其他坐席已接单的会话为 True)
|
||||
|
||||
排序规则:紧急→举手→需介入→活跃→已结单。
|
||||
|
||||
Args:
|
||||
status: 按状态过滤(可选)
|
||||
agent_id: 按坐席ID过滤(可选)
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含会话列表和总数
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversations, total = await session_service.get_conversations(
|
||||
status=status,
|
||||
agent_id=agent_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# 批量查询所有涉及坐席的信息,避免 N+1 查询
|
||||
# 收集所有需要查询姓名的坐席ID(主责坐席 + 协作坐席)
|
||||
agent_ids_to_query = set()
|
||||
for conv in conversations:
|
||||
if conv.assigned_agent_id:
|
||||
agent_ids_to_query.add(conv.assigned_agent_id)
|
||||
for aid in (conv.collaborating_agent_ids or []):
|
||||
agent_ids_to_query.add(aid)
|
||||
|
||||
# 一次性查询所有相关坐席姓名
|
||||
agent_name_map: dict[str, str] = {}
|
||||
if agent_ids_to_query:
|
||||
stmt = select(Agent).where(Agent.user_id.in_(agent_ids_to_query))
|
||||
result = await db.execute(stmt)
|
||||
for agent in result.scalars().all():
|
||||
agent_name_map[agent.user_id] = agent.name
|
||||
|
||||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
|
||||
items = []
|
||||
for conv in conversations:
|
||||
conv_data = ConversationResponse.model_validate(conv).model_dump()
|
||||
# 是否为当前坐席的会话
|
||||
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
|
||||
# 坐席姓名(从批量查询结果中获取)
|
||||
conv_data["assigned_agent_name"] = agent_name_map.get(conv.assigned_agent_id) if conv.assigned_agent_id else None
|
||||
# 是否可以接手:其他坐席已接单(assigned 且不是自己的)
|
||||
conv_data["can_grab"] = (
|
||||
conv.assigned_agent_id is not None
|
||||
and conv.assigned_agent_id != current_agent.user_id
|
||||
and conv.status == "serving"
|
||||
)
|
||||
# ----- 多坐席协作扩展字段 -----
|
||||
# 协作坐席ID列表
|
||||
collab_ids = conv.collaborating_agent_ids or []
|
||||
conv_data["collaborating_agent_ids"] = collab_ids
|
||||
# 协作坐席姓名映射
|
||||
conv_data["collaborating_agent_names"] = {
|
||||
aid: agent_name_map.get(aid, "未知") for aid in collab_ids
|
||||
}
|
||||
# 是否为协作坐席(在协作列表中但不是主责坐席)
|
||||
conv_data["is_collaborator"] = (
|
||||
current_agent.user_id in collab_ids
|
||||
and conv.assigned_agent_id != current_agent.user_id
|
||||
)
|
||||
items.append(conv_data)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"items": items,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversations/{id} — 获取会话详情
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_conversation(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话详情。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含会话详情
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.get_conversation(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/assign — 坐席接单
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/assign")
|
||||
async def assign_conversation(
|
||||
conversation_id: UUID,
|
||||
body: ConversationAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席接单(接入会话)。
|
||||
|
||||
坐席点击"接单"按钮时调用,将会话状态从 queued 改为 serving。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 接单请求体(包含 agent_id)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
# 创建企微服务实例用于发送接入通知
|
||||
redis_client = None
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
from app.config import settings
|
||||
redis_client = aioredis.from_url(settings.redis_url)
|
||||
wecom_service = WecomService(redis_client)
|
||||
session_service = SessionService(db, wecom_service=wecom_service)
|
||||
except Exception:
|
||||
logger.warning("创建企微服务失败,接入通知将不发送")
|
||||
session_service = SessionService(db)
|
||||
|
||||
conversation = await session_service.assign_agent(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=body.agent_id,
|
||||
)
|
||||
|
||||
# 关闭企微服务连接
|
||||
if redis_client:
|
||||
try:
|
||||
await session_service.wecom_service.close()
|
||||
await redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/resolve — 结单
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/resolve")
|
||||
async def resolve_conversation(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""结单。
|
||||
|
||||
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
|
||||
|
||||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||||
协作坐席和其他坐席不能结单。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
|
||||
# 先查询会话,验证主责坐席身份
|
||||
from sqlalchemy import select as _select
|
||||
from app.models.conversation import Conversation as _Conversation
|
||||
stmt = _select(_Conversation).where(_Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
raise AppException(3003, "会话不存在")
|
||||
if conv.assigned_agent_id != current_agent.user_id:
|
||||
raise AppException(3027, "只有主责坐席才能结单")
|
||||
|
||||
conversation = await session_service.resolve_conversation(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/pin")
|
||||
async def toggle_pin(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换会话置顶状态。
|
||||
|
||||
每次调用切换当前状态:置顶→取消置顶,取消置顶→置顶。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.toggle_pin(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/todo — 代办/取消代办
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/todo")
|
||||
async def toggle_todo(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换会话代办状态。
|
||||
|
||||
每次调用切换当前状态:代办→取消代办,取消代办→代办。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.toggle_todo(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/transfer — 转接
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/transfer")
|
||||
async def transfer_conversation(
|
||||
conversation_id: UUID,
|
||||
body: ConversationAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""转接会话到另一个坐席。
|
||||
|
||||
第一步简化版:只更换坐席,不做转接通知。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 转接请求体(包含 target agent_id)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.transfer_conversation(
|
||||
conversation_id=conversation_id,
|
||||
target_agent_id=body.agent_id,
|
||||
)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/grab — 接手会话(抢单)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/grab")
|
||||
async def grab_conversation(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""接手其他坐席的会话(抢单)。
|
||||
|
||||
接手后原坐席自动释放,会话 assigned_agent_id 切换为当前坐席。
|
||||
验证规则:
|
||||
1. 会话必须已分配给其他坐席(不能接手自己的,不能接手未分配的)
|
||||
2. 当前坐席未满负荷
|
||||
3. 会话状态为 serving
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含接手后的会话信息
|
||||
"""
|
||||
# 1. 查找目标会话
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.get_conversation(conversation_id)
|
||||
|
||||
# 2. 校验:会话必须已分配给其他坐席
|
||||
if not conversation.assigned_agent_id:
|
||||
raise AppException(3011, "该会话尚未分配坐席,请使用接单功能")
|
||||
if conversation.assigned_agent_id == current_agent.user_id:
|
||||
raise AppException(3012, "不能接手自己的会话")
|
||||
if conversation.status == "resolved":
|
||||
raise AppException(3002, "会话已结单")
|
||||
if conversation.status != "serving":
|
||||
raise AppException(3013, f"只能接手服务中的会话,当前状态: {conversation.status}")
|
||||
|
||||
# 3. 校验当前坐席未满负荷
|
||||
# 刷新坐席数据(current_agent 可能是缓存的旧数据)
|
||||
stmt = select(Agent).where(Agent.user_id == current_agent.user_id)
|
||||
result = await db.execute(stmt)
|
||||
fresh_agent = result.scalars().first()
|
||||
if fresh_agent and fresh_agent.current_load >= fresh_agent.max_load:
|
||||
raise AppException(3005, "您已满负荷,无法接手更多会话")
|
||||
|
||||
# 4. 原坐席 current_load 减 1
|
||||
old_agent_id = conversation.assigned_agent_id
|
||||
stmt = select(Agent).where(Agent.user_id == old_agent_id)
|
||||
result = await db.execute(stmt)
|
||||
old_agent = result.scalars().first()
|
||||
if old_agent and old_agent.current_load > 0:
|
||||
old_agent.current_load -= 1
|
||||
db.add(old_agent)
|
||||
|
||||
# 5. 更新会话 assigned_agent_id 为当前坐席
|
||||
conversation.assigned_agent_id = current_agent.user_id
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
|
||||
# 6. 当前坐席 current_load 加 1
|
||||
if fresh_agent:
|
||||
fresh_agent.current_load += 1
|
||||
db.add(fresh_agent)
|
||||
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"会话接手: conv_id={conversation_id}, "
|
||||
f"from={old_agent_id} to={current_agent.user_id}"
|
||||
)
|
||||
|
||||
# 7. WS 广播 conversation_updated 事件(原坐席和当前坐席都能收到)
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_updated",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"status": conversation.status,
|
||||
"assigned_agent_id": conversation.assigned_agent_id,
|
||||
"old_agent_id": old_agent_id,
|
||||
"new_agent_id": current_agent.user_id,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败: {e}")
|
||||
|
||||
# 8. 返回接手成功的会话信息
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
response_data["is_mine"] = True
|
||||
response_data["assigned_agent_name"] = current_agent.name
|
||||
response_data["can_grab"] = False
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/invite")
|
||||
async def invite_collaborator(
|
||||
conversation_id: UUID,
|
||||
body: ConversationInvite,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席A邀请坐席B加入会话协作。
|
||||
|
||||
校验规则:
|
||||
1. 当前坐席必须是主责坐席或已加入的协作坐席
|
||||
2. 被邀请坐席存在且在线
|
||||
3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请)
|
||||
4. 会话必须为 serving(已结单的不能摇人)
|
||||
|
||||
副作用:
|
||||
- WebSocket 推送给被邀请坐席(collaborator_invited 定向通知)
|
||||
- WebSocket 广播给所有坐席(collaborator_joined 刷新列表)
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 邀请请求(含 agent_id)
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.invite_collaborator(
|
||||
conversation_id=conversation_id,
|
||||
inviter_agent_id=current_agent.user_id,
|
||||
invitee_agent_id=body.agent_id,
|
||||
)
|
||||
|
||||
# 构建响应
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
response_data["is_mine"] = conversation.assigned_agent_id == current_agent.user_id
|
||||
response_data["is_collaborator"] = False # 邀请人自己不是被邀请的协作坐席
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/leave — 退出协作
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/leave")
|
||||
async def leave_collaboration(
|
||||
conversation_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""坐席退出协作。
|
||||
|
||||
校验规则:
|
||||
1. 当前坐席必须在协作列表中
|
||||
2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单)
|
||||
|
||||
副作用:
|
||||
- WebSocket 广播给所有坐席(collaborator_left 刷新列表)
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
conversation = await session_service.leave_collaboration(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=current_agent.user_id,
|
||||
)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
@@ -0,0 +1,116 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 员工 API
|
||||
# =============================================================================
|
||||
# 说明:提供员工相关的管理接口
|
||||
# 接口列表:
|
||||
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
|
||||
# =============================================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.utils.response import success_response
|
||||
|
||||
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/employees", tags=["员工管理"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ItLevelUpdateRequest(BaseModel):
|
||||
"""IT技能等级更新请求 Schema。"""
|
||||
|
||||
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
|
||||
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
|
||||
|
||||
@field_validator("it_level")
|
||||
@classmethod
|
||||
def validate_it_level(cls, v: str) -> str:
|
||||
"""校验IT等级值是否合法。"""
|
||||
if v not in VALID_IT_LEVELS:
|
||||
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
|
||||
return v
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def validate_source(cls, v: str) -> str:
|
||||
"""校验等级来源值是否合法。"""
|
||||
if v not in VALID_LEVEL_SOURCES:
|
||||
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
|
||||
return v
|
||||
|
||||
|
||||
class ItLevelUpdateResponse(BaseModel):
|
||||
"""IT技能等级更新响应 Schema。"""
|
||||
|
||||
employee_id: str
|
||||
it_level: str
|
||||
it_level_source: str
|
||||
message: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 员工数据存储(IT 等级映射)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 简单的内存存储,key 为 employee_id,value 为 it_level
|
||||
MOCK_EMPLOYEE_IT_LEVELS: dict = {
|
||||
"emp-001": "silver",
|
||||
"emp-002": "gold",
|
||||
"emp-003": "bronze",
|
||||
"emp-004": "platinum",
|
||||
"emp-005": "diamond",
|
||||
"emp-006": "silver",
|
||||
"emp-007": "star",
|
||||
"emp-008": "king",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.put("/{employee_id}/it-level")
|
||||
async def update_employee_it_level(
|
||||
employee_id: str,
|
||||
request: ItLevelUpdateRequest,
|
||||
):
|
||||
"""更新员工IT技能等级。
|
||||
|
||||
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
|
||||
更新后等级立即生效,并记录来源以便追溯。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
request: 等级更新请求
|
||||
|
||||
Returns:
|
||||
更新结果
|
||||
"""
|
||||
# 更新内存中的等级
|
||||
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
|
||||
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
|
||||
|
||||
# 构造等级名称映射
|
||||
level_names = {
|
||||
"bronze": "青铜",
|
||||
"silver": "白银",
|
||||
"gold": "黄金",
|
||||
"platinum": "铂金",
|
||||
"diamond": "钻石",
|
||||
"star": "星耀",
|
||||
"king": "王者",
|
||||
}
|
||||
|
||||
return success_response(data=ItLevelUpdateResponse(
|
||||
employee_id=employee_id,
|
||||
it_level=request.it_level,
|
||||
it_level_source=request.source,
|
||||
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
|
||||
).model_dump())
|
||||
@@ -0,0 +1,902 @@
|
||||
# =============================================================================
|
||||
# 企微智能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 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
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import 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
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.services.ai_handler import AIHandler
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
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小时
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助:从请求头获取员工ID(旧版,仅作为过渡期兼容)
|
||||
# --------------------------------------------------------------------------
|
||||
def _get_employee_id(
|
||||
x_employee_id: Optional[str] = Header(None, alias="X-Employee-Id"),
|
||||
) -> str:
|
||||
"""从请求头获取员工ID(旧版兼容)。
|
||||
|
||||
H5 用户端通过企微 OAuth2 授权后,前端将员工ID放在请求头中。
|
||||
第一步简化实现:直接从请求头读取,不校验 token。
|
||||
|
||||
注意:此方法已废弃,请使用 _get_current_employee 替代。
|
||||
|
||||
Args:
|
||||
x_employee_id: 请求头中的员工ID
|
||||
|
||||
Returns:
|
||||
str: 员工企微 UserID
|
||||
|
||||
Raises:
|
||||
AppException: 未提供员工ID
|
||||
"""
|
||||
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 明文头(开发降级,仅本地测试)
|
||||
# =====================================================================
|
||||
if x_employee_id:
|
||||
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(
|
||||
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:
|
||||
redirect_uri: 自定义回调地址(可选)
|
||||
request_host: 请求的 Host 头(自动获取,用于构造默认回调地址)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 authorize_url 字段
|
||||
"""
|
||||
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}/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}/itdesk/", 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")
|
||||
async def oauth_callback(
|
||||
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:
|
||||
body: OAuth2 回调请求体(包含 code)
|
||||
db: 数据库会话
|
||||
redis_client: 共享 Redis 客户端(DI 注入)
|
||||
wecom_service: 共享企微服务(DI 注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 token 和员工信息
|
||||
"""
|
||||
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")
|
||||
async def mock_login(
|
||||
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, "消息内容不能为空")
|
||||
|
||||
# 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="text",
|
||||
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. 返回用户消息 + 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:
|
||||
try:
|
||||
after_uuid = UUID(after_message_id)
|
||||
after_stmt = select(Message.created_at).where(Message.id == after_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)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
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})
|
||||
@@ -0,0 +1,256 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 消息管理 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的消息管理接口,包括:
|
||||
# 1. GET /api/conversations/{id}/messages — 获取会话消息列表(分页)
|
||||
# 2. POST /api/conversations/{id}/messages — 坐席发送消息
|
||||
# 3. GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
|
||||
# 消息发送需同时:存数据库 + 调用企微API发送给员工
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.schemas.message import MessageCreate, MessageResponse
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversations/{id}/messages — 获取会话消息列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
async def list_messages(
|
||||
conversation_id: UUID,
|
||||
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
|
||||
before: Optional[str] = Query(None, description="加载此消息ID之前的消息(向上翻页)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取会话消息列表(分页)。
|
||||
|
||||
支持向上加载历史消息(通过 before 参数指定消息ID)。
|
||||
默认返回最新的 limit 条消息。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
limit: 每页消息数量
|
||||
before: 加载此消息ID之前的消息(向上翻页)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含消息列表和是否还有更多消息
|
||||
"""
|
||||
# 校验会话存在(UUID 转为字符串,兼容 SQLite String(36) 列)
|
||||
conv_id_str = str(conversation_id)
|
||||
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
|
||||
conv_result = await db.execute(conv_stmt)
|
||||
conversation = conv_result.scalars().first()
|
||||
if not conversation:
|
||||
raise ERR_CONVERSATION_NOT_FOUND
|
||||
|
||||
# 构建查询
|
||||
stmt = select(Message).where(
|
||||
Message.conversation_id == conv_id_str
|
||||
).order_by(Message.created_at.desc())
|
||||
|
||||
# 如果指定了 before,只加载该消息之前的消息
|
||||
if before:
|
||||
try:
|
||||
before_uuid = str(UUID(before))
|
||||
# 先获取 before 消息的创建时间
|
||||
before_stmt = select(Message.created_at).where(Message.id == before_uuid)
|
||||
before_result = await db.execute(before_stmt)
|
||||
before_time = before_result.scalar_one_or_none()
|
||||
if before_time:
|
||||
stmt = stmt.where(Message.created_at < before_time)
|
||||
except ValueError:
|
||||
pass # before 参数格式错误,忽略
|
||||
|
||||
# 限制数量
|
||||
stmt = stmt.limit(limit + 1) # 多查一条判断是否还有更多
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 判断是否还有更多消息
|
||||
has_more = len(messages) > limit
|
||||
if has_more:
|
||||
messages = messages[:limit] # 去掉多查的那一条
|
||||
|
||||
# 按时间正序排列(最早的在前)
|
||||
messages.reverse()
|
||||
|
||||
# 标记消息为已读(坐席查看时自动标记)
|
||||
for msg in messages:
|
||||
if not msg.is_read and msg.sender_type == "employee":
|
||||
msg.is_read = True
|
||||
await db.flush()
|
||||
|
||||
# 转换为响应格式
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"items": items,
|
||||
"has_more": has_more,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/messages — 坐席发送消息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/messages")
|
||||
async def send_message(
|
||||
conversation_id: UUID,
|
||||
body: MessageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席发送消息。
|
||||
|
||||
流程:
|
||||
1. 校验会话存在且未结单
|
||||
2. 将消息存入 messages 表
|
||||
3. 调用企微 API 发送消息给员工
|
||||
4. 更新会话的最后消息信息
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 消息请求体(包含 content 和 msg_type)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含发送的消息对象
|
||||
"""
|
||||
# 1. 校验会话(UUID 转为字符串,兼容 SQLite String(36) 列)
|
||||
conv_id_str = str(conversation_id)
|
||||
conv_stmt = select(Conversation).where(Conversation.id == conv_id_str)
|
||||
conv_result = await db.execute(conv_stmt)
|
||||
conversation = conv_result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise ERR_CONVERSATION_NOT_FOUND
|
||||
if conversation.status == "resolved":
|
||||
raise ERR_CONVERSATION_RESOLVED
|
||||
|
||||
# 2. 创建消息记录
|
||||
# 从会话的 assigned_agent_id 获取坐席信息
|
||||
agent_id = conversation.assigned_agent_id or "unknown"
|
||||
|
||||
message = Message(
|
||||
conversation_id=conv_id_str,
|
||||
sender_type="agent",
|
||||
sender_id=agent_id,
|
||||
sender_name="", # 坐席姓名,后续从坐席信息补充
|
||||
content=body.content,
|
||||
msg_type=body.msg_type,
|
||||
is_read=True, # 坐席自己发的消息默认已读
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# 3. 更新会话最后消息信息
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.last_message_summary = body.content[:256]
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
|
||||
await db.flush() # 刷新以获取消息 ID
|
||||
|
||||
# 4. 调用企微 API 发送消息给员工
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
from app.config import settings
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url)
|
||||
wecom_service = WecomService(redis_client)
|
||||
|
||||
if body.msg_type == "text":
|
||||
await wecom_service.send_text_message(
|
||||
conversation.employee_id, body.content
|
||||
)
|
||||
# 图片和文件消息的发送逻辑(第一步预留,暂不实现)
|
||||
|
||||
await wecom_service.close()
|
||||
await redis_client.close()
|
||||
|
||||
except Exception as e:
|
||||
# 企微 API 调用失败不阻塞消息存储
|
||||
logger.warning(f"企微消息发送失败(消息已存储): {e}")
|
||||
|
||||
# 转换为响应格式
|
||||
response_data = MessageResponse.model_validate(message).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/conversations/{conversation_id}/messages/poll")
|
||||
async def poll_messages(
|
||||
conversation_id: UUID,
|
||||
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席轮询新消息。
|
||||
|
||||
前端每 3-5 秒调用一次,获取上次轮询后的新消息。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
after_message_id: 上次轮询的最后一消息ID(返回此之后的消息)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含新消息列表
|
||||
"""
|
||||
# 构建查询(UUID 转为字符串,兼容 SQLite String(36) 列)
|
||||
conv_id_str = str(conversation_id)
|
||||
stmt = select(Message).where(
|
||||
Message.conversation_id == conv_id_str
|
||||
).order_by(Message.created_at.asc())
|
||||
|
||||
# 如果指定了 after_message_id,只返回该ID之后的消息
|
||||
if after_message_id:
|
||||
try:
|
||||
after_uuid = str(UUID(after_message_id))
|
||||
# 获取 after_message 的创建时间
|
||||
after_stmt = select(Message.created_at).where(Message.id == after_uuid)
|
||||
after_result = await db.execute(after_stmt)
|
||||
after_time = after_result.scalar_one_or_none()
|
||||
if after_time:
|
||||
stmt = stmt.where(Message.created_at > after_time)
|
||||
except ValueError:
|
||||
pass # 参数格式错误,忽略
|
||||
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 标记员工消息为已读
|
||||
for msg in messages:
|
||||
if not msg.is_read and msg.sender_type == "employee":
|
||||
msg.is_read = True
|
||||
await db.flush()
|
||||
|
||||
# 转换为响应格式
|
||||
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"items": items,
|
||||
"has_more": False, # 轮询接口不需要分页
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 快速回复模板 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的快速回复模板管理接口,包括:
|
||||
# 1. GET /api/quick-replies — 获取模板列表(按分类)
|
||||
# 2. POST /api/quick-replies — 创建模板
|
||||
# 3. PUT /api/quick-replies/{id} — 更新模板
|
||||
# 4. DELETE /api/quick-replies/{id} — 删除模板
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.quick_reply_template import QuickReplyTemplate
|
||||
from app.schemas.quick_reply import (
|
||||
QuickReplyCreate,
|
||||
QuickReplyResponse,
|
||||
QuickReplyUpdate,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/quick-replies — 获取模板列表
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/quick-replies")
|
||||
async def list_quick_replies(
|
||||
category: Optional[str] = Query(None, description="按分类过滤: 账号/网络/软件/硬件/通用"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取快速回复模板列表。
|
||||
|
||||
支持按分类过滤,按 sort_order 排序。
|
||||
|
||||
Args:
|
||||
category: 按分类过滤(可选)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含模板列表
|
||||
"""
|
||||
stmt = select(QuickReplyTemplate).order_by(
|
||||
QuickReplyTemplate.category, QuickReplyTemplate.sort_order
|
||||
)
|
||||
|
||||
if category:
|
||||
stmt = stmt.where(QuickReplyTemplate.category == category)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
templates = list(result.scalars().all())
|
||||
|
||||
items = [QuickReplyResponse.model_validate(t).model_dump() for t in templates]
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/quick-replies — 创建模板
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/quick-replies")
|
||||
async def create_quick_reply(
|
||||
body: QuickReplyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建快速回复模板。
|
||||
|
||||
Args:
|
||||
body: 创建请求体(包含 category、title、content、variables、sort_order)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的模板
|
||||
"""
|
||||
template = QuickReplyTemplate(
|
||||
category=body.category,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
variables=body.variables,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"创建快速回复模板: category={body.category}, title={body.title}")
|
||||
|
||||
template_data = QuickReplyResponse.model_validate(template).model_dump()
|
||||
return success_response(data=template_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/quick-replies/{id} — 更新模板
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/quick-replies/{template_id}")
|
||||
async def update_quick_reply(
|
||||
template_id: UUID,
|
||||
body: QuickReplyUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新快速回复模板。
|
||||
|
||||
只更新传入的字段(部分更新)。
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
body: 更新请求体(所有字段可选)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的模板
|
||||
"""
|
||||
# 查找模板
|
||||
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
|
||||
result = await db.execute(stmt)
|
||||
template = result.scalars().first()
|
||||
|
||||
if not template:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 只更新传入的字段
|
||||
if body.category is not None:
|
||||
template.category = body.category
|
||||
if body.title is not None:
|
||||
template.title = body.title
|
||||
if body.content is not None:
|
||||
template.content = body.content
|
||||
if body.variables is not None:
|
||||
template.variables = body.variables
|
||||
if body.sort_order is not None:
|
||||
template.sort_order = body.sort_order
|
||||
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"更新快速回复模板: id={template_id}")
|
||||
|
||||
template_data = QuickReplyResponse.model_validate(template).model_dump()
|
||||
return success_response(data=template_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DELETE /api/quick-replies/{id} — 删除模板
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/quick-replies/{template_id}")
|
||||
async def delete_quick_reply(
|
||||
template_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除快速回复模板。
|
||||
|
||||
第一步使用物理删除。
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 查找模板
|
||||
stmt = select(QuickReplyTemplate).where(QuickReplyTemplate.id == template_id)
|
||||
result = await db.execute(stmt)
|
||||
template = result.scalars().first()
|
||||
|
||||
if not template:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 物理删除
|
||||
await db.delete(template)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"删除快速回复模板: id={template_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
@@ -0,0 +1,114 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — API 路由汇总
|
||||
# =============================================================================
|
||||
# 说明:汇总所有 API 子路由,统一挂载到 FastAPI 应用
|
||||
# T02 阶段注册所有后端核心服务路由
|
||||
# =============================================================================
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
# 导入各子路由模块
|
||||
from app.api.wecom_callback import router as wecom_router
|
||||
from app.api.conversations import router as conversations_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.agents import router as agents_router
|
||||
from app.api.quick_replies import router as quick_replies_router
|
||||
from app.api.h5 import router as h5_router
|
||||
from app.api.agent_notes import router as agent_notes_router
|
||||
from app.api.system import router as system_router
|
||||
from app.api.wingman import router as wingman_router
|
||||
from app.api.todo_items import router as todo_items_router
|
||||
from app.api.troubleshooting_templates import router as troubleshooting_templates_router
|
||||
from app.api.employees import router as employees_router
|
||||
|
||||
# 创建 API 路由器
|
||||
# 所有子路由都会挂载到这个路由器上
|
||||
api_router = APIRouter()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 注册所有子路由
|
||||
# --------------------------------------------------------------------------
|
||||
# 每个子路由都有对应的 prefix 和 tags,方便 Swagger 文档分类展示
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 企微回调 API
|
||||
# GET /api/wecom/callback — 验证URL有效性
|
||||
# POST /api/wecom/callback — 接收企微推送消息
|
||||
api_router.include_router(wecom_router, tags=["企微回调"])
|
||||
|
||||
# 会话管理 API
|
||||
# GET /api/conversations — 获取会话列表
|
||||
# GET /api/conversations/{id} — 获取会话详情
|
||||
# POST /api/conversations/{id}/assign — 坐席接单
|
||||
# POST /api/conversations/{id}/resolve — 结单
|
||||
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||||
# POST /api/conversations/{id}/todo — 代办/取消代办
|
||||
# POST /api/conversations/{id}/transfer — 转接
|
||||
api_router.include_router(conversations_router, tags=["会话管理"])
|
||||
|
||||
# 消息管理 API
|
||||
# GET /api/conversations/{id}/messages — 获取消息列表
|
||||
# POST /api/conversations/{id}/messages — 坐席发送消息
|
||||
# GET /api/conversations/{id}/messages/poll — 轮询新消息
|
||||
api_router.include_router(messages_router, tags=["消息管理"])
|
||||
|
||||
# 坐席管理 API
|
||||
# POST /api/agents/login — 坐席登录
|
||||
# GET /api/agents/me — 获取当前坐席信息
|
||||
# PUT /api/agents/me/status — 更新坐席状态
|
||||
# GET /api/agents — 获取坐席列表
|
||||
api_router.include_router(agents_router, tags=["坐席管理"])
|
||||
|
||||
# 快速回复模板 API
|
||||
# GET /api/quick-replies — 获取模板列表
|
||||
# POST /api/quick-replies — 创建模板
|
||||
# PUT /api/quick-replies/{id} — 更新模板
|
||||
# DELETE /api/quick-replies/{id} — 删除模板
|
||||
api_router.include_router(quick_replies_router, tags=["快速回复"])
|
||||
|
||||
# H5 用户端 API
|
||||
# POST /api/h5/oauth/callback — OAuth2回调
|
||||
# GET /api/h5/user — 获取用户信息
|
||||
# GET /api/h5/conversations/current — 获取当前会话
|
||||
# POST /api/h5/conversations/current/messages — 发送消息
|
||||
# GET /api/h5/conversations/current/messages/poll — 轮询新消息
|
||||
# POST /api/h5/conversations/current/shake — 摇人
|
||||
# GET /api/h5/approval-links — 获取审批链接
|
||||
# GET /api/h5/software-downloads — 获取软件下载
|
||||
api_router.include_router(h5_router, tags=["H5用户端"])
|
||||
|
||||
# 坐席备注 API
|
||||
# GET /api/agent-notes/{employee_id} — 获取员工备注
|
||||
# POST /api/agent-notes — 添加备注
|
||||
# PUT /api/agent-notes/{id} — 更新备注
|
||||
# DELETE /api/agent-notes/{id} — 删除备注
|
||||
api_router.include_router(agent_notes_router, tags=["坐席备注"])
|
||||
|
||||
# 系统管理 API
|
||||
# GET /api/system/emergency-mode — 查询应急模式状态
|
||||
# PUT /api/system/emergency-mode — 切换应急模式开关
|
||||
api_router.include_router(system_router, tags=["系统管理"])
|
||||
|
||||
# AI Wingman 智能副驾驶 API
|
||||
# POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
|
||||
# POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
|
||||
# POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
|
||||
api_router.include_router(wingman_router, tags=["AI Wingman"])
|
||||
|
||||
# 待办事项 API
|
||||
# GET /api/todo-items — 获取当前坐席待办列表
|
||||
# GET /api/todo-items/{id} — 获取待办详情
|
||||
# PUT /api/todo-items/{id}/status — 更新待办状态
|
||||
api_router.include_router(todo_items_router, tags=["待办事项"])
|
||||
|
||||
# 排查模板 API
|
||||
# GET /api/troubleshooting-templates — 获取排查模板列表
|
||||
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
|
||||
# POST /api/troubleshooting-templates — 新增模板(管理员)
|
||||
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
|
||||
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
|
||||
api_router.include_router(troubleshooting_templates_router, tags=["排查模板"])
|
||||
|
||||
# 员工管理 API
|
||||
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
|
||||
api_router.include_router(employees_router, tags=["员工管理"])
|
||||
@@ -0,0 +1,130 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 系统管理 API
|
||||
# =============================================================================
|
||||
# 说明:系统级配置管理接口,包括:
|
||||
# 1. GET /api/system/emergency-mode — 查询应急模式状态
|
||||
# 2. PUT /api/system/emergency-mode — 切换应急模式开关
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
# 应急模式配置键(与 main.py init_data 保持一致)
|
||||
EMERGENCY_MODE_KEY = "emergency_mode"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/system/emergency-mode — 查询应急模式状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/system/emergency-mode")
|
||||
async def get_emergency_mode(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询应急模式状态。
|
||||
|
||||
返回当前应急模式的开关状态。
|
||||
应急模式开启时,智能服务台降级,引导员工使用企微原生「员工服务」通道。
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
data.emergency_mode: bool — 是否启用应急模式
|
||||
data.employee_service_guide: str — 开启时的引导文案(仅开启时返回)
|
||||
"""
|
||||
# 从数据库读取 emergency_mode 配置
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == EMERGENCY_MODE_KEY
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalars().first()
|
||||
|
||||
# 配置不存在时默认关闭(安全默认值)
|
||||
is_enabled = False
|
||||
if config and config.config_value:
|
||||
is_enabled = config.config_value.lower() in ("true", "1", "yes")
|
||||
|
||||
response_data = {"emergency_mode": is_enabled}
|
||||
|
||||
# 应急模式开启时,附带引导文案
|
||||
if is_enabled:
|
||||
response_data["employee_service_guide"] = (
|
||||
"智能IT支持服务台正在进行系统维护,"
|
||||
"请通过企业微信「通讯录 → 员工服务」联系IT支持人员,"
|
||||
"我们将尽快为您处理。"
|
||||
)
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/system/emergency-mode — 切换应急模式开关
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/system/emergency-mode")
|
||||
async def toggle_emergency_mode(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换应急模式开关(仅限坐席/管理员操作)。
|
||||
|
||||
开启应急模式后:
|
||||
- H5 用户端页面显示引导文案,提示走企微原生「员工服务」
|
||||
- 坐席工作台顶部显示醒目的应急模式横幅
|
||||
|
||||
关闭应急模式后恢复正常服务。
|
||||
|
||||
Args:
|
||||
body: 请求体,包含 emergency_mode: bool
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
data.emergency_mode: bool — 切换后的状态
|
||||
"""
|
||||
enabled = body.get("emergency_mode", None)
|
||||
if enabled is None:
|
||||
raise AppException(1001, "emergency_mode 参数不能为空")
|
||||
|
||||
enabled_bool = bool(enabled)
|
||||
|
||||
# 查找或创建 emergency_mode 配置项
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == EMERGENCY_MODE_KEY
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalars().first()
|
||||
|
||||
new_value = "true" if enabled_bool else "false"
|
||||
|
||||
if config:
|
||||
# 更新已有配置
|
||||
config.config_value = new_value
|
||||
else:
|
||||
# 配置不存在时新建(兜底,正常情况由 init_data 创建)
|
||||
config = SystemConfig(
|
||||
config_key=EMERGENCY_MODE_KEY,
|
||||
config_value=new_value,
|
||||
description="应急模式开关(true=启用员工服务通道,智能服务台降级)",
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.flush()
|
||||
|
||||
status_text = "开启" if enabled_bool else "关闭"
|
||||
logger.info(f"应急模式已{status_text}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"emergency_mode": enabled_bool,
|
||||
"message": f"应急模式已{status_text}",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 待办事项 API
|
||||
# =============================================================================
|
||||
# 说明:提供待办事项的 CRUD 接口
|
||||
# 接口列表:
|
||||
# GET /api/todo-items — 获取当前坐席待办列表
|
||||
# GET /api/todo-items/{id} — 获取待办详情
|
||||
# PUT /api/todo-items/{id}/status — 更新待办状态
|
||||
# Mock: 预置示例待办数据,不连接真实外部系统
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.response import success_response, AppException
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/todo-items", tags=["待办事项"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求/响应 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TodoStatusUpdateRequest(BaseModel):
|
||||
"""更新待办状态请求 Schema。"""
|
||||
status: str = Field(..., description="新状态: pending/processing/resolved")
|
||||
|
||||
|
||||
class TodoItemResponse(BaseModel):
|
||||
"""待办事项响应 Schema。"""
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
priority: str
|
||||
description: dict
|
||||
status: str
|
||||
assigned_agent_id: Optional[str] = None
|
||||
corp_id: str = ""
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class TodoItemListResponse(BaseModel):
|
||||
"""待办事项列表响应 Schema。"""
|
||||
items: List[TodoItemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 数据 — 预置示例待办(共 20 条,覆盖全部类型 × 状态)
|
||||
# --------------------------------------------------------------------------
|
||||
MOCK_TODO_ITEMS: List[dict] = [
|
||||
# ========== 工单(ticket)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-001",
|
||||
"type": "ticket",
|
||||
"title": "VPN连接失败 — 财务部张伟",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "张伟",
|
||||
"department": "财务部",
|
||||
"error": "VPN Error 691",
|
||||
"steps": ["检查账号状态", "重置密码", "检查VPN配置"],
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T09:15:00Z",
|
||||
"updated_at": "2026-06-05T09:15:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-007",
|
||||
"type": "ticket",
|
||||
"title": "OA系统登录异常 — 人事部刘芳",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "刘芳",
|
||||
"department": "人事部",
|
||||
"error": "页面白屏,控制台报500错误",
|
||||
"affected_count": 15,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T11:30:00Z",
|
||||
"updated_at": "2026-06-05T11:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-009",
|
||||
"type": "ticket",
|
||||
"title": "WiFi 无法连接 — 研发部开放区",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "陈明",
|
||||
"department": "研发部",
|
||||
"error": "获取IP失败,提示无法连接到此网络",
|
||||
"location": "3楼开放区",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T08:00:00Z",
|
||||
"updated_at": "2026-06-06T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-017",
|
||||
"type": "ticket",
|
||||
"title": "鼠标失灵 — 行政部周婷",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "周婷",
|
||||
"department": "行政部",
|
||||
"error": "USB鼠标间歇性失灵,更换接口无效",
|
||||
"os": "Windows 11",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T09:00:00Z",
|
||||
"updated_at": "2026-06-06T09:00:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-004",
|
||||
"type": "ticket",
|
||||
"title": "邮箱容量告警 — 市场部王强",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "王强",
|
||||
"department": "市场部",
|
||||
"current_usage": "4.8GB / 5GB",
|
||||
"action": "协助清理或申请扩容",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-04T14:30:00Z",
|
||||
"updated_at": "2026-06-05T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-010",
|
||||
"type": "ticket",
|
||||
"title": "ERP系统响应慢 — 全公司反馈",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "多个员工",
|
||||
"department": "全公司",
|
||||
"error": "ERP首页加载超过15秒",
|
||||
"affected_count": 50,
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T10:00:00Z",
|
||||
"updated_at": "2026-06-05T15:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-011",
|
||||
"type": "ticket",
|
||||
"title": "打印机驱动安装 — 市场部赵敏",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "赵敏",
|
||||
"department": "市场部",
|
||||
"device_model": "Canon LBP2900",
|
||||
"solution": "从官网下载驱动并安装,测试打印正常",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-01T09:00:00Z",
|
||||
"updated_at": "2026-06-02T16:00:00Z",
|
||||
},
|
||||
|
||||
# ========== 审批(approval)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-002",
|
||||
"type": "approval",
|
||||
"title": "软件安装审批 — 设计部PS申请",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "李娜",
|
||||
"department": "设计部",
|
||||
"software": "Adobe Photoshop 2026",
|
||||
"license_type": "企业许可",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T10:20:00Z",
|
||||
"updated_at": "2026-06-05T10:20:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-005",
|
||||
"type": "approval",
|
||||
"title": "权限升级审批 — 研发部数据库访问",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "陈明",
|
||||
"department": "研发部",
|
||||
"target_system": "生产数据库",
|
||||
"access_level": "只读",
|
||||
"approver": "研发总监",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T08:45:00Z",
|
||||
"updated_at": "2026-06-05T08:45:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-008",
|
||||
"type": "approval",
|
||||
"title": "新员工设备采购审批 — Q3批次",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"batch": "Q3新员工",
|
||||
"count": 5,
|
||||
"items": ["笔记本x5", "显示器x5", "键鼠套装x5"],
|
||||
"budget": "65,000元",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T07:00:00Z",
|
||||
"updated_at": "2026-06-05T07:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-018",
|
||||
"type": "approval",
|
||||
"title": "弹性福利审批 — 全体员工Q3",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"applicant": "人事部",
|
||||
"type": "弹性福利",
|
||||
"budget_per_person": "3000元",
|
||||
"total_count": 120,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T07:00:00Z",
|
||||
"updated_at": "2026-06-06T07:00:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-012",
|
||||
"type": "approval",
|
||||
"title": "预算审批 — IT部Q3采购",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"department": "IT部",
|
||||
"amount": "280,000元",
|
||||
"items": ["服务器x2", "防火墙x2", "交换机x4"],
|
||||
"approver": "CFO",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-04T09:00:00Z",
|
||||
"updated_at": "2026-06-05T14:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-013",
|
||||
"type": "approval",
|
||||
"title": "会议室预订审批 — 销售部Q3客户拜访",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "刘军",
|
||||
"department": "销售部",
|
||||
"room": "5楼大会议室",
|
||||
"time": "2026-06-10 14:00-17:00",
|
||||
"result": "已批准",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-05-28T08:00:00Z",
|
||||
"updated_at": "2026-05-29T10:00:00Z",
|
||||
},
|
||||
|
||||
# ========== 设备(device)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-003",
|
||||
"type": "device",
|
||||
"title": "工位打印机故障 — 3楼A区",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"location": "3楼A区打印间",
|
||||
"device_model": "HP LaserJet Pro M404",
|
||||
"issue": "卡纸,无法打印",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T11:05:00Z",
|
||||
"updated_at": "2026-06-05T11:05:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-014",
|
||||
"type": "device",
|
||||
"title": "核心交换机故障 — 机房",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"location": "机房A区",
|
||||
"device_model": "Cisco Catalyst 9300",
|
||||
"issue": "端口3-12全部down,影响2楼所有工位",
|
||||
"affected_count": 45,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T00:30:00Z",
|
||||
"updated_at": "2026-06-06T00:30:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-006",
|
||||
"type": "device",
|
||||
"title": "会议室投影仪维修 — 5楼大会议室",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"location": "5楼大会议室",
|
||||
"device_model": "Epson EB-X51",
|
||||
"issue": "投影模糊,可能灯泡老化",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-03T16:00:00Z",
|
||||
"updated_at": "2026-06-04T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-015",
|
||||
"type": "device",
|
||||
"title": "服务器硬盘更换 — 虚拟化集群",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"location": "机房B区",
|
||||
"device_model": "Dell R740",
|
||||
"issue": "硬盘预警,需更换并做好数据迁移",
|
||||
"affected_vms": 12,
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T09:00:00Z",
|
||||
"updated_at": "2026-06-05T16:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-016",
|
||||
"type": "device",
|
||||
"title": "员工笔记本磁盘扩容 — 人事部吴婷",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "吴婷",
|
||||
"department": "人事部",
|
||||
"device_model": "ThinkPad X1 Carbon",
|
||||
"solution": "更换1TB SSD,克隆系统,测试正常",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-05-20T13:00:00Z",
|
||||
"updated_at": "2026-05-22T17:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_todo_items(
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
):
|
||||
"""获取当前坐席待办列表。
|
||||
|
||||
支持按状态和优先级过滤。
|
||||
"""
|
||||
items = MOCK_TODO_ITEMS
|
||||
|
||||
# 按状态过滤
|
||||
if status:
|
||||
items = [item for item in items if item["status"] == status]
|
||||
|
||||
# 按优先级过滤
|
||||
if priority:
|
||||
items = [item for item in items if item["priority"] == priority]
|
||||
|
||||
# 按优先级排序:urgent → high → normal
|
||||
priority_order = {"urgent": 0, "high": 1, "normal": 2}
|
||||
items = sorted(items, key=lambda x: priority_order.get(x["priority"], 3))
|
||||
|
||||
return success_response(data={
|
||||
"items": [TodoItemResponse(**item).model_dump() for item in items],
|
||||
"total": len(items),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_todo_item(item_id: str):
|
||||
"""获取待办事项详情。"""
|
||||
for item in MOCK_TODO_ITEMS:
|
||||
if item["id"] == item_id:
|
||||
return success_response(data=TodoItemResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
|
||||
|
||||
|
||||
@router.put("/{item_id}/status")
|
||||
async def update_todo_item_status(item_id: str, request: TodoStatusUpdateRequest):
|
||||
"""更新待办事项状态。"""
|
||||
# 校验状态值
|
||||
valid_statuses = {"pending", "processing", "resolved"}
|
||||
if request.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的状态值: {request.status},合法值为: {valid_statuses}",
|
||||
)
|
||||
|
||||
for item in MOCK_TODO_ITEMS:
|
||||
if item["id"] == item_id:
|
||||
item["status"] = request.status
|
||||
item["updated_at"] = datetime.now().isoformat()
|
||||
return success_response(data=TodoItemResponse(**item).model_dump())
|
||||
|
||||
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
|
||||
+719
@@ -0,0 +1,719 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 排查模板 API
|
||||
# =============================================================================
|
||||
# 说明:提供排查模板的 CRUD 接口
|
||||
# 接口列表:
|
||||
# GET /api/troubleshooting-templates — 获取排查模板列表
|
||||
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
|
||||
# POST /api/troubleshooting-templates — 新增模板(管理员)
|
||||
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
|
||||
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
|
||||
# Mock: 预置 8 套常见问题模板(VPN/邮箱/系统/账号等)
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.response import success_response, AppException
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/troubleshooting-templates", tags=["排查模板"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求/响应 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PathStepSchema(BaseModel):
|
||||
"""排障步骤路径节点 Schema。"""
|
||||
label: str = Field(..., description="步骤标题")
|
||||
status: str = Field(default="pending", description="步骤状态: done/current/pending")
|
||||
|
||||
|
||||
class FlowchartNodeSchema(BaseModel):
|
||||
"""决策树递归节点 Schema。"""
|
||||
id: str = Field(..., description="节点唯一标识")
|
||||
type: str = Field(..., description="节点类型: step/decision")
|
||||
label: str = Field(..., description="节点标签")
|
||||
status: Optional[str] = Field(None, description="节点状态: done/current/pending")
|
||||
children: Optional[List["FlowchartNodeSchema"]] = Field(None, description="子节点列表")
|
||||
yes_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'是' 分支")
|
||||
no_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'否' 分支")
|
||||
|
||||
|
||||
class TroubleshootingTemplateCreateRequest(BaseModel):
|
||||
"""创建排查模板请求 Schema。"""
|
||||
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
|
||||
category: str = Field(default="system", description="分类: vpn/email/system/account")
|
||||
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
|
||||
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
|
||||
class TroubleshootingTemplateUpdateRequest(BaseModel):
|
||||
"""更新排查模板请求 Schema。"""
|
||||
name: Optional[str] = Field(None, max_length=256, description="模板名称")
|
||||
category: Optional[str] = Field(None, description="分类")
|
||||
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
|
||||
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
|
||||
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||
|
||||
|
||||
class TroubleshootingTemplateResponse(BaseModel):
|
||||
"""排查模板响应 Schema。"""
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
path_steps: List[Dict[str, Any]]
|
||||
flowchart: Dict[str, Any]
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class TroubleshootingTemplateListResponse(BaseModel):
|
||||
"""排查模板列表响应 Schema。"""
|
||||
items: List[TroubleshootingTemplateResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 数据 — 预置 8 套常见问题模板
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _build_vpn_flowchart() -> Dict[str, Any]:
|
||||
"""构建 VPN 故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-vpn-1",
|
||||
"type": "step",
|
||||
"label": "确认VPN客户端版本",
|
||||
"status": "done",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-2",
|
||||
"type": "decision",
|
||||
"label": "版本是否为最新?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-3",
|
||||
"type": "step",
|
||||
"label": "清除DNS缓存并重连",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-4",
|
||||
"type": "decision",
|
||||
"label": "重连是否成功?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-6",
|
||||
"type": "step",
|
||||
"label": "发起远程协助",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-7",
|
||||
"type": "decision",
|
||||
"label": "远程能否解决?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-8",
|
||||
"type": "step",
|
||||
"label": "回访确认并结单",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-9",
|
||||
"type": "step",
|
||||
"label": "升级至二线团队",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-10",
|
||||
"type": "step",
|
||||
"label": "升级VPN客户端到最新版",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-11",
|
||||
"type": "step",
|
||||
"label": "重试连接",
|
||||
"status": "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_email_flowchart() -> Dict[str, Any]:
|
||||
"""构建邮箱故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-email-1",
|
||||
"type": "step",
|
||||
"label": "确认邮箱账号状态",
|
||||
"status": "done",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-email-2",
|
||||
"type": "decision",
|
||||
"label": "账号是否被锁定?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-email-3",
|
||||
"type": "step",
|
||||
"label": "解锁账号并重置密码",
|
||||
"status": "current",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-email-4",
|
||||
"type": "step",
|
||||
"label": "检查Outlook配置",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-email-5",
|
||||
"type": "decision",
|
||||
"label": "配置是否正确?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-email-6",
|
||||
"type": "step",
|
||||
"label": "清理Outlook缓存",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-email-7",
|
||||
"type": "step",
|
||||
"label": "重新配置Outlook",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_system_flowchart() -> Dict[str, Any]:
|
||||
"""构建系统登录异常排查流程图。"""
|
||||
return {
|
||||
"id": "fc-sys-1",
|
||||
"type": "step",
|
||||
"label": "确认系统服务是否正常",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-sys-2",
|
||||
"type": "decision",
|
||||
"label": "系统服务是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-sys-3",
|
||||
"type": "step",
|
||||
"label": "清除浏览器缓存",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-sys-4",
|
||||
"type": "decision",
|
||||
"label": "清除后是否恢复?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-sys-5",
|
||||
"type": "step",
|
||||
"label": "回访确认并结单",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-sys-6",
|
||||
"type": "step",
|
||||
"label": "更换浏览器重试",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-sys-7",
|
||||
"type": "step",
|
||||
"label": "联系运维检查服务端",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_account_flowchart() -> Dict[str, Any]:
|
||||
"""构建账号权限问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-acc-1",
|
||||
"type": "step",
|
||||
"label": "确认权限需求与合规性",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-acc-2",
|
||||
"type": "decision",
|
||||
"label": "权限是否符合策略?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-acc-3",
|
||||
"type": "step",
|
||||
"label": "提交权限审批流程",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-acc-4",
|
||||
"type": "step",
|
||||
"label": "审批通过后配置权限",
|
||||
"status": "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-acc-5",
|
||||
"type": "step",
|
||||
"label": "建议替代方案或申请特批",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_network_flowchart() -> Dict[str, Any]:
|
||||
"""构建网络连接问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-net-1",
|
||||
"type": "step",
|
||||
"label": "确认网络连接状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-net-2",
|
||||
"type": "decision",
|
||||
"label": "能否ping通网关?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-net-3",
|
||||
"type": "step",
|
||||
"label": "检查DNS解析",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-net-4",
|
||||
"type": "decision",
|
||||
"label": "DNS是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-net-5",
|
||||
"type": "step",
|
||||
"label": "检查防火墙规则",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-net-6",
|
||||
"type": "step",
|
||||
"label": "手动配置DNS服务器",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-net-7",
|
||||
"type": "step",
|
||||
"label": "检查网线和交换机端口",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_printer_flowchart() -> Dict[str, Any]:
|
||||
"""构建打印机故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-prt-1",
|
||||
"type": "step",
|
||||
"label": "确认打印机连接状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-prt-2",
|
||||
"type": "decision",
|
||||
"label": "打印机是否在线?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-prt-3",
|
||||
"type": "step",
|
||||
"label": "清除打印队列并重启打印服务",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-prt-4",
|
||||
"type": "decision",
|
||||
"label": "打印是否恢复?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-prt-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-prt-6",
|
||||
"type": "step",
|
||||
"label": "重新安装打印机驱动",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-prt-7",
|
||||
"type": "step",
|
||||
"label": "检查网络连接和打印机电源",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_office_flowchart() -> Dict[str, Any]:
|
||||
"""构建 Office 软件问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-off-1",
|
||||
"type": "step",
|
||||
"label": "确认Office版本和激活状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-off-2",
|
||||
"type": "decision",
|
||||
"label": "Office是否正常激活?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-off-3",
|
||||
"type": "step",
|
||||
"label": "修复Office安装",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-off-4",
|
||||
"type": "decision",
|
||||
"label": "修复后是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-off-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-off-6",
|
||||
"type": "step",
|
||||
"label": "卸载重装Office",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-off-7",
|
||||
"type": "step",
|
||||
"label": "重新激活Office许可证",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_password_flowchart() -> Dict[str, Any]:
|
||||
"""构建密码重置问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-pwd-1",
|
||||
"type": "step",
|
||||
"label": "确认账号状态和锁定原因",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-pwd-2",
|
||||
"type": "decision",
|
||||
"label": "账号是否被锁定?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-pwd-3",
|
||||
"type": "step",
|
||||
"label": "解锁账号并引导自助重置",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-pwd-4",
|
||||
"type": "decision",
|
||||
"label": "自助重置是否成功?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-pwd-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-pwd-6",
|
||||
"type": "step",
|
||||
"label": "管理员手动重置密码",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-pwd-7",
|
||||
"type": "step",
|
||||
"label": "检查SSO单点登录配置",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# 所有 Mock 模板数据
|
||||
MOCK_TEMPLATES: List[dict] = [
|
||||
{
|
||||
"id": "tpl-vpn-001",
|
||||
"name": "VPN连接故障",
|
||||
"category": "vpn",
|
||||
"path_steps": [
|
||||
{"label": "确认VPN版本", "status": "done"},
|
||||
{"label": "清除缓存重连", "status": "current"},
|
||||
{"label": "远程排查", "status": "pending"},
|
||||
{"label": "升级客户端", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_vpn_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-email-001",
|
||||
"name": "邮箱登录故障",
|
||||
"category": "email",
|
||||
"path_steps": [
|
||||
{"label": "确认邮箱状态", "status": "done"},
|
||||
{"label": "重置密码", "status": "current"},
|
||||
{"label": "检查配置", "status": "pending"},
|
||||
{"label": "清理缓存", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_email_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-20T14:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-system-001",
|
||||
"name": "系统登录异常",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认系统状态", "status": "current"},
|
||||
{"label": "清除浏览器缓存", "status": "pending"},
|
||||
{"label": "更换浏览器", "status": "pending"},
|
||||
{"label": "检查网络权限", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_system_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-25T09:15:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-account-001",
|
||||
"name": "账号权限问题",
|
||||
"category": "account",
|
||||
"path_steps": [
|
||||
{"label": "确认权限需求", "status": "current"},
|
||||
{"label": "提交审批", "status": "pending"},
|
||||
{"label": "配置权限", "status": "pending"},
|
||||
{"label": "验证权限", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_account_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-28T16:45:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-network-001",
|
||||
"name": "网络连接问题",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认网络状态", "status": "current"},
|
||||
{"label": "检查DNS配置", "status": "pending"},
|
||||
{"label": "检查防火墙", "status": "pending"},
|
||||
{"label": "更换网口/网线", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_network_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-05T10:00:00Z",
|
||||
"updated_at": "2025-06-22T11:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-printer-001",
|
||||
"name": "打印机故障",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认打印机状态", "status": "current"},
|
||||
{"label": "清除打印队列", "status": "pending"},
|
||||
{"label": "重新安装驱动", "status": "pending"},
|
||||
{"label": "检查网络连接", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_printer_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-10T09:00:00Z",
|
||||
"updated_at": "2025-07-01T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-office-001",
|
||||
"name": "Office软件问题",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认Office版本", "status": "current"},
|
||||
{"label": "修复安装", "status": "pending"},
|
||||
{"label": "重新激活", "status": "pending"},
|
||||
{"label": "卸载重装", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_office_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-12T14:00:00Z",
|
||||
"updated_at": "2025-06-30T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-password-001",
|
||||
"name": "密码重置问题",
|
||||
"category": "account",
|
||||
"path_steps": [
|
||||
{"label": "确认账号状态", "status": "current"},
|
||||
{"label": "解锁账号", "status": "pending"},
|
||||
{"label": "引导自助重置", "status": "pending"},
|
||||
{"label": "管理员重置", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_password_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-15T08:00:00Z",
|
||||
"updated_at": "2025-07-01T09:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_troubleshooting_templates(
|
||||
category: Optional[str] = None,
|
||||
):
|
||||
"""获取排查模板列表。
|
||||
|
||||
支持按分类过滤。
|
||||
"""
|
||||
items = MOCK_TEMPLATES
|
||||
|
||||
# 按分类过滤
|
||||
if category:
|
||||
items = [item for item in items if item["category"] == category]
|
||||
|
||||
# 只返回启用的模板
|
||||
items = [item for item in items if item.get("is_active", True)]
|
||||
|
||||
return success_response(data={
|
||||
"items": [TroubleshootingTemplateResponse(**item).model_dump() for item in items],
|
||||
"total": len(items),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_troubleshooting_template(template_id: str):
|
||||
"""获取排查模板详情。"""
|
||||
for item in MOCK_TEMPLATES:
|
||||
if item["id"] == template_id:
|
||||
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_troubleshooting_template(request: TroubleshootingTemplateCreateRequest):
|
||||
"""新增排查模板(管理员)。"""
|
||||
new_template = {
|
||||
"id": f"tpl-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"name": request.name,
|
||||
"category": request.category,
|
||||
"path_steps": request.path_steps,
|
||||
"flowchart": request.flowchart,
|
||||
"is_active": request.is_active,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
MOCK_TEMPLATES.append(new_template)
|
||||
return success_response(data=TroubleshootingTemplateResponse(**new_template).model_dump())
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_troubleshooting_template(
|
||||
template_id: str,
|
||||
request: TroubleshootingTemplateUpdateRequest,
|
||||
):
|
||||
"""修改排查模板(管理员)。"""
|
||||
for item in MOCK_TEMPLATES:
|
||||
if item["id"] == template_id:
|
||||
if request.name is not None:
|
||||
item["name"] = request.name
|
||||
if request.category is not None:
|
||||
item["category"] = request.category
|
||||
if request.path_steps is not None:
|
||||
item["path_steps"] = request.path_steps
|
||||
if request.flowchart is not None:
|
||||
item["flowchart"] = request.flowchart
|
||||
if request.is_active is not None:
|
||||
item["is_active"] = request.is_active
|
||||
item["updated_at"] = datetime.now().isoformat()
|
||||
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_troubleshooting_template(template_id: str):
|
||||
"""删除排查模板(管理员)。"""
|
||||
for i, item in enumerate(MOCK_TEMPLATES):
|
||||
if item["id"] == template_id:
|
||||
MOCK_TEMPLATES.pop(i)
|
||||
return success_response(data=None, message=f"排查模板 {template_id} 已删除")
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
@@ -0,0 +1,276 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — 企微回调 API
|
||||
# =============================================================================
|
||||
# 说明:处理企微服务器的回调请求,包括:
|
||||
# 1. GET /api/wecom/callback — 验证URL有效性(企微配置回调URL时调用)
|
||||
# 2. POST /api/wecom/callback — 接收企微推送的消息
|
||||
#
|
||||
# 重构记录(2026-06):
|
||||
# - 移除手动创建 Redis/WecomService/AIService 实例的模式
|
||||
# - 改用 dependencies 模块提供的共享服务实例
|
||||
# - 不再手动 close() 服务实例(由应用生命周期管理)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import _get_session_factory
|
||||
from app.dependencies import (
|
||||
get_shared_redis,
|
||||
get_shared_wecom_service,
|
||||
get_shared_ai_handler,
|
||||
)
|
||||
from app.services.ai_handler import AIHandler
|
||||
from app.services.cache_service import CacheService
|
||||
from app.services.message_router import MessageRouter
|
||||
from app.services.scoring_service import ScoringService
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.wecom_crypto import WecomCrypto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
# 加解密工具实例(懒加载单例,避免导入时因无效配置导致 base64 解码失败)
|
||||
_wecom_crypto: WecomCrypto | None = None
|
||||
|
||||
|
||||
def _get_wecom_crypto() -> WecomCrypto:
|
||||
"""获取加解密工具单例(延迟初始化)。
|
||||
|
||||
在测试环境中,settings 中的 EncodingAESKey 可能是无效的占位值,
|
||||
延迟初始化可以避免模块导入时就触发 base64 解码错误。
|
||||
"""
|
||||
global _wecom_crypto
|
||||
if _wecom_crypto is None:
|
||||
from app.config import settings
|
||||
_wecom_crypto = WecomCrypto(
|
||||
token=settings.wecom_token,
|
||||
encoding_aes_key=settings.wecom_encoding_aes_key,
|
||||
corp_id=settings.wecom_corp_id,
|
||||
)
|
||||
return _wecom_crypto
|
||||
|
||||
|
||||
@router.get("/wecom/callback")
|
||||
async def verify_url(
|
||||
msg_signature: str = Query(..., description="企微签名"),
|
||||
timestamp: str = Query(..., description="时间戳"),
|
||||
nonce: str = Query(..., description="随机数"),
|
||||
echostr: str = Query(..., description="加密的验证字符串"),
|
||||
):
|
||||
"""验证企微回调URL有效性。
|
||||
|
||||
企微管理后台配置回调URL时,会发送 GET 请求验证。
|
||||
验证流程:
|
||||
1. 验证签名 SHA1(sort(token, timestamp, nonce, echostr))
|
||||
2. 解密 echostr
|
||||
3. 返回解密后的明文
|
||||
|
||||
Args:
|
||||
msg_signature: 企微签名
|
||||
timestamp: 时间戳
|
||||
nonce: 随机数
|
||||
echostr: 加密的验证字符串
|
||||
|
||||
Returns:
|
||||
str: 解密后的 echostr 明文
|
||||
"""
|
||||
try:
|
||||
# 验证签名并解密 echostr
|
||||
plaintext = _get_wecom_crypto().decrypt_echostr(
|
||||
msg_signature=msg_signature,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
echostr=echostr,
|
||||
)
|
||||
logger.info("企微回调URL验证成功")
|
||||
return Response(content=plaintext, media_type="text/plain")
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"企微回调URL验证失败: {e}")
|
||||
return Response(content=f"验证失败: {e}", media_type="text/plain", status_code=400)
|
||||
|
||||
|
||||
@router.post("/wecom/callback")
|
||||
async def receive_message(
|
||||
request: Request,
|
||||
msg_signature: str = Query(..., description="企微签名"),
|
||||
timestamp: str = Query(..., description="时间戳"),
|
||||
nonce: str = Query(..., description="随机数"),
|
||||
):
|
||||
"""接收企微推送的消息。
|
||||
|
||||
企微将员工发送的消息通过此接口推送过来。
|
||||
处理流程:
|
||||
1. 读取 XML 请求体
|
||||
2. 解密消息(验证签名 + AES 解密)
|
||||
3. 解析消息内容
|
||||
4. 路由到 MessageRouter 处理
|
||||
5. 返回 "success" 字符串(企微要求)
|
||||
|
||||
重构说明:使用 dependencies 模块提供的共享服务实例,
|
||||
不再手动创建/关闭 Redis、WecomService、AIService。
|
||||
|
||||
企微推送的消息格式(加密后):
|
||||
<xml>
|
||||
<ToUserName><![CDATA[corp_id]]></ToUserName>
|
||||
<AgentID>1000002</AgentID>
|
||||
<Encrypt><![CDATA[加密内容]]></Encrypt>
|
||||
</xml>
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象(读取 XML 请求体)
|
||||
msg_signature: 企微签名
|
||||
timestamp: 时间戳
|
||||
nonce: 随机数
|
||||
|
||||
Returns:
|
||||
str: "success" 字符串(企微要求的固定响应)
|
||||
"""
|
||||
try:
|
||||
# 1. 读取 XML 请求体
|
||||
xml_body = (await request.body()).decode("utf-8")
|
||||
logger.debug(f"收到企微回调: xml_length={len(xml_body)}")
|
||||
|
||||
# 2. 解密消息
|
||||
message_dict = _get_wecom_crypto().decrypt_message(
|
||||
xml_body=xml_body,
|
||||
msg_signature=msg_signature,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
|
||||
# 3. 提取消息关键字段
|
||||
from_user_id = message_dict.get("FromUserName", "")
|
||||
content = message_dict.get("Content", "")
|
||||
msg_type = message_dict.get("MsgType", "text")
|
||||
agent_id = message_dict.get("AgentID", "")
|
||||
event = message_dict.get("Event", "")
|
||||
msg_id = message_dict.get("MsgId", "")
|
||||
|
||||
# 提取非文本消息的媒体字段(图片/语音/视频/文件/位置)
|
||||
media_id: str = message_dict.get("MediaId", "")
|
||||
pic_url: str = message_dict.get("PicUrl", "")
|
||||
msg_format: str = message_dict.get("Format", "")
|
||||
file_name: str = message_dict.get("FileName", "")
|
||||
file_size: str = message_dict.get("FileSize", "")
|
||||
# 位置消息字段
|
||||
location_x: str = message_dict.get("Location_X", "")
|
||||
location_y: str = message_dict.get("Location_Y", "")
|
||||
location_label: str = message_dict.get("Label", "")
|
||||
|
||||
# 4. 处理事件消息(如员工进入应用)
|
||||
if event:
|
||||
await _handle_event(event, from_user_id, message_dict)
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
# 5. 处理各类消息(文本 + 非文本)
|
||||
# 文本消息必须有 Content 字段;非文本消息(image/voice/video/file/location)
|
||||
# 没有 Content 字段,content 可能为空字符串,这是正常的
|
||||
if msg_type == "text" and (not from_user_id or not content):
|
||||
logger.warning("文本消息缺少发送者或内容,忽略")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
elif msg_type != "text" and not from_user_id:
|
||||
logger.warning("非文本消息缺少发送者,忽略")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
# 6. 路由消息到 MessageRouter(使用共享服务实例)
|
||||
session_factory = _get_session_factory()
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
# 获取共享服务实例(不再手动创建/关闭)
|
||||
wecom_service = get_shared_wecom_service()
|
||||
ai_handler = get_shared_ai_handler()
|
||||
redis_client = get_shared_redis()
|
||||
|
||||
# ScoringService 需要当前 db 会话,仍需按请求创建
|
||||
scoring_service = ScoringService(db)
|
||||
|
||||
# CacheService 使用共享 Redis 客户端
|
||||
cache_service = CacheService(redis_client)
|
||||
|
||||
# 创建消息路由器
|
||||
message_router = MessageRouter(
|
||||
db=db,
|
||||
wecom_service=wecom_service,
|
||||
scoring_service=scoring_service,
|
||||
ai_handler=ai_handler,
|
||||
cache_service=cache_service,
|
||||
)
|
||||
|
||||
# 构建 extra_data(存储各消息类型的额外元数据)
|
||||
extra_data: dict = {}
|
||||
if msg_type == "image":
|
||||
extra_data["pic_url"] = pic_url
|
||||
elif msg_type == "voice":
|
||||
extra_data["format"] = msg_format
|
||||
elif msg_type == "video":
|
||||
extra_data["thumb_media_id"] = message_dict.get("ThumbMediaId", "")
|
||||
elif msg_type == "location":
|
||||
extra_data["location_x"] = location_x
|
||||
extra_data["location_y"] = location_y
|
||||
extra_data["label"] = location_label
|
||||
extra_data["scale"] = message_dict.get("Scale", "")
|
||||
|
||||
# 路由消息
|
||||
await message_router.route_message(
|
||||
from_user_id=from_user_id,
|
||||
content=content,
|
||||
msg_type=msg_type,
|
||||
msg_id=msg_id if msg_id else None,
|
||||
media_id=media_id if media_id else None,
|
||||
extra_data=extra_data if extra_data else None,
|
||||
file_name=file_name if file_name else None,
|
||||
file_size=int(file_size) if file_size else None,
|
||||
)
|
||||
|
||||
# 提交事务
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"消息路由处理失败: {e}", exc_info=True)
|
||||
# 即使处理失败,也返回 "success" 避免企微重试
|
||||
# 但记录错误日志以便排查
|
||||
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
except ValueError as e:
|
||||
# 解密失败,记录日志但仍返回 success 避免企微重试
|
||||
logger.error(f"消息解密失败: {e}")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
except Exception as e:
|
||||
# 其他未知错误,记录日志但仍返回 success
|
||||
logger.error(f"消息处理未知错误: {e}", exc_info=True)
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
|
||||
async def _handle_event(
|
||||
event: str, from_user_id: str, message_dict: dict
|
||||
) -> None:
|
||||
"""处理企微事件消息。
|
||||
|
||||
事件类型:
|
||||
- subscribe: 员工关注应用
|
||||
- unsubscribe: 员工取消关注
|
||||
- enter_agent: 员工进入应用
|
||||
|
||||
Args:
|
||||
event: 事件类型
|
||||
from_user_id: 发送者企微 UserID
|
||||
message_dict: 完整消息字典
|
||||
"""
|
||||
if event == "enter_agent":
|
||||
logger.info(f"员工进入应用: user_id={from_user_id}")
|
||||
elif event == "subscribe":
|
||||
logger.info(f"员工关注应用: user_id={from_user_id}")
|
||||
elif event == "unsubscribe":
|
||||
logger.info(f"员工取消关注: user_id={from_user_id}")
|
||||
else:
|
||||
logger.info(f"收到事件消息: event={event}, user_id={from_user_id}")
|
||||
@@ -0,0 +1,227 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — AI Wingman API 路由
|
||||
# =============================================================================
|
||||
# 说明:坐席端 AI 智能副驾驶 API,包含 3 个核心端点:
|
||||
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
|
||||
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
|
||||
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
|
||||
#
|
||||
# 所有端点需要坐席认证(get_current_agent)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_wingman_service
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.utils.response import ERR_NOT_FOUND, success_response
|
||||
|
||||
# 复用坐席认证依赖
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _validate_conversation(
|
||||
conversation_id: str,
|
||||
agent: Agent,
|
||||
db: AsyncSession,
|
||||
) -> Conversation:
|
||||
"""验证会话存在性并返回会话对象。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Conversation: 会话对象
|
||||
|
||||
Raises:
|
||||
AppException: 会话不存在
|
||||
"""
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
async def _get_recent_messages(
|
||||
conversation_id: str,
|
||||
db: AsyncSession,
|
||||
limit: int = 20,
|
||||
) -> list[dict]:
|
||||
"""获取会话最近的消息历史(转换为字典列表)。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
limit: 获取的消息条数
|
||||
|
||||
Returns:
|
||||
list[dict]: 消息字典列表
|
||||
"""
|
||||
stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 按时间正序排列(最早的在前)
|
||||
messages.reverse()
|
||||
|
||||
# 转换为字典列表
|
||||
return [
|
||||
{
|
||||
"id": msg.id,
|
||||
"sender_type": msg.sender_type,
|
||||
"sender_name": msg.sender_name,
|
||||
"content": msg.content,
|
||||
"msg_type": msg.msg_type,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else "",
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/draft
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/draft")
|
||||
async def generate_draft(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成 AI 草稿回复。
|
||||
|
||||
基于当前会话的消息历史,让 Wingman Agent 生成坐席可以采纳的草稿回复。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含草稿内容、置信度和推理说明
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的消息历史(最近 20 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=20)
|
||||
|
||||
# 3. 调用 WingmanService 生成草稿
|
||||
result = await wingman_service.generate_draft(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/summary
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/summary")
|
||||
async def generate_summary(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成会话自动摘要。
|
||||
|
||||
基于完整对话生成结构化摘要,包含问题、原因、解决方案。
|
||||
通常在结单时调用。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含问题、原因、解决方案
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的完整消息历史(最多 50 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=50)
|
||||
|
||||
# 3. 调用 WingmanService 生成摘要
|
||||
result = await wingman_service.generate_summary(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/tags
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/tags")
|
||||
async def suggest_tags(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成自动标签建议。
|
||||
|
||||
基于对话内容建议标签分类,包含标签列表、分类和优先级。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含建议标签、分类和优先级
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
conversation = await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的消息历史(最近 20 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=20)
|
||||
|
||||
# 3. 获取已有标签(用于避免重复建议)
|
||||
existing_tags = {}
|
||||
if hasattr(conversation, 'tags') and conversation.tags:
|
||||
existing_tags = conversation.tags if isinstance(conversation.tags, dict) else {}
|
||||
|
||||
# 4. 调用 WingmanService 生成标签建议
|
||||
result = await wingman_service.suggest_tags(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
existing_tags=existing_tags,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
@@ -0,0 +1,80 @@
|
||||
# =============================================================================
|
||||
# 企微智能IT支持服务台 — WebSocket 端点
|
||||
# =============================================================================
|
||||
# 说明:提供 WebSocket 端点,供坐席前端建立长连接,实现实时推送。
|
||||
# 核心功能:
|
||||
# 1. 接受坐席的 WebSocket 连接请求
|
||||
# 2. 维持连接,监听客户端消息(主要是心跳 ping)
|
||||
# 3. 连接断开时自动清理注册信息
|
||||
#
|
||||
# 端点路径:/ws/{agent_id}
|
||||
# 为什么不挂 /api 前缀:WebSocket 不是 REST API,不走 Vite 的 /api 代理配置
|
||||
# 前端通过 /ws/{agent_id} 直接连接(Vite 单独配置了 /ws 的 WebSocket 代理)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/{agent_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, agent_id: str) -> None:
|
||||
"""WebSocket 端点主循环。
|
||||
|
||||
做什么:
|
||||
1. 接受坐席的连接请求,注册到 ConnectionManager
|
||||
2. 进入消息接收循环,处理客户端发送的消息
|
||||
3. 目前客户端只发送心跳 ping,后续可扩展其他消息类型
|
||||
4. 连接断开时(客户端关闭页面/网络中断),清理注册信息
|
||||
|
||||
为什么用 try/except WebSocketDisconnect:
|
||||
- WebSocket 断开是正常行为(坐席关闭页面、刷新、网络波动)
|
||||
- FastAPI 抛出 WebSocketDisconnect 异常,我们需要捕获并做清理
|
||||
- 不能让异常冒泡到全局处理器,否则会打印大量错误日志
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
agent_id: 坐席ID(从 URL 路径参数获取)
|
||||
"""
|
||||
# 注册连接(内部会调用 websocket.accept())
|
||||
await ws_manager.connect(agent_id, websocket)
|
||||
|
||||
try:
|
||||
# 消息接收循环
|
||||
# 保持连接打开,监听客户端发来的消息
|
||||
# 即使客户端不发消息,这个循环也必须保持,否则连接会关闭
|
||||
while True:
|
||||
# 等待接收客户端消息(阻塞等待)
|
||||
data = await websocket.receive_json()
|
||||
|
||||
# 处理心跳 ping
|
||||
# 前端每 30 秒发送一次 ping,后端回复 pong
|
||||
# 作用:检测连接是否存活,防止中间代理(如 Nginx)因超时断开连接
|
||||
if data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
logger.debug(f"WebSocket 心跳: agent_id={agent_id}")
|
||||
else:
|
||||
# 未来可扩展处理其他类型的客户端消息
|
||||
logger.debug(
|
||||
f"WebSocket 收到未知消息: agent_id={agent_id}, "
|
||||
f"type={data.get('type', 'unknown')}"
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# 客户端主动断开连接(正常行为)
|
||||
# 清理 ConnectionManager 中的注册信息
|
||||
ws_manager.disconnect(agent_id)
|
||||
logger.info(f"坐席断开 WebSocket 连接: agent_id={agent_id}")
|
||||
|
||||
except Exception as e:
|
||||
# 其他异常(如网络错误、JSON 解析错误等)
|
||||
# 确保注册信息被清理
|
||||
ws_manager.disconnect(agent_id)
|
||||
logger.warning(f"WebSocket 异常断开: agent_id={agent_id}, error={e}")
|
||||
Reference in New Issue
Block a user