chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# =============================================================================
# 企微IT智能服务台 — API 包初始化
# =============================================================================
# 说明:将 api/ 目录标记为 Python 包
# =============================================================================
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
# =============================================================================
# 企微IT智能服务台 — 管理后台角色管理 API
# =============================================================================
# 说明:管理后台的角色管理接口
# 包含:
# 1. 角色管理(CRUD
# 2. 用户角色分配/撤销
# 3. 角色映射规则管理
# 所有接口需要 admin 角色权限
# =============================================================================
import logging
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo, require_role
from app.database import get_db
from app.models.role import Role
from app.models.role_mapping_rule import RoleMappingRule
from app.models.user_role import UserRole
from app.schemas.role import (
RoleAssignRequest,
RoleMappingRuleRequest,
RoleMappingRuleResponse,
RoleRevokeRequest,
RoleResponse,
UserRoleResponse,
)
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
"""脱敏处理敏感数据。
Args:
value: 原始值
visible_chars: 开头保留的字符数
Returns:
str: 脱敏后的值,如 "abc***def"
"""
if not value:
return ""
if len(value) <= visible_chars:
return "*" * len(value)
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
# 创建路由器
router = APIRouter(prefix="/admin/roles")
# --------------------------------------------------------------------------
# 管理后台权限校验依赖
# --------------------------------------------------------------------------
async def require_admin(
current_user: UserInfo = Depends(get_current_user),
) -> UserInfo:
"""管理后台权限校验:仅 admin 角色可访问。
Args:
current_user: 当前用户(通过认证依赖注入)
Returns:
UserInfo: 具有管理权限的用户信息
Raises:
AppException: 非管理员角色(错误码 1004)
"""
if "admin" not in current_user.roles:
raise AppException(1004, "无管理权限")
return current_user
# ==========================================================================
# 1. 角色管理
# ==========================================================================
# ---------- GET /api/admin/roles ----------
@router.get("")
async def get_roles(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色列表。
返回角色列表,包含每个角色的用户数量统计。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色列表
"""
# 查询所有角色
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
result = await db.execute(stmt)
roles = result.scalars().all()
# 构建响应,包含用户数量
role_list = []
for role in roles:
# 统计拥有该角色的用户数
count_stmt = select(func.count()).select_from(UserRole).where(UserRole.role_id == role.id)
count_result = await db.execute(count_stmt)
user_count = count_result.scalar() or 0
role_list.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
user_count=user_count,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
return success_response(data=[r.model_dump() for r in role_list])
# ==========================================================================
# 2. 用户角色分配/撤销
# ==========================================================================
# ---------- POST /api/admin/roles/assign ----------
@router.post("/assign")
async def assign_role(
body: RoleAssignRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""手动分配角色。
为指定用户分配角色,记录分配者和分配原因。
安全限制:禁止管理员给自己分配角色。
Args:
body: 分配角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员给自己分配角色
if body.employee_id == admin.employee_id:
raise AppException(4014, "不能给自己分配角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已拥有该角色
existing_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4009, f"用户已拥有 {body.role_name} 角色")
# 创建用户角色关联
user_role = UserRole(
employee_id=body.employee_id,
role_id=role.id,
source="manual",
assigned_by=admin.employee_id,
)
db.add(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 为用户 {_mask_sensitive_data(body.employee_id)} 分配角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 分配成功")
# ---------- POST /api/admin/roles/revoke ----------
@router.post("/revoke")
async def revoke_role(
body: RoleRevokeRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""撤销角色。
撤销指定用户的角色。
安全限制:禁止管理员撤销自己的角色。
Args:
body: 撤销角色请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 安全限制:禁止管理员撤销自己的角色
if body.employee_id == admin.employee_id:
raise AppException(4015, "不能撤销自己的角色")
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 不允许撤销默认角色
if role.is_default:
raise AppException(4010, "不能撤销默认角色")
# 查询用户角色关联
user_role_stmt = select(UserRole).where(
UserRole.employee_id == body.employee_id,
UserRole.role_id == role.id,
)
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if not user_role:
raise AppException(4011, f"用户没有 {body.role_name} 角色")
# 删除用户角色关联
await db.delete(user_role)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 撤销用户 {_mask_sensitive_data(body.employee_id)} 的角色 {body.role_name},原因:{body.reason}")
return success_response(message=f"角色 {body.role_name} 撤销成功")
# ==========================================================================
# 3. 角色映射规则管理
# ==========================================================================
# ---------- GET /api/admin/roles/mapping-rules ----------
@router.get("/mapping-rules")
async def get_mapping_rules(
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""获取所有角色映射规则。
Args:
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含映射规则列表
"""
# 查询所有映射规则
stmt = (
select(RoleMappingRule, Role)
.join(Role, RoleMappingRule.role_id == Role.id)
.order_by(RoleMappingRule.priority.desc(), RoleMappingRule.source_type)
)
result = await db.execute(stmt)
rules = result.all()
# 构建响应
rule_list = []
for rule, role in rules:
rule_list.append(
RoleMappingRuleResponse(
id=rule.id,
role_id=rule.role_id,
role_name=role.name,
source_type=rule.source_type,
source_value=rule.source_value,
priority=rule.priority,
is_active=rule.is_active,
created_at=rule.created_at,
)
)
return success_response(data=[r.model_dump() for r in rule_list])
# ---------- POST /api/admin/roles/mapping-rules ----------
@router.post("/mapping-rules")
async def create_mapping_rule(
body: RoleMappingRuleRequest,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""创建角色映射规则。
Args:
body: 创建映射规则请求
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含新创建的规则 ID
"""
# 查询目标角色
role_stmt = select(Role).where(Role.name == body.role_name)
role_result = await db.execute(role_stmt)
role = role_result.scalars().first()
if not role:
raise AppException(4004, f"角色 {body.role_name} 不存在")
# 检查是否已存在相同的规则
existing_stmt = select(RoleMappingRule).where(
RoleMappingRule.role_id == role.id,
RoleMappingRule.source_type == body.source_type,
RoleMappingRule.source_value == body.source_value,
)
existing_result = await db.execute(existing_stmt)
existing = existing_result.scalars().first()
if existing:
raise AppException(4012, "已存在相同的映射规则")
# 创建映射规则
rule = RoleMappingRule(
role_id=role.id,
source_type=body.source_type,
source_value=body.source_value,
priority=body.priority,
is_active=body.is_active,
)
db.add(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 创建映射规则:{body.source_type}={body.source_value}{body.role_name}")
return success_response(
message="映射规则创建成功",
data={"id": rule.id},
)
# ---------- DELETE /api/admin/roles/mapping-rules/{rule_id} ----------
@router.delete("/mapping-rules/{rule_id}")
async def delete_mapping_rule(
rule_id: str,
admin: UserInfo = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""删除角色映射规则。
Args:
rule_id: 规则 ID
admin: 管理员(权限校验)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询规则
rule_stmt = select(RoleMappingRule).where(RoleMappingRule.id == rule_id)
rule_result = await db.execute(rule_stmt)
rule = rule_result.scalars().first()
if not rule:
raise AppException(4013, "映射规则不存在")
# 删除规则
await db.delete(rule)
await db.commit()
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
return success_response(message="映射规则删除成功")
+215
View File
@@ -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="删除成功")
+519
View File
@@ -0,0 +1,519 @@
# =============================================================================
# 企微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 base64
import io
import json
import logging
import secrets
from datetime import datetime
from typing import Optional
from uuid import UUID
import pyotp
import qrcode
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Query, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, require_role
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
# 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
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 settings.create_redis_client()
# --------------------------------------------------------------------------
# 坐席认证依赖
# --------------------------------------------------------------------------
async def get_current_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Agent:
"""从请求头中提取坐席身份(认证依赖)。
支持两种 Token 格式:
1. 统一格式:user:token:{token} → JSON 包含 employee_id 和 roles
2. 旧格式:agent:token:{token} → 直接存储 user_id
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:
# 1. 尝试统一格式(新)
unified_data = await redis_client.get(f"user:token:{token}")
if unified_data:
try:
user_info = json.loads(unified_data)
agent_user_id = user_info.get("employee_id")
if agent_user_id:
# 从数据库查找坐席
stmt = select(Agent).where(Agent.user_id == agent_user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if agent:
return agent
except json.JSONDecodeError:
logger.warning(f"统一 Token 数据解析失败: {token[:10]}...")
# 2. 尝试旧格式(兼容)
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")
@limiter.limit("10/minute") # 登录接口限流:每IP每分钟最多10次,防暴力破解
async def agent_login(
request: Request,
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不可达时:仅允许已注册坐席降级登录,新注册必须验证
# 原因:网络故障不应阻断已注册坐席工作,但不能让未验证用户注册新账号
logger.warning(
f"企微通讯录验证失败: user_id={body.user_id}, "
f"error={wecom_err}"
)
# 检查是否为已注册坐席(数据库已有记录才允许降级登录)
check_stmt = select(Agent).where(Agent.user_id == body.user_id)
check_result = await db.execute(check_stmt)
existing_agent = check_result.scalars().first()
if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException(
1003,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
)
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# 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. OTP 二次验证(admin 角色且已绑定 OTP)
if agent.role == "admin" and agent.otp_enabled == 1:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
"user_id": agent.user_id,
"name": agent.name,
})
else:
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入")
# 3. 生成随机 token(使用统一格式)
from app.services.token_service import TokenService
from app.dependencies import get_redis
# 使用共享 Redis 连接(从连接池获取,不要手动关闭)
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 查询用户角色
from app.services.role_mapping_service import RoleMappingService
role_service = RoleMappingService(db)
roles = await role_service.get_user_roles(body.user_id)
# 创建统一格式的 Token
token = await token_service.create_token(
employee_id=body.user_id,
name=body.name,
roles=roles,
login_source="agent",
)
# 5. 返回坐席信息和 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 — 获取坐席列表(需要 agent 或 admin 角色)
# --------------------------------------------------------------------------
@router.get("/agents")
@require_role("agent", "admin")
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})
# --------------------------------------------------------------------------
# OTP 绑定接口
# --------------------------------------------------------------------------
@router.post("/agents/otp-bind")
async def bind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""为当前坐席生成 OTP 密钥和二维码。
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
返回二维码(base64编码)和密钥,供用户手动输入备用。
Returns:
Dict: 二维码图片(base64)和密钥
"""
try:
# 检查是否已绑定
if agent.otp_secret:
# 已绑定,返回现有密钥的二维码
totp = pyotp.TOTP(agent.otp_secret)
else:
# 生成新密钥
secret = pyotp.random_base32()
agent.otp_secret = secret
# otp_enabled 保持 0,等待首次验证后启用
db.add(agent)
await db.flush()
totp = pyotp.TOTP(secret)
# 生成 otpauth:// URI
otpauth_uri = totp.provisioning_uri(
name=f"IT支持服务:{agent.name}",
issuer_name="IT支持服务",
)
# 生成二维码图片
qr = qrcode.make(otpauth_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.otp_secret,
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP绑定异常: {e}", exc_info=True)
raise AppException(1007, f"OTP绑定失败: {str(e)}")
@router.post("/agents/otp-verify")
async def verify_agent_otp(
body: AgentLogin, # 复用 AgentLoginotp_code 为必填
db: AsyncSession = Depends(get_db),
):
"""验证并启用 OTP。
用户输入 OTP 码验证成功后,启用 OTP。
首次验证成功后 otp_enabled 设为 1。
Args:
body.otp_code: 用户输入的 OTP 码(必填)
Returns:
Dict: 验证结果
"""
try:
# 查找坐席
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent or not agent.otp_secret:
raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 OTP
agent.otp_enabled = 1
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={
"otp_enabled": True,
"message": "OTP验证成功,已启用",
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP验证异常: {e}", exc_info=True)
raise AppException(1009, f"OTP验证失败: {str(e)}")
@router.post("/agents/otp-unbind")
async def unbind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""解绑 OTP。
解绑后 otp_secret 和 otp_enabled 都清空。
需要管理员操作。
Returns:
Dict: 解绑结果
"""
try:
agent.otp_secret = None
agent.otp_enabled = 0
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP解绑: agent={agent.user_id}")
return success_response(data={"message": "OTP已解绑"})
except AppException:
raise
except Exception as e:
logger.error(f"OTP解绑异常: {e}", exc_info=True)
raise AppException(1010, f"OTP解绑失败: {str(e)}")
+688
View File
@@ -0,0 +1,688 @@
# =============================================================================
# 企微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,
InviteParticipantRequest,
JoinConversationRequest,
)
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: str,
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: str,
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 = settings.create_redis_client()
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: str,
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: str,
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: str,
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: str,
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: str,
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: str,
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: str,
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)
# =============================================================================
# 邀请功能 APIP0-09~P0-11
# =============================================================================
# 和「摇人」的区别:
# 摇人 (invite) = 坐席 → 坐席协作(collaborating_agent_ids
# 邀请 (invite-participant) = 坐席 → 任意员工/部门(participants
# =============================================================================
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite-participant")
async def invite_participant(
conversation_id: str,
body: InviteParticipantRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席邀请员工/部门加入会话(P0-09 邀请发起)。
权限:只有主责坐席可以发起邀请。
副作用:
- 向被邀请人发送企微卡片通知(含「加入会话」按钮)
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 邀请请求(含被邀请人列表 + 历史共享模式)
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例(发送卡片通知用)
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
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.invite_participants(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
participants=[p.model_dump() for p in body.participants],
history_mode=body.history_mode,
)
# 关闭连接
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}/join — 被邀请人加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/join")
async def join_conversation(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""被邀请人通过链接加入会话(P0-10 加入会话)。
校验:该员工必须在 participants 列表中(被邀请过才能加入)。
副作用:
- 更新参与者的 joined 状态
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 加入请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.join_conversation(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
# --------------------------------------------------------------------------
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
async def remove_participant(
conversation_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""移除参与者(P0-11 参与者管理)。
权限:只有主责坐席可以移除参与者。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
user_id: 被移除的员工UserID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.remove_participant(
conversation_id=conversation_id,
remover_agent_id=current_agent.user_id,
target_user_id=user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave-participant")
async def leave_as_participant(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""参与者主动退出会话。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 退出请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_as_participant(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
+116
View File
@@ -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())
File diff suppressed because it is too large Load Diff
+556
View File
@@ -0,0 +1,556 @@
# =============================================================================
# 企微IT智能服务台 — 消息管理 API
# =============================================================================
# 说明:坐席端的消息管理接口,包括:
# 1. GET /api/conversations/{id}/messages — 获取会话消息列表(分页)
# 2. POST /api/conversations/{id}/messages — 坐席发送消息
# 3. GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# 4. POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# 5. DELETE /api/messages/{id} — 删除消息
# 6. POST /api/conversations/{id}/mark-read — 标记已读
# 7. POST /api/messages/image — 上传图片
# 8. POST /api/messages/file — 上传文件
# 消息发送需同时:存数据库 + 调用企微API发送给员工
# =============================================================================
import logging
import os
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, File, Query, UploadFile
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.schemas.message import MessageCreate, MessageResponse
from app.api.agents import get_current_agent
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()
# 文件大小限制:10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
# 可撤回时间窗口:2分钟
RECALLABLE_WINDOW_MINUTES = 2
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages — 获取会话消息列表
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
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: str,
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"
# 计算可撤回截止时间
recallable_until = datetime.now() + timedelta(minutes=RECALLABLE_WINDOW_MINUTES)
message = Message(
conversation_id=conv_id_str,
sender_type="agent",
sender_id=agent_id,
sender_name="", # 坐席姓名,后续从坐席信息补充
content=body.content,
msg_type=body.msg_type,
# M1 新增:文件上传相关字段
media_url=body.media_url,
file_name=body.file_name,
file_size=body.file_size,
# M1 新增:引用回复
reply_to_id=body.reply_to_id,
status="sending", # 初始状态为发送中
recallable_until=recallable_until,
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 发送消息给员工
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
# 跳过 Redis 连可避免无谓的网络开销,减少截图发送超时
if body.msg_type == "text":
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
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}")
# 5. 更新消息状态为已发送
message.status = "sent"
await db.flush()
# 转换为响应格式
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: str,
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_message 的创建时间
# 注意:确保用字符串比较,避免SQLAlchemy把参数转成UUID导致类型不匹配
after_stmt = select(Message.created_at).where(
Message.id == str(after_message_id)
)
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 Exception:
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, # 轮询接口不需要分页
}
)
# --------------------------------------------------------------------------
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# --------------------------------------------------------------------------
@router.post("/messages/{message_id}/recall")
async def recall_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""撤回消息(2分钟内)。
仅可撤回2分钟内坐席自己发送的消息。
P0-2 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可调用此端点修改任意消息。
现在依赖 get_current_agent 校验登录态,再校验 message.sender_id
是否等于当前坐席的 user_id,防止越权撤回他人消息。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# 校验是否是坐席发送的消息
if message.sender_type != "agent":
raise AppException(code=403, message="只能撤回坐席发送的消息")
# P0-2 修复:校验是否是当前坐席自己发的
if message.sender_id != agent.user_id:
raise AppException(code=403, message="只能撤回自己的消息")
# 校验是否在可撤回时间窗口内
if message.recallable_until and datetime.now() > message.recallable_until:
raise AppException(code=403, message="消息已超过2分钟,无法撤回")
# 将消息内容置为空,表示已撤回
message.content = "[消息已撤回]"
message.status = "recalled"
await db.flush()
return success_response(message="消息撤回成功")
# --------------------------------------------------------------------------
# DELETE /api/messages/{id} — 删除消息
# --------------------------------------------------------------------------
@router.delete("/messages/{message_id}")
async def delete_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""删除坐席自己发送的消息。
P0-3 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可删除任意消息。
现在依赖 get_current_agent 校验登录态,再校验消息是否属于当前坐席,
防止越权删除他人/会话历史。
Args:
message_id: 消息ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 查询消息
stmt = select(Message).where(Message.id == str(message_id))
result = await db.execute(stmt)
message = result.scalars().first()
if not message:
raise AppException(code=404, message="消息不存在")
# P0-3 修复:仅允许坐席删除自己发送的消息
if message.sender_type != "agent" or message.sender_id != agent.user_id:
raise AppException(code=403, message="只能删除自己发送的消息")
# 删除消息
await db.delete(message)
await db.flush()
return success_response(message="消息删除成功")
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/mark-read — 标记已读
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/mark-read")
async def mark_read(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""标记会话中所有员工未读消息为已读。
P0-4 安全修复(2026-06-14 评审):
此前完全无鉴权,任意 HTTP 客户端可标记任意会话为已读,
会破坏"未读消息数"业务统计。
现在依赖 get_current_agent 校验登录态,再校验当前坐席是会话的
主责或协作坐席才允许标记,防止越权篡改未读状态。
P2-3 修复:原 `.where(Message.is_read == False)` 是 Python 表达式比较
永远为 False(不抛错但实际未过滤),SQLAlchemy 也会当成赋值表达式
处理;改为 `is_(False)` 走 SQL 否定。
Args:
conversation_id: 会话ID
agent: 当前坐席(鉴权依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
conv_id_str = str(conversation_id)
# P0-4 修复:先校验当前坐席有权访问此会话
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
is_assigned = conversation.assigned_agent_id == agent.user_id
is_collaborator = agent.user_id in (conversation.collaborating_agent_ids or [])
if not (is_assigned or is_collaborator):
raise AppException(code=403, message="您不是该会话的坐席,无权操作")
# P2-3 修复:使用 is_(False) 而非 == False
# 更新该会话的所有员工未读消息为已读
stmt = (
update(Message)
.where(Message.conversation_id == conv_id_str)
.where(Message.sender_type == "employee")
.where(Message.is_read.is_(False))
.values(is_read=True, status="read")
)
await db.execute(stmt)
await db.flush()
return success_response(message="标记已读成功")
# --------------------------------------------------------------------------
# POST /api/messages/image — 上传图片
# --------------------------------------------------------------------------
@router.post("/messages/image")
async def upload_image(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传图片文件。
文件大小限制:10MB
Args:
file: 图片文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校验文件大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 校验文件类型
allowed_types = ["image/jpeg", "image/png", "image/gif", "image/webp"]
content_type = file.content_type
if content_type not in allowed_types:
raise AppException(code=400, message="不支持的图片格式")
# 生成保存路径
import uuid as uuid_module
file_ext = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "images")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/images/{file_name}"
return success_response(
data={
"url": file_url,
"filename": file_name,
"file_size": file_size,
"content_type": content_type,
}
)
# --------------------------------------------------------------------------
# POST /api/messages/file — 上传文件
# --------------------------------------------------------------------------
@router.post("/messages/file")
async def upload_message_file(
file: UploadFile = File(...),
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""上传普通文件。
文件大小限制:10MB
Args:
file: 文件
db: 数据库会话
Returns:
Dict: 统一响应格式,包含文件URL和元数据
"""
# 校大小
file.file.seek(0, 2)
file_size = file.file.tell()
file.file.seek(0)
if file_size > MAX_FILE_SIZE:
raise AppException(code=400, message=f"文件大小超过10MB限制")
# 生成保存路径
import uuid as uuid_module
original_name = file.filename or "file"
file_ext = os.path.splitext(original_name)[1]
file_name = f"{uuid_module.uuid4()}{file_ext}"
upload_dir = os.path.join("media", "files")
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, file_name)
# 保存文件
content = await file.read()
with open(file_path, "wb") as f:
f.write(content)
# 返回文件URL
file_url = f"/media/files/{file_name}"
return success_response(
data={
"url": file_url,
"filename": original_name,
"file_size": file_size,
"content_type": file.content_type,
}
)
+249
View File
@@ -0,0 +1,249 @@
# =============================================================================
# 企微IT智能服务台 — Portal 统一入口 API
# =============================================================================
# 说明:统一入口(Portal)相关接口
# 包含:
# 1. 获取当前用户角色信息
# 2. 切换当前角色
# 3. 获取角色对应的入口 URL
# 所有接口需要有效的 Bearer Token
# =============================================================================
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, UserInfo
from app.config import settings
from app.database import get_db
from app.models.role import Role
from app.models.user_role import UserRole
from app.schemas.role import (
PortalUserInfo,
RoleResponse,
SwitchRoleRequest,
SwitchRoleResponse,
)
from app.services.token_service import TokenService
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# HTTP Bearer 认证方案
security = HTTPBearer()
# 创建路由器
router = APIRouter(prefix="/portal")
# --------------------------------------------------------------------------
# 获取当前用户角色信息
# --------------------------------------------------------------------------
@router.get("/roles")
async def get_user_roles(
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的角色信息。
返回用户的基本信息和角色列表,用于路由选择页展示。
Args:
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含用户信息和角色列表
"""
# 查询用户拥有的角色
stmt = (
select(Role, UserRole)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(
# 过滤已过期的角色
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > func.now())
)
)
result = await db.execute(stmt)
role_rows = result.all()
# 构建角色列表
roles = []
for role, user_role in role_rows:
roles.append(
RoleResponse(
id=role.id,
name=role.name,
display_name=role.display_name,
description=role.description,
permissions=role.permissions or [],
is_default=role.is_default,
created_at=role.created_at,
updated_at=role.updated_at,
)
)
# 如果用户没有任何角色,添加默认的 user 角色
if not roles:
# 查询 user 角色
user_role_stmt = select(Role).where(Role.name == "user")
user_role_result = await db.execute(user_role_stmt)
user_role = user_role_result.scalars().first()
if user_role:
roles.append(
RoleResponse(
id=user_role.id,
name=user_role.name,
display_name=user_role.display_name,
description=user_role.description,
permissions=user_role.permissions or [],
is_default=user_role.is_default,
created_at=user_role.created_at,
updated_at=user_role.updated_at,
)
)
# 构建响应
user_info = PortalUserInfo(
employee_id=current_user.employee_id,
name=current_user.name,
department=current_user.department,
avatar=current_user.avatar,
roles=roles,
current_role=current_user.current_role,
)
return success_response(data=user_info.model_dump())
# --------------------------------------------------------------------------
# 切换当前角色
# --------------------------------------------------------------------------
@router.post("/switch-role")
async def switch_role(
body: SwitchRoleRequest,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""切换当前角色。
更新 Redis Token 中的 current_role 字段,返回目标角色的入口 URL。
Args:
body: 切换角色请求
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含切换后的角色和重定向 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == body.new_role)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {body.new_role} 角色权限")
# 更新 Redis Token 中的 current_role
from app.dependencies import get_redis
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 从请求头获取 token
token = credentials.credentials
switch_success = await token_service.switch_role(token, body.new_role)
if not switch_success:
raise AppException(4003, "角色切换失败")
# 获取目标角色的入口 URL
redirect_url = _get_role_url(body.new_role)
logger.info(f"用户 {current_user.employee_id} 切换角色到 {body.new_role}")
return success_response(
data=SwitchRoleResponse(
current_role=body.new_role,
redirect_url=redirect_url,
).model_dump()
)
# --------------------------------------------------------------------------
# 获取角色对应的入口 URL
# --------------------------------------------------------------------------
@router.get("/entry/{role_name}")
async def get_role_entry(
role_name: str,
current_user: UserInfo = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取角色对应的入口 URL。
Args:
role_name: 角色标识
current_user: 当前用户(通过认证依赖注入)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含角色信息和入口 URL
"""
# 验证用户是否有目标角色
stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == current_user.employee_id)
.where(Role.name == role_name)
)
result = await db.execute(stmt)
target_role = result.scalars().first()
if not target_role:
raise AppException(4003, f"没有 {role_name} 角色权限")
# 获取入口 URL
redirect_url = _get_role_url(role_name)
return success_response(
data={
"role": role_name,
"url": redirect_url,
"display_name": target_role.display_name,
}
)
# --------------------------------------------------------------------------
# 辅助函数:获取角色对应的 URL
# --------------------------------------------------------------------------
def _get_role_url(role_name: str) -> str:
"""获取角色对应的前端 URL。
Args:
role_name: 角色标识
Returns:
str: 前端 URL
"""
role_urls = {
"user": "/itdesk/",
"agent": "/itagent/",
"admin": "/itadmin/",
}
return role_urls.get(role_name, "/itdesk/")
+256
View File
@@ -0,0 +1,256 @@
# =============================================================================
# 企微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, Header, Query
from sqlalchemy import or_, and_
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
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, ERR_UNAUTHORIZED, success_response
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 可选坐席认证(有 token 则认证,无 token 则跳过)
# --------------------------------------------------------------------------
async def get_optional_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Optional[Agent]:
"""可选坐席认证依赖。
有 Authorization 头时尝试认证,无或认证失败时返回 None。
Args:
authorization: 请求头中的 Authorization 字段
db: 数据库会话
Returns:
Optional[Agent]: 认证成功返回坐席对象,否则返回 None
"""
if not authorization:
return None
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
return None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
try:
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
return None
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()
return agent
finally:
try:
await redis_client.close()
except Exception:
pass
except Exception as e:
logger.warning(f"可选坐席认证失败: {e}")
return None
# --------------------------------------------------------------------------
# GET /api/quick-replies — 获取模板列表
# --------------------------------------------------------------------------
@router.get("/quick-replies")
async def list_quick_replies(
category: Optional[str] = Query(None, description="按分类过滤: 账号/网络/软件/硬件/通用"),
db: AsyncSession = Depends(get_db),
agent: Optional[Agent] = Depends(get_optional_agent),
):
"""获取快速回复模板列表。
支持按分类过滤,按 sort_order 排序。
坐席端可见性规则:
- 有认证:返回 approved + 自己的 pending_review
- 无认证:只返回 approved
Args:
category: 按分类过滤(可选)
db: 数据库会话
agent: 当前坐席(可选认证)
Returns:
Dict: 统一响应格式,包含模板列表
"""
stmt = select(QuickReplyTemplate).order_by(
QuickReplyTemplate.category, QuickReplyTemplate.sort_order
)
if category:
stmt = stmt.where(QuickReplyTemplate.category == category)
# 状态筛选:坐席端可见性规则
if agent:
# 有认证:approved + 自己的 pending_review
stmt = stmt.where(
or_(
QuickReplyTemplate.status == "approved",
and_(
QuickReplyTemplate.status == "pending_review",
QuickReplyTemplate.submitted_by == agent.id,
),
)
)
else:
# 无认证:只返回 approved
stmt = stmt.where(QuickReplyTemplate.status == "approved")
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="删除成功")
+157
View File
@@ -0,0 +1,157 @@
# =============================================================================
# 企微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
from app.api.upload import router as upload_router
from app.api.admin import router as admin_router
from app.api.portal import router as portal_router
from app.api.admin_roles import router as admin_roles_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=["员工管理"])
# 文件上传 API
# POST /api/upload — 上传文件(图片/文档)
# GET /api/media/{year}/{month}/{day}/{filename} — 访问上传的文件
api_router.include_router(upload_router, tags=["文件上传"])
# 管理后台 API
# GET /api/admin/dashboard/overview — 仪表盘统计
# GET /api/admin/configs — 获取配置分组
# PUT /api/admin/configs/{key} — 更新配置项
# GET /api/admin/configs/{key}/history — 配置变更历史
# GET /api/admin/agents — 坐席列表(管理视图)
# POST /api/admin/agents — 添加坐席
# PUT /api/admin/agents/{id} — 编辑坐席
# DELETE /api/admin/agents/{id} — 移除坐席
# GET /api/admin/integrations — 集成系统列表
# PUT /api/admin/integrations/{id} — 更新集成配置
# GET /api/admin/quick-replies/pending — 待审核快速回复
# PUT /api/admin/quick-replies/{id}/review — 审核快速回复
# GET /api/admin/assignment-mode — 获取分配模式
# PUT /api/admin/assignment-mode — 切换分配模式
# GET /api/admin/monitor/sessions — 会话监控
# GET /api/admin/search — 全局搜索
api_router.include_router(admin_router, tags=["管理后台"])
# Portal 统一入口 API
# GET /api/portal/roles — 获取当前用户角色信息
# POST /api/portal/switch-role — 切换当前角色
# GET /api/portal/entry/{role} — 获取角色对应的入口 URL
api_router.include_router(portal_router, tags=["统一入口"])
# 管理后台角色管理 API
# GET /api/admin/roles — 获取所有角色
# POST /api/admin/roles/assign — 分配角色
# POST /api/admin/roles/revoke — 撤销角色
# GET /api/admin/roles/mapping-rules — 获取映射规则
# POST /api/admin/roles/mapping-rules — 创建映射规则
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
api_router.include_router(admin_roles_router, tags=["角色管理"])
+130
View File
@@ -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}",
}
)
+439
View File
@@ -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} 不存在")
+206
View File
@@ -0,0 +1,206 @@
# =============================================================================
# 企微IT智能服务台 — 文件上传 API
# =============================================================================
# 说明:处理图片/文件上传,保存到服务器本地存储
# 1. POST /api/upload — 上传文件(图片/文件),返回文件URL
# 2. GET /api/media/{path} — 静态文件服务(开发环境)
# 文件存储路径:./uploads/YYYY/MM/DD/{uuid}.{ext}
# =============================================================================
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from app.utils.response import success_response
from app.api.h5 import _get_current_employee
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# 文件存储配置
# --------------------------------------------------------------------------
# 上传文件的根目录(Docker 环境中映射为 Volume 持久化存储)
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
# 允许上传的文件扩展名(白名单,防止上传可执行文件等危险文件)
ALLOWED_EXTENSIONS = {
# 图片
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg",
# 文档
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"txt", "csv", "md", "rtf",
# 压缩包
"zip", "rar", "7z", "tar", "gz",
# 其他
"log", "json", "xml", "yaml", "yml",
}
# 单文件最大大小(默认 20MB
MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(20 * 1024 * 1024))) # 20MB
# 图片最大大小(默认 10MB
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", str(10 * 1024 * 1024))) # 10MB
# 图片类型扩展名集合
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"}
def _get_file_extension(filename: str) -> str:
"""从文件名中提取小写扩展名。
Args:
filename: 原始文件名
Returns:
str: 小写扩展名(不含点号),如 "png"
"""
# os.path.splitext 返回 (root, ext)ext 含点号如 ".png"
ext = os.path.splitext(filename)[1].lower().lstrip(".")
return ext or "bin" # 无扩展名时默认 bin
def _generate_storage_path(extension: str) -> tuple[Path, str]:
"""生成文件存储路径(按日期分目录)。
目录结构:uploads/YYYY/MM/DD/{uuid}.{ext}
同时返回完整本地路径和用于API访问的相对URL路径。
Args:
extension: 文件扩展名(如 "png"
Returns:
tuple: (本地文件完整路径, API访问的URL路径)
"""
now = datetime.now()
# 按日期建子目录,方便按时间归档和清理
date_dir = UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
# 确保目录存在(exist_ok=True 避免并发创建时报错)
date_dir.mkdir(parents=True, exist_ok=True)
# 使用 UUID 避免文件名冲突和安全风险(不使用原始文件名存储)
file_id = uuid.uuid4().hex[:12] # 12位足够短且唯一
filename = f"{file_id}.{extension}"
local_path = date_dir / filename
# URL 路径:/api/media/YYYY/MM/DD/{uuid}.{ext}
url_path = f"/api/media/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
return local_path, url_path
# --------------------------------------------------------------------------
# POST /api/upload — 上传文件
# --------------------------------------------------------------------------
@router.post("/upload")
async def upload_file(
file: UploadFile = File(..., description="上传的文件(图片或文档)"),
employee_id: str = Depends(_get_current_employee),
):
"""上传文件到服务器。
处理流程:
1. 校验文件扩展名(白名单)
2. 校验文件大小(图片10MB,其他20MB)
3. 按日期目录存储文件
4. 返回文件访问URL
Args:
file: FastAPI UploadFile 对象
Returns:
Dict: 统一响应格式,包含文件URL、文件名、文件大小、文件类型
"""
# 1. 提取并校验文件扩展名
ext = _get_file_extension(file.filename or "unknown")
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件类型: .{ext},允许的类型: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
# 2. 读取文件内容并校验大小
content = await file.read()
file_size = len(content)
# 图片和普通文件分别校验大小
is_image = ext in IMAGE_EXTENSIONS
max_size = MAX_IMAGE_SIZE if is_image else MAX_FILE_SIZE
size_label = "10MB" if is_image else "20MB"
if file_size > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小 {file_size / 1024 / 1024:.1f}MB 超过限制({size_label}",
)
# 3. 生成存储路径并保存文件
local_path, url_path = _generate_storage_path(ext)
try:
# 以二进制模式写入文件
with open(local_path, "wb") as f:
f.write(content)
except OSError as e:
logger.error(f"文件保存失败: {e}")
raise HTTPException(status_code=500, detail="文件保存失败,请重试")
# 4. 返回文件信息
logger.info(f"文件上传成功: {url_path} ({file_size} bytes, {file.filename})")
return success_response(data={
"url": url_path, # 文件访问URL(前端用于展示/下载)
"filename": file.filename, # 原始文件名(显示用)
"file_size": file_size, # 文件大小(字节)
"msg_type": "image" if is_image else "file", # 消息类型(前端根据此字段区分展示)
"extension": ext, # 文件扩展名
})
# --------------------------------------------------------------------------
# GET /api/media/{year}/{month}/{day}/{filename} — 静态文件服务
# --------------------------------------------------------------------------
# 注意:生产环境由 Nginx 直接提供静态文件服务(性能更好)
# 此接口仅用于开发环境,或 Nginx 未配置静态文件时的降级方案
@router.get("/media/{year}/{month}/{day}/{filename}")
async def serve_media_file(
year: str,
month: str,
day: str,
filename: str,
):
"""提供上传文件的静态访问。
开发环境使用 FastAPI 直接返回文件;
生产环境建议 Nginx 配置 location /api/media/ 直接代理到 uploads 目录。
Args:
year: 年份(路径参数)
month: 月份(路径参数)
day: 日期(路径参数)
filename: 文件名(路径参数)
Returns:
FileResponse: 文件响应
"""
file_path = UPLOAD_DIR / year / month / day / filename
# 安全检查:防止路径遍历攻击(如 ../../etc/passwd
# resolve() 解析符号链接和 .. ,然后检查是否在 UPLOAD_DIR 内
try:
resolved = file_path.resolve()
upload_root = UPLOAD_DIR.resolve()
if not str(resolved).startswith(str(upload_root)):
raise HTTPException(status_code=403, detail="禁止访问")
except (ValueError, OSError):
raise HTTPException(status_code=403, detail="禁止访问")
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
# FileResponse 自动根据扩展名设置 Content-Type
return FileResponse(file_path)
+276
View File
@@ -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}")
+227
View File
@@ -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)
+278
View File
@@ -0,0 +1,278 @@
# =============================================================================
# 企微IT智能服务台 — WebSocket 端点
# =============================================================================
# 说明:提供 WebSocket 端点,供坐席前端和H5用户端建立长连接,实现实时推送。
# 核心功能:
# 1. 接受坐席的 WebSocket 连接请求(含 token 认证)— /ws/{agent_id}
# 2. 接受H5员工的 WebSocket 连接请求(含 token 认证)— /ws/h5/{employee_id}
# 3. 维持连接,监听客户端消息(主要是心跳 ping)
# 4. 连接断开时自动清理注册信息
# 安全(WS-01):
# 握手时从 query param 取 token → 查 Redis 验证 → 不通过则 close(code=4001)
# 防止未授权用户冒充坐席/员工建立 WS 连接
#
# 端点路径:
# - 坐席端:/ws/{agent_id}?token=xxx
# - H5员工端:/ws/h5/{employee_id}?token=xxx
# 为什么不挂 /api 前缀:WebSocket 不是 REST API,不走 Vite 的 /api 代理配置
# =============================================================================
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.services.ws_manager import manager as ws_manager
from app.services.cache_service import cache_service
logger = logging.getLogger(__name__)
# WebSocket 路由器(不挂 /api 前缀,直接注册在应用根路径)
router = APIRouter()
# 认证失败时的 WebSocket 关闭码
# 4001 = 自定义码,表示"未授权"(4000+ 为应用自定义范围)
WS_CLOSE_UNAUTHORIZED = 4001
@router.websocket("/ws/{agent_id}")
async def websocket_endpoint(
websocket: WebSocket,
agent_id: str,
token: str = Query(default="", description="登录 token,用于 WebSocket 认证"),
) -> None:
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
做什么:
1. 验证 token 有效性(查 Redis
2. 验证 token 与 agent_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager
4. 进入消息接收循环,处理客户端发送的消息
5. 连接断开时清理注册信息
为什么需要 token 认证(WS-01):
- 之前 /ws/{agent_id} 无任何认证,任何人知道 URL 即可冒充任意坐席
- 攻击者可监听所有消息、发送伪造消息,是 P0 级安全漏洞
- 修复后,必须提供与 agent_id 匹配的有效 token 才能建立连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
agent_id: 坐席ID(从 URL 路径参数获取)
token: 登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# WS-01: Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
# 先 accept 再 close,否则客户端收不到关闭帧
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的坐席信息
# Redis 中存储格式: agent:token:{token} -> agent_user_id
# (与坐席登录 API /api/agents/login 存储格式一致)
try:
stored_agent_id = await cache_service.get(f"agent:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接:token 验证依赖 Redis,无法验证身份
# 如果降级放行,攻击者可在 Redis 故障时用任意 agent_id 冒充坐席
logger.error(f"Redis 查询失败,拒绝 WS 连接: agent_id={agent_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 agent_id 一致性
if not stored_agent_id:
# token 不存在(已过期或伪造)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, 原因=token无效或已过期")
return
if stored_agent_id != agent_id:
# token 对应的坐席与请求的 agent_id 不匹配(冒充)
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-agent mismatch")
logger.warning(
f"WebSocket 拒绝连接: agent_id={agent_id}, "
f"原因=token对应坐席{stored_agent_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册连接(内部会调用 websocket.accept()
await ws_manager.connect(agent_id, websocket)
logger.info(f"坐席 WebSocket 连接已认证: agent_id={agent_id}")
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}")
# 处理输入指示器 typing 事件
# 前端在用户输入时发送 typing 事件,后端广播给同一会话的其他参与者
elif data.get("type") == "typing":
conversation_id = data.get("conversation_id")
sender_name = data.get("sender_name", agent_id)
if conversation_id:
# 广播给所有坐席(包含 sender_type 和 sender_id
# 前端可据此过滤掉自己的 typing 事件)
await ws_manager.broadcast({
"type": "typing",
"data": {
"conversation_id": conversation_id,
"sender_id": agent_id,
"sender_name": sender_name,
"sender_type": "agent",
}
})
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}")
# ==========================================================================
# H5员工 WebSocket 端点
# ==========================================================================
@router.websocket("/ws/h5/{employee_id}")
async def h5_websocket_endpoint(
websocket: WebSocket,
employee_id: str,
token: str = Query(default="", description="H5员工登录 token,用于 WebSocket 认证"),
) -> None:
"""H5员工 WebSocket 端点主循环(含 token 认证)。
做什么:
1. 验证 employee token 有效性(查 Redis
2. 验证 token 与 employee_id 一致性(防冒充)
3. 认证通过后接受连接,注册到 ConnectionManager 的员工连接表
4. 进入消息接收循环,处理心跳 ping
5. 连接断开时清理注册信息
为什么需要 H5 WS 连接:
- H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等)
- 当前仅通过 3 秒轮询获取更新,实时性不足
- WS 推送 + 轮询降级,双通道保证消息可达
认证机制(与坐席端一致):
- Redis 中存储格式: employee:token:{token} -> employee_id
- (与H5登录 API /api/h5/mock-login 存储格式一致)
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
Args:
websocket: FastAPI WebSocket 对象(框架自动注入)
employee_id: 员工企微 UserID(从 URL 路径参数获取)
token: H5员工登录 token(从 URL query parameter 获取)
"""
# ======================================================================
# Token 认证
# ======================================================================
# 步骤1: 检查 token 是否为空
if not token:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=缺少token")
return
# 步骤2: 从 Redis 查询 token 对应的员工信息
# Redis 中存储格式: employee:token:{token} -> employee_id
# (与H5登录 API /api/h5/mock-login 存储格式一致)
try:
stored_employee_id = await cache_service.get(f"employee:token:{token}")
except Exception as e:
# Redis 不可用时必须拒绝连接(与坐席端一致的安全策略)
logger.error(f"Redis 查询失败,拒绝 H5 WS 连接: employee_id={employee_id}, error={e}")
await websocket.accept()
await websocket.close(
code=WS_CLOSE_UNAUTHORIZED,
reason="Authentication service unavailable"
)
return
# 步骤3: 验证 token 与 employee_id 一致性
if not stored_employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid or expired token")
logger.warning(f"H5 WebSocket 拒绝连接: employee_id={employee_id}, 原因=token无效或已过期")
return
if stored_employee_id != employee_id:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Token-employee mismatch")
logger.warning(
f"H5 WebSocket 拒绝连接: employee_id={employee_id}, "
f"原因=token对应员工{stored_employee_id}与请求不匹配"
)
return
# ======================================================================
# 认证通过,建立连接
# ======================================================================
# 注册员工连接(内部会调用 websocket.accept()
await ws_manager.connect_employee(employee_id, websocket)
logger.info(f"H5员工 WebSocket 连接已认证: employee_id={employee_id}")
try:
# 消息接收循环
# H5员工端目前只发送心跳 ping,不需要发送 typing 等事件
while True:
data = await websocket.receive_json()
# 处理心跳 ping
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
logger.debug(f"H5 WebSocket 心跳: employee_id={employee_id}")
else:
logger.debug(
f"H5 WebSocket 收到未知消息: employee_id={employee_id}, "
f"type={data.get('type', 'unknown')}"
)
except WebSocketDisconnect:
# 客户端主动断开连接
ws_manager.disconnect_employee(employee_id)
logger.info(f"H5员工断开 WebSocket 连接: employee_id={employee_id}")
except Exception as e:
# 其他异常
ws_manager.disconnect_employee(employee_id)
logger.warning(f"H5 WebSocket 异常断开: employee_id={employee_id}, error={e}")