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:
Simon
2026-08-07 22:31:32 +08:00
parent 5a77a89ab1
commit facc04aa65
573 changed files with 129347 additions and 909 deletions
@@ -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 — 获取坐席列表(用于转接选择)
# 坐席认证使用 JWTtoken 存 RedisTTL 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 存 RedisTTL 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 可能是 bytesRedis 返回)或 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 存入 RedisTTL 8小时)。
流程:
1. 查找坐席记录(按 user_id),不存在则自动创建
2. 生成随机 token
3. token 存 Rediskey: 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_idvalue 为 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: 未授权(无有效认证)
"""
# =====================================================================
# 方式1Bearer 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}")
# =====================================================================
# 方式2X-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静默授权URLsnsapi_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 存入 Rediskey: 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 存入 Rediskey: 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} 不存在")
@@ -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}")