chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — API 包初始化
|
||||
# =============================================================================
|
||||
# 说明:将 api/ 目录标记为 Python 包
|
||||
# =============================================================================
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="映射规则删除成功")
|
||||
@@ -0,0 +1,215 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 坐席备注 API
|
||||
# =============================================================================
|
||||
# 说明:坐席端的备注管理接口,包括:
|
||||
# 1. GET /api/agent-notes/{employee_id} — 获取员工的所有备注
|
||||
# 2. POST /api/agent-notes — 添加备注
|
||||
# 3. PUT /api/agent-notes/{id} — 更新备注
|
||||
# 4. DELETE /api/agent-notes/{id} — 删除备注
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent_note import AgentNote
|
||||
from app.models.conversation import Conversation
|
||||
from app.utils.response import AppException, ERR_NOT_FOUND, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/agent-notes/{employee_id} — 获取员工的所有备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/agent-notes/{employee_id}")
|
||||
async def list_agent_notes(
|
||||
employee_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取员工的所有备注。
|
||||
|
||||
通过员工ID查找其所有会话的备注。
|
||||
用于坐席端用户信息面板展示。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含备注列表
|
||||
"""
|
||||
# 查找该员工所有会话的备注
|
||||
stmt = (
|
||||
select(AgentNote)
|
||||
.join(Conversation, AgentNote.conversation_id == Conversation.id)
|
||||
.where(Conversation.employee_id == employee_id)
|
||||
.order_by(AgentNote.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
notes = list(result.scalars().all())
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
for note in notes
|
||||
]
|
||||
|
||||
return success_response(data={"items": items})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/agent-notes — 添加备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/agent-notes")
|
||||
async def create_agent_note(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""添加坐席备注。
|
||||
|
||||
Args:
|
||||
body: 备注请求体(包含 conversation_id, agent_id, content)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含创建的备注
|
||||
"""
|
||||
conversation_id = body.get("conversation_id", "")
|
||||
agent_id = body.get("agent_id", "")
|
||||
content = body.get("content", "")
|
||||
|
||||
if not conversation_id or not agent_id or not content:
|
||||
raise AppException(1001, "缺少必要参数: conversation_id, agent_id, content")
|
||||
|
||||
# 校验会话存在
|
||||
try:
|
||||
conv_uuid = UUID(conversation_id)
|
||||
except ValueError:
|
||||
raise AppException(1001, "无效的 conversation_id 格式")
|
||||
|
||||
conv_stmt = select(Conversation).where(Conversation.id == conv_uuid)
|
||||
conv_result = await db.execute(conv_stmt)
|
||||
if not conv_result.scalars().first():
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 创建备注
|
||||
note = AgentNote(
|
||||
conversation_id=conv_uuid,
|
||||
agent_id=agent_id,
|
||||
content=content,
|
||||
)
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"添加坐席备注: conv_id={conversation_id}, agent={agent_id}")
|
||||
|
||||
note_data = {
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
|
||||
return success_response(data=note_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/agent-notes/{id} — 更新备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/agent-notes/{note_id}")
|
||||
async def update_agent_note(
|
||||
note_id: UUID,
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新坐席备注。
|
||||
|
||||
Args:
|
||||
note_id: 备注ID
|
||||
body: 更新请求体(包含 content)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的备注
|
||||
"""
|
||||
# 查找备注
|
||||
stmt = select(AgentNote).where(AgentNote.id == note_id)
|
||||
result = await db.execute(stmt)
|
||||
note = result.scalars().first()
|
||||
|
||||
if not note:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 更新内容
|
||||
content = body.get("content")
|
||||
if content is not None:
|
||||
note.content = content
|
||||
note.updated_at = datetime.now()
|
||||
|
||||
db.add(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"更新坐席备注: id={note_id}")
|
||||
|
||||
note_data = {
|
||||
"id": str(note.id),
|
||||
"conversation_id": str(note.conversation_id),
|
||||
"agent_id": note.agent_id,
|
||||
"content": note.content,
|
||||
"created_at": note.created_at.isoformat() if note.created_at else "",
|
||||
"updated_at": note.updated_at.isoformat() if note.updated_at else "",
|
||||
}
|
||||
|
||||
return success_response(data=note_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# DELETE /api/agent-notes/{id} — 删除备注
|
||||
# --------------------------------------------------------------------------
|
||||
@router.delete("/agent-notes/{note_id}")
|
||||
async def delete_agent_note(
|
||||
note_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除坐席备注。
|
||||
|
||||
Args:
|
||||
note_id: 备注ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 查找备注
|
||||
stmt = select(AgentNote).where(AgentNote.id == note_id)
|
||||
result = await db.execute(stmt)
|
||||
note = result.scalars().first()
|
||||
|
||||
if not note:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
# 物理删除
|
||||
await db.delete(note)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"删除坐席备注: id={note_id}")
|
||||
|
||||
return success_response(data=None, message="删除成功")
|
||||
@@ -0,0 +1,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 — 获取坐席列表(用于转接选择)
|
||||
# 坐席认证使用 JWT,token 存 Redis(TTL 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 存 Redis,TTL 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 可能是 bytes(Redis 返回)或 str
|
||||
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
|
||||
stmt = select(Agent).where(Agent.user_id == uid)
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent:
|
||||
raise ERR_UNAUTHORIZED
|
||||
|
||||
return agent
|
||||
|
||||
except AppException:
|
||||
# 业务异常直接抛出(如 ERR_UNAUTHORIZED)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Redis 连接失败等底层异常
|
||||
logger.error(f"Redis 读取失败: {e}")
|
||||
raise ERR_UNAUTHORIZED
|
||||
finally:
|
||||
try:
|
||||
await redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/agents/login — 坐席登录
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/agents/login")
|
||||
@limiter.limit("10/minute") # 登录接口限流:每IP每分钟最多10次,防暴力破解
|
||||
async def agent_login(
|
||||
request: Request,
|
||||
body: AgentLogin,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席登录。
|
||||
|
||||
第一步使用简单的用户名密码登录。
|
||||
登录成功后生成 token 存入 Redis(TTL 8小时)。
|
||||
|
||||
流程:
|
||||
1. 查找坐席记录(按 user_id),不存在则自动创建
|
||||
2. 生成随机 token
|
||||
3. token 存 Redis(key: agent:token:{token}, value: user_id)
|
||||
4. 更新坐席状态为 online
|
||||
5. 返回坐席信息和 token
|
||||
|
||||
Args:
|
||||
body: 登录请求体(包含 user_id 和 name)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含坐席信息和 token
|
||||
"""
|
||||
try:
|
||||
# 0. 企微通讯录身份验证(防止任意 user_id 冒充坐席)
|
||||
# 调用企微API校验 user_id 是否存在于通讯录中
|
||||
# 安全策略:
|
||||
# - 企微验证通过 → 正常登录,用企微真实姓名覆盖前端传入值
|
||||
# - 企微验证失败(用户不存在) → 拒绝登录
|
||||
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
|
||||
wecom_verified = False
|
||||
try:
|
||||
redis_client_verify = _get_redis()
|
||||
try:
|
||||
wecom_service = WecomService(redis_client_verify)
|
||||
user_info = await wecom_service.get_user_info(body.user_id)
|
||||
# 验证通过:用户存在于企微通讯录
|
||||
wecom_verified = True
|
||||
# 用企微返回的真实姓名覆盖前端传入的姓名(防止冒用他人身份)
|
||||
real_name = user_info.get("name", "")
|
||||
if real_name:
|
||||
body.name = real_name
|
||||
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
|
||||
finally:
|
||||
try:
|
||||
await redis_client_verify.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await wecom_service.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as wecom_err:
|
||||
# 企微API不可达时:仅允许已注册坐席降级登录,新注册必须验证
|
||||
# 原因:网络故障不应阻断已注册坐席工作,但不能让未验证用户注册新账号
|
||||
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, # 复用 AgentLogin,otp_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)}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 邀请功能 API(P0-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)
|
||||
@@ -0,0 +1,116 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 员工 API
|
||||
# =============================================================================
|
||||
# 说明:提供员工相关的管理接口
|
||||
# 接口列表:
|
||||
# PUT /api/employees/{employee_id}/it-level — 更新员工IT技能等级
|
||||
# =============================================================================
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.utils.response import success_response
|
||||
|
||||
from app.schemas.employee import VALID_IT_LEVELS, VALID_LEVEL_SOURCES
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/employees", tags=["员工管理"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class ItLevelUpdateRequest(BaseModel):
|
||||
"""IT技能等级更新请求 Schema。"""
|
||||
|
||||
it_level: str = Field(..., description="IT技能等级: bronze/silver/gold/platinum/diamond/star/king")
|
||||
source: str = Field(default="manual", description="等级来源: system/manual/assessment")
|
||||
|
||||
@field_validator("it_level")
|
||||
@classmethod
|
||||
def validate_it_level(cls, v: str) -> str:
|
||||
"""校验IT等级值是否合法。"""
|
||||
if v not in VALID_IT_LEVELS:
|
||||
raise ValueError(f"无效的IT等级: {v},合法值为: {VALID_IT_LEVELS}")
|
||||
return v
|
||||
|
||||
@field_validator("source")
|
||||
@classmethod
|
||||
def validate_source(cls, v: str) -> str:
|
||||
"""校验等级来源值是否合法。"""
|
||||
if v not in VALID_LEVEL_SOURCES:
|
||||
raise ValueError(f"无效的等级来源: {v},合法值为: {VALID_LEVEL_SOURCES}")
|
||||
return v
|
||||
|
||||
|
||||
class ItLevelUpdateResponse(BaseModel):
|
||||
"""IT技能等级更新响应 Schema。"""
|
||||
|
||||
employee_id: str
|
||||
it_level: str
|
||||
it_level_source: str
|
||||
message: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 员工数据存储(IT 等级映射)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 简单的内存存储,key 为 employee_id,value 为 it_level
|
||||
MOCK_EMPLOYEE_IT_LEVELS: dict = {
|
||||
"emp-001": "silver",
|
||||
"emp-002": "gold",
|
||||
"emp-003": "bronze",
|
||||
"emp-004": "platinum",
|
||||
"emp-005": "diamond",
|
||||
"emp-006": "silver",
|
||||
"emp-007": "star",
|
||||
"emp-008": "king",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.put("/{employee_id}/it-level")
|
||||
async def update_employee_it_level(
|
||||
employee_id: str,
|
||||
request: ItLevelUpdateRequest,
|
||||
):
|
||||
"""更新员工IT技能等级。
|
||||
|
||||
坐席可以手动调整员工的IT技能等级,等级来源标记为 manual。
|
||||
更新后等级立即生效,并记录来源以便追溯。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
request: 等级更新请求
|
||||
|
||||
Returns:
|
||||
更新结果
|
||||
"""
|
||||
# 更新内存中的等级
|
||||
old_level = MOCK_EMPLOYEE_IT_LEVELS.get(employee_id, "silver")
|
||||
MOCK_EMPLOYEE_IT_LEVELS[employee_id] = request.it_level
|
||||
|
||||
# 构造等级名称映射
|
||||
level_names = {
|
||||
"bronze": "青铜",
|
||||
"silver": "白银",
|
||||
"gold": "黄金",
|
||||
"platinum": "铂金",
|
||||
"diamond": "钻石",
|
||||
"star": "星耀",
|
||||
"king": "王者",
|
||||
}
|
||||
|
||||
return success_response(data=ItLevelUpdateResponse(
|
||||
employee_id=employee_id,
|
||||
it_level=request.it_level,
|
||||
it_level_source=request.source,
|
||||
message=f"IT等级已从 {level_names.get(old_level, old_level)} 调整为 {level_names.get(request.it_level, request.it_level)}",
|
||||
).model_dump())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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/")
|
||||
|
||||
|
||||
@@ -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="删除成功")
|
||||
@@ -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=["角色管理"])
|
||||
@@ -0,0 +1,130 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 系统管理 API
|
||||
# =============================================================================
|
||||
# 说明:系统级配置管理接口,包括:
|
||||
# 1. GET /api/system/emergency-mode — 查询应急模式状态
|
||||
# 2. PUT /api/system/emergency-mode — 切换应急模式开关
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
# 应急模式配置键(与 main.py init_data 保持一致)
|
||||
EMERGENCY_MODE_KEY = "emergency_mode"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/system/emergency-mode — 查询应急模式状态
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/system/emergency-mode")
|
||||
async def get_emergency_mode(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询应急模式状态。
|
||||
|
||||
返回当前应急模式的开关状态。
|
||||
应急模式开启时,智能服务台降级,引导员工使用企微原生「员工服务」通道。
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
data.emergency_mode: bool — 是否启用应急模式
|
||||
data.employee_service_guide: str — 开启时的引导文案(仅开启时返回)
|
||||
"""
|
||||
# 从数据库读取 emergency_mode 配置
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == EMERGENCY_MODE_KEY
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalars().first()
|
||||
|
||||
# 配置不存在时默认关闭(安全默认值)
|
||||
is_enabled = False
|
||||
if config and config.config_value:
|
||||
is_enabled = config.config_value.lower() in ("true", "1", "yes")
|
||||
|
||||
response_data = {"emergency_mode": is_enabled}
|
||||
|
||||
# 应急模式开启时,附带引导文案
|
||||
if is_enabled:
|
||||
response_data["employee_service_guide"] = (
|
||||
"IT智能服务台正在进行系统维护,"
|
||||
"请通过企业微信「通讯录 → 员工服务」联系IT支持人员,"
|
||||
"我们将尽快为您处理。"
|
||||
)
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# PUT /api/system/emergency-mode — 切换应急模式开关
|
||||
# --------------------------------------------------------------------------
|
||||
@router.put("/system/emergency-mode")
|
||||
async def toggle_emergency_mode(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换应急模式开关(仅限坐席/管理员操作)。
|
||||
|
||||
开启应急模式后:
|
||||
- H5 用户端页面显示引导文案,提示走企微原生「员工服务」
|
||||
- 坐席工作台顶部显示醒目的应急模式横幅
|
||||
|
||||
关闭应急模式后恢复正常服务。
|
||||
|
||||
Args:
|
||||
body: 请求体,包含 emergency_mode: bool
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
data.emergency_mode: bool — 切换后的状态
|
||||
"""
|
||||
enabled = body.get("emergency_mode", None)
|
||||
if enabled is None:
|
||||
raise AppException(1001, "emergency_mode 参数不能为空")
|
||||
|
||||
enabled_bool = bool(enabled)
|
||||
|
||||
# 查找或创建 emergency_mode 配置项
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == EMERGENCY_MODE_KEY
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config = result.scalars().first()
|
||||
|
||||
new_value = "true" if enabled_bool else "false"
|
||||
|
||||
if config:
|
||||
# 更新已有配置
|
||||
config.config_value = new_value
|
||||
else:
|
||||
# 配置不存在时新建(兜底,正常情况由 init_data 创建)
|
||||
config = SystemConfig(
|
||||
config_key=EMERGENCY_MODE_KEY,
|
||||
config_value=new_value,
|
||||
description="应急模式开关(true=启用员工服务通道,智能服务台降级)",
|
||||
)
|
||||
db.add(config)
|
||||
|
||||
await db.flush()
|
||||
|
||||
status_text = "开启" if enabled_bool else "关闭"
|
||||
logger.info(f"应急模式已{status_text}")
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"emergency_mode": enabled_bool,
|
||||
"message": f"应急模式已{status_text}",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 待办事项 API
|
||||
# =============================================================================
|
||||
# 说明:提供待办事项的 CRUD 接口
|
||||
# 接口列表:
|
||||
# GET /api/todo-items — 获取当前坐席待办列表
|
||||
# GET /api/todo-items/{id} — 获取待办详情
|
||||
# PUT /api/todo-items/{id}/status — 更新待办状态
|
||||
# Mock: 预置示例待办数据,不连接真实外部系统
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.response import success_response, AppException
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/todo-items", tags=["待办事项"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求/响应 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class TodoStatusUpdateRequest(BaseModel):
|
||||
"""更新待办状态请求 Schema。"""
|
||||
status: str = Field(..., description="新状态: pending/processing/resolved")
|
||||
|
||||
|
||||
class TodoItemResponse(BaseModel):
|
||||
"""待办事项响应 Schema。"""
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
priority: str
|
||||
description: dict
|
||||
status: str
|
||||
assigned_agent_id: Optional[str] = None
|
||||
corp_id: str = ""
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class TodoItemListResponse(BaseModel):
|
||||
"""待办事项列表响应 Schema。"""
|
||||
items: List[TodoItemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 数据 — 预置示例待办(共 20 条,覆盖全部类型 × 状态)
|
||||
# --------------------------------------------------------------------------
|
||||
MOCK_TODO_ITEMS: List[dict] = [
|
||||
# ========== 工单(ticket)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-001",
|
||||
"type": "ticket",
|
||||
"title": "VPN连接失败 — 财务部张伟",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "张伟",
|
||||
"department": "财务部",
|
||||
"error": "VPN Error 691",
|
||||
"steps": ["检查账号状态", "重置密码", "检查VPN配置"],
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T09:15:00Z",
|
||||
"updated_at": "2026-06-05T09:15:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-007",
|
||||
"type": "ticket",
|
||||
"title": "OA系统登录异常 — 人事部刘芳",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "刘芳",
|
||||
"department": "人事部",
|
||||
"error": "页面白屏,控制台报500错误",
|
||||
"affected_count": 15,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T11:30:00Z",
|
||||
"updated_at": "2026-06-05T11:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-009",
|
||||
"type": "ticket",
|
||||
"title": "WiFi 无法连接 — 研发部开放区",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"employee_name": "陈明",
|
||||
"department": "研发部",
|
||||
"error": "获取IP失败,提示无法连接到此网络",
|
||||
"location": "3楼开放区",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T08:00:00Z",
|
||||
"updated_at": "2026-06-06T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-017",
|
||||
"type": "ticket",
|
||||
"title": "鼠标失灵 — 行政部周婷",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "周婷",
|
||||
"department": "行政部",
|
||||
"error": "USB鼠标间歇性失灵,更换接口无效",
|
||||
"os": "Windows 11",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T09:00:00Z",
|
||||
"updated_at": "2026-06-06T09:00:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-004",
|
||||
"type": "ticket",
|
||||
"title": "邮箱容量告警 — 市场部王强",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "王强",
|
||||
"department": "市场部",
|
||||
"current_usage": "4.8GB / 5GB",
|
||||
"action": "协助清理或申请扩容",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-04T14:30:00Z",
|
||||
"updated_at": "2026-06-05T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-010",
|
||||
"type": "ticket",
|
||||
"title": "ERP系统响应慢 — 全公司反馈",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "多个员工",
|
||||
"department": "全公司",
|
||||
"error": "ERP首页加载超过15秒",
|
||||
"affected_count": 50,
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T10:00:00Z",
|
||||
"updated_at": "2026-06-05T15:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-011",
|
||||
"type": "ticket",
|
||||
"title": "打印机驱动安装 — 市场部赵敏",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "赵敏",
|
||||
"department": "市场部",
|
||||
"device_model": "Canon LBP2900",
|
||||
"solution": "从官网下载驱动并安装,测试打印正常",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-01T09:00:00Z",
|
||||
"updated_at": "2026-06-02T16:00:00Z",
|
||||
},
|
||||
|
||||
# ========== 审批(approval)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-002",
|
||||
"type": "approval",
|
||||
"title": "软件安装审批 — 设计部PS申请",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "李娜",
|
||||
"department": "设计部",
|
||||
"software": "Adobe Photoshop 2026",
|
||||
"license_type": "企业许可",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T10:20:00Z",
|
||||
"updated_at": "2026-06-05T10:20:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-005",
|
||||
"type": "approval",
|
||||
"title": "权限升级审批 — 研发部数据库访问",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"employee_name": "陈明",
|
||||
"department": "研发部",
|
||||
"target_system": "生产数据库",
|
||||
"access_level": "只读",
|
||||
"approver": "研发总监",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T08:45:00Z",
|
||||
"updated_at": "2026-06-05T08:45:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-008",
|
||||
"type": "approval",
|
||||
"title": "新员工设备采购审批 — Q3批次",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"batch": "Q3新员工",
|
||||
"count": 5,
|
||||
"items": ["笔记本x5", "显示器x5", "键鼠套装x5"],
|
||||
"budget": "65,000元",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T07:00:00Z",
|
||||
"updated_at": "2026-06-05T07:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-018",
|
||||
"type": "approval",
|
||||
"title": "弹性福利审批 — 全体员工Q3",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"applicant": "人事部",
|
||||
"type": "弹性福利",
|
||||
"budget_per_person": "3000元",
|
||||
"total_count": 120,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T07:00:00Z",
|
||||
"updated_at": "2026-06-06T07:00:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-012",
|
||||
"type": "approval",
|
||||
"title": "预算审批 — IT部Q3采购",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"department": "IT部",
|
||||
"amount": "280,000元",
|
||||
"items": ["服务器x2", "防火墙x2", "交换机x4"],
|
||||
"approver": "CFO",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-04T09:00:00Z",
|
||||
"updated_at": "2026-06-05T14:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-013",
|
||||
"type": "approval",
|
||||
"title": "会议室预订审批 — 销售部Q3客户拜访",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "刘军",
|
||||
"department": "销售部",
|
||||
"room": "5楼大会议室",
|
||||
"time": "2026-06-10 14:00-17:00",
|
||||
"result": "已批准",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-05-28T08:00:00Z",
|
||||
"updated_at": "2026-05-29T10:00:00Z",
|
||||
},
|
||||
|
||||
# ========== 设备(device)==========
|
||||
# 待处理
|
||||
{
|
||||
"id": "todo-003",
|
||||
"type": "device",
|
||||
"title": "工位打印机故障 — 3楼A区",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"location": "3楼A区打印间",
|
||||
"device_model": "HP LaserJet Pro M404",
|
||||
"issue": "卡纸,无法打印",
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T11:05:00Z",
|
||||
"updated_at": "2026-06-05T11:05:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-014",
|
||||
"type": "device",
|
||||
"title": "核心交换机故障 — 机房",
|
||||
"priority": "urgent",
|
||||
"description": {
|
||||
"location": "机房A区",
|
||||
"device_model": "Cisco Catalyst 9300",
|
||||
"issue": "端口3-12全部down,影响2楼所有工位",
|
||||
"affected_count": 45,
|
||||
},
|
||||
"status": "pending",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-06T00:30:00Z",
|
||||
"updated_at": "2026-06-06T00:30:00Z",
|
||||
},
|
||||
# 进行中
|
||||
{
|
||||
"id": "todo-006",
|
||||
"type": "device",
|
||||
"title": "会议室投影仪维修 — 5楼大会议室",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"location": "5楼大会议室",
|
||||
"device_model": "Epson EB-X51",
|
||||
"issue": "投影模糊,可能灯泡老化",
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-03T16:00:00Z",
|
||||
"updated_at": "2026-06-04T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "todo-015",
|
||||
"type": "device",
|
||||
"title": "服务器硬盘更换 — 虚拟化集群",
|
||||
"priority": "high",
|
||||
"description": {
|
||||
"location": "机房B区",
|
||||
"device_model": "Dell R740",
|
||||
"issue": "硬盘预警,需更换并做好数据迁移",
|
||||
"affected_vms": 12,
|
||||
},
|
||||
"status": "processing",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-06-05T09:00:00Z",
|
||||
"updated_at": "2026-06-05T16:00:00Z",
|
||||
},
|
||||
# 已完成
|
||||
{
|
||||
"id": "todo-016",
|
||||
"type": "device",
|
||||
"title": "员工笔记本磁盘扩容 — 人事部吴婷",
|
||||
"priority": "normal",
|
||||
"description": {
|
||||
"employee_name": "吴婷",
|
||||
"department": "人事部",
|
||||
"device_model": "ThinkPad X1 Carbon",
|
||||
"solution": "更换1TB SSD,克隆系统,测试正常",
|
||||
},
|
||||
"status": "resolved",
|
||||
"assigned_agent_id": "agent-001",
|
||||
"corp_id": "ww1234567890",
|
||||
"created_at": "2026-05-20T13:00:00Z",
|
||||
"updated_at": "2026-05-22T17:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_todo_items(
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
):
|
||||
"""获取当前坐席待办列表。
|
||||
|
||||
支持按状态和优先级过滤。
|
||||
"""
|
||||
items = MOCK_TODO_ITEMS
|
||||
|
||||
# 按状态过滤
|
||||
if status:
|
||||
items = [item for item in items if item["status"] == status]
|
||||
|
||||
# 按优先级过滤
|
||||
if priority:
|
||||
items = [item for item in items if item["priority"] == priority]
|
||||
|
||||
# 按优先级排序:urgent → high → normal
|
||||
priority_order = {"urgent": 0, "high": 1, "normal": 2}
|
||||
items = sorted(items, key=lambda x: priority_order.get(x["priority"], 3))
|
||||
|
||||
return success_response(data={
|
||||
"items": [TodoItemResponse(**item).model_dump() for item in items],
|
||||
"total": len(items),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_todo_item(item_id: str):
|
||||
"""获取待办事项详情。"""
|
||||
for item in MOCK_TODO_ITEMS:
|
||||
if item["id"] == item_id:
|
||||
return success_response(data=TodoItemResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
|
||||
|
||||
|
||||
@router.put("/{item_id}/status")
|
||||
async def update_todo_item_status(item_id: str, request: TodoStatusUpdateRequest):
|
||||
"""更新待办事项状态。"""
|
||||
# 校验状态值
|
||||
valid_statuses = {"pending", "processing", "resolved"}
|
||||
if request.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的状态值: {request.status},合法值为: {valid_statuses}",
|
||||
)
|
||||
|
||||
for item in MOCK_TODO_ITEMS:
|
||||
if item["id"] == item_id:
|
||||
item["status"] = request.status
|
||||
item["updated_at"] = datetime.now().isoformat()
|
||||
return success_response(data=TodoItemResponse(**item).model_dump())
|
||||
|
||||
raise AppException(code=1003, message=f"待办事项 {item_id} 不存在")
|
||||
@@ -0,0 +1,719 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 排查模板 API
|
||||
# =============================================================================
|
||||
# 说明:提供排查模板的 CRUD 接口
|
||||
# 接口列表:
|
||||
# GET /api/troubleshooting-templates — 获取排查模板列表
|
||||
# GET /api/troubleshooting-templates/{id} — 获取排查模板详情
|
||||
# POST /api/troubleshooting-templates — 新增模板(管理员)
|
||||
# PUT /api/troubleshooting-templates/{id} — 修改模板(管理员)
|
||||
# DELETE /api/troubleshooting-templates/{id} — 删除模板(管理员)
|
||||
# Mock: 预置 8 套常见问题模板(VPN/邮箱/系统/账号等)
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.utils.response import success_response, AppException
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/troubleshooting-templates", tags=["排查模板"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求/响应 Schema
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
class PathStepSchema(BaseModel):
|
||||
"""排障步骤路径节点 Schema。"""
|
||||
label: str = Field(..., description="步骤标题")
|
||||
status: str = Field(default="pending", description="步骤状态: done/current/pending")
|
||||
|
||||
|
||||
class FlowchartNodeSchema(BaseModel):
|
||||
"""决策树递归节点 Schema。"""
|
||||
id: str = Field(..., description="节点唯一标识")
|
||||
type: str = Field(..., description="节点类型: step/decision")
|
||||
label: str = Field(..., description="节点标签")
|
||||
status: Optional[str] = Field(None, description="节点状态: done/current/pending")
|
||||
children: Optional[List["FlowchartNodeSchema"]] = Field(None, description="子节点列表")
|
||||
yes_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'是' 分支")
|
||||
no_branch: Optional["FlowchartNodeSchema"] = Field(None, description="'否' 分支")
|
||||
|
||||
|
||||
class TroubleshootingTemplateCreateRequest(BaseModel):
|
||||
"""创建排查模板请求 Schema。"""
|
||||
name: str = Field(..., min_length=1, max_length=256, description="模板名称")
|
||||
category: str = Field(default="system", description="分类: vpn/email/system/account")
|
||||
path_steps: List[Dict[str, Any]] = Field(default_factory=list, description="排障步骤路径")
|
||||
flowchart: Dict[str, Any] = Field(default_factory=dict, description="流程图定义")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
|
||||
class TroubleshootingTemplateUpdateRequest(BaseModel):
|
||||
"""更新排查模板请求 Schema。"""
|
||||
name: Optional[str] = Field(None, max_length=256, description="模板名称")
|
||||
category: Optional[str] = Field(None, description="分类")
|
||||
path_steps: Optional[List[Dict[str, Any]]] = Field(None, description="排障步骤路径")
|
||||
flowchart: Optional[Dict[str, Any]] = Field(None, description="流程图定义")
|
||||
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||
|
||||
|
||||
class TroubleshootingTemplateResponse(BaseModel):
|
||||
"""排查模板响应 Schema。"""
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
path_steps: List[Dict[str, Any]]
|
||||
flowchart: Dict[str, Any]
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class TroubleshootingTemplateListResponse(BaseModel):
|
||||
"""排查模板列表响应 Schema。"""
|
||||
items: List[TroubleshootingTemplateResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mock 数据 — 预置 8 套常见问题模板
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _build_vpn_flowchart() -> Dict[str, Any]:
|
||||
"""构建 VPN 故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-vpn-1",
|
||||
"type": "step",
|
||||
"label": "确认VPN客户端版本",
|
||||
"status": "done",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-2",
|
||||
"type": "decision",
|
||||
"label": "版本是否为最新?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-3",
|
||||
"type": "step",
|
||||
"label": "清除DNS缓存并重连",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-4",
|
||||
"type": "decision",
|
||||
"label": "重连是否成功?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-6",
|
||||
"type": "step",
|
||||
"label": "发起远程协助",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-7",
|
||||
"type": "decision",
|
||||
"label": "远程能否解决?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-vpn-8",
|
||||
"type": "step",
|
||||
"label": "回访确认并结单",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-9",
|
||||
"type": "step",
|
||||
"label": "升级至二线团队",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-vpn-10",
|
||||
"type": "step",
|
||||
"label": "升级VPN客户端到最新版",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-vpn-11",
|
||||
"type": "step",
|
||||
"label": "重试连接",
|
||||
"status": "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_email_flowchart() -> Dict[str, Any]:
|
||||
"""构建邮箱故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-email-1",
|
||||
"type": "step",
|
||||
"label": "确认邮箱账号状态",
|
||||
"status": "done",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-email-2",
|
||||
"type": "decision",
|
||||
"label": "账号是否被锁定?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-email-3",
|
||||
"type": "step",
|
||||
"label": "解锁账号并重置密码",
|
||||
"status": "current",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-email-4",
|
||||
"type": "step",
|
||||
"label": "检查Outlook配置",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-email-5",
|
||||
"type": "decision",
|
||||
"label": "配置是否正确?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-email-6",
|
||||
"type": "step",
|
||||
"label": "清理Outlook缓存",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-email-7",
|
||||
"type": "step",
|
||||
"label": "重新配置Outlook",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_system_flowchart() -> Dict[str, Any]:
|
||||
"""构建系统登录异常排查流程图。"""
|
||||
return {
|
||||
"id": "fc-sys-1",
|
||||
"type": "step",
|
||||
"label": "确认系统服务是否正常",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-sys-2",
|
||||
"type": "decision",
|
||||
"label": "系统服务是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-sys-3",
|
||||
"type": "step",
|
||||
"label": "清除浏览器缓存",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-sys-4",
|
||||
"type": "decision",
|
||||
"label": "清除后是否恢复?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-sys-5",
|
||||
"type": "step",
|
||||
"label": "回访确认并结单",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-sys-6",
|
||||
"type": "step",
|
||||
"label": "更换浏览器重试",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-sys-7",
|
||||
"type": "step",
|
||||
"label": "联系运维检查服务端",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_account_flowchart() -> Dict[str, Any]:
|
||||
"""构建账号权限问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-acc-1",
|
||||
"type": "step",
|
||||
"label": "确认权限需求与合规性",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-acc-2",
|
||||
"type": "decision",
|
||||
"label": "权限是否符合策略?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-acc-3",
|
||||
"type": "step",
|
||||
"label": "提交权限审批流程",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-acc-4",
|
||||
"type": "step",
|
||||
"label": "审批通过后配置权限",
|
||||
"status": "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-acc-5",
|
||||
"type": "step",
|
||||
"label": "建议替代方案或申请特批",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_network_flowchart() -> Dict[str, Any]:
|
||||
"""构建网络连接问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-net-1",
|
||||
"type": "step",
|
||||
"label": "确认网络连接状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-net-2",
|
||||
"type": "decision",
|
||||
"label": "能否ping通网关?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-net-3",
|
||||
"type": "step",
|
||||
"label": "检查DNS解析",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-net-4",
|
||||
"type": "decision",
|
||||
"label": "DNS是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-net-5",
|
||||
"type": "step",
|
||||
"label": "检查防火墙规则",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-net-6",
|
||||
"type": "step",
|
||||
"label": "手动配置DNS服务器",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-net-7",
|
||||
"type": "step",
|
||||
"label": "检查网线和交换机端口",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_printer_flowchart() -> Dict[str, Any]:
|
||||
"""构建打印机故障排查流程图。"""
|
||||
return {
|
||||
"id": "fc-prt-1",
|
||||
"type": "step",
|
||||
"label": "确认打印机连接状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-prt-2",
|
||||
"type": "decision",
|
||||
"label": "打印机是否在线?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-prt-3",
|
||||
"type": "step",
|
||||
"label": "清除打印队列并重启打印服务",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-prt-4",
|
||||
"type": "decision",
|
||||
"label": "打印是否恢复?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-prt-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-prt-6",
|
||||
"type": "step",
|
||||
"label": "重新安装打印机驱动",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-prt-7",
|
||||
"type": "step",
|
||||
"label": "检查网络连接和打印机电源",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_office_flowchart() -> Dict[str, Any]:
|
||||
"""构建 Office 软件问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-off-1",
|
||||
"type": "step",
|
||||
"label": "确认Office版本和激活状态",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-off-2",
|
||||
"type": "decision",
|
||||
"label": "Office是否正常激活?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-off-3",
|
||||
"type": "step",
|
||||
"label": "修复Office安装",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-off-4",
|
||||
"type": "decision",
|
||||
"label": "修复后是否正常?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-off-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-off-6",
|
||||
"type": "step",
|
||||
"label": "卸载重装Office",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-off-7",
|
||||
"type": "step",
|
||||
"label": "重新激活Office许可证",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _build_password_flowchart() -> Dict[str, Any]:
|
||||
"""构建密码重置问题排查流程图。"""
|
||||
return {
|
||||
"id": "fc-pwd-1",
|
||||
"type": "step",
|
||||
"label": "确认账号状态和锁定原因",
|
||||
"status": "current",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-pwd-2",
|
||||
"type": "decision",
|
||||
"label": "账号是否被锁定?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-pwd-3",
|
||||
"type": "step",
|
||||
"label": "解锁账号并引导自助重置",
|
||||
"status": "pending",
|
||||
"children": [
|
||||
{
|
||||
"id": "fc-pwd-4",
|
||||
"type": "decision",
|
||||
"label": "自助重置是否成功?",
|
||||
"status": "pending",
|
||||
"yes_branch": {
|
||||
"id": "fc-pwd-5",
|
||||
"type": "step",
|
||||
"label": "回访确认",
|
||||
"status": "pending",
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-pwd-6",
|
||||
"type": "step",
|
||||
"label": "管理员手动重置密码",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"no_branch": {
|
||||
"id": "fc-pwd-7",
|
||||
"type": "step",
|
||||
"label": "检查SSO单点登录配置",
|
||||
"status": "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# 所有 Mock 模板数据
|
||||
MOCK_TEMPLATES: List[dict] = [
|
||||
{
|
||||
"id": "tpl-vpn-001",
|
||||
"name": "VPN连接故障",
|
||||
"category": "vpn",
|
||||
"path_steps": [
|
||||
{"label": "确认VPN版本", "status": "done"},
|
||||
{"label": "清除缓存重连", "status": "current"},
|
||||
{"label": "远程排查", "status": "pending"},
|
||||
{"label": "升级客户端", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_vpn_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-15T10:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-email-001",
|
||||
"name": "邮箱登录故障",
|
||||
"category": "email",
|
||||
"path_steps": [
|
||||
{"label": "确认邮箱状态", "status": "done"},
|
||||
{"label": "重置密码", "status": "current"},
|
||||
{"label": "检查配置", "status": "pending"},
|
||||
{"label": "清理缓存", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_email_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-20T14:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-system-001",
|
||||
"name": "系统登录异常",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认系统状态", "status": "current"},
|
||||
{"label": "清除浏览器缓存", "status": "pending"},
|
||||
{"label": "更换浏览器", "status": "pending"},
|
||||
{"label": "检查网络权限", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_system_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-25T09:15:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-account-001",
|
||||
"name": "账号权限问题",
|
||||
"category": "account",
|
||||
"path_steps": [
|
||||
{"label": "确认权限需求", "status": "current"},
|
||||
{"label": "提交审批", "status": "pending"},
|
||||
{"label": "配置权限", "status": "pending"},
|
||||
{"label": "验证权限", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_account_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-01T08:00:00Z",
|
||||
"updated_at": "2025-06-28T16:45:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-network-001",
|
||||
"name": "网络连接问题",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认网络状态", "status": "current"},
|
||||
{"label": "检查DNS配置", "status": "pending"},
|
||||
{"label": "检查防火墙", "status": "pending"},
|
||||
{"label": "更换网口/网线", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_network_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-05T10:00:00Z",
|
||||
"updated_at": "2025-06-22T11:30:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-printer-001",
|
||||
"name": "打印机故障",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认打印机状态", "status": "current"},
|
||||
{"label": "清除打印队列", "status": "pending"},
|
||||
{"label": "重新安装驱动", "status": "pending"},
|
||||
{"label": "检查网络连接", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_printer_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-10T09:00:00Z",
|
||||
"updated_at": "2025-07-01T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-office-001",
|
||||
"name": "Office软件问题",
|
||||
"category": "system",
|
||||
"path_steps": [
|
||||
{"label": "确认Office版本", "status": "current"},
|
||||
{"label": "修复安装", "status": "pending"},
|
||||
{"label": "重新激活", "status": "pending"},
|
||||
{"label": "卸载重装", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_office_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-12T14:00:00Z",
|
||||
"updated_at": "2025-06-30T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "tpl-password-001",
|
||||
"name": "密码重置问题",
|
||||
"category": "account",
|
||||
"path_steps": [
|
||||
{"label": "确认账号状态", "status": "current"},
|
||||
{"label": "解锁账号", "status": "pending"},
|
||||
{"label": "引导自助重置", "status": "pending"},
|
||||
{"label": "管理员重置", "status": "pending"},
|
||||
{"label": "回访确认", "status": "pending"},
|
||||
],
|
||||
"flowchart": _build_password_flowchart(),
|
||||
"is_active": True,
|
||||
"created_at": "2025-06-15T08:00:00Z",
|
||||
"updated_at": "2025-07-01T09:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 接口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_troubleshooting_templates(
|
||||
category: Optional[str] = None,
|
||||
):
|
||||
"""获取排查模板列表。
|
||||
|
||||
支持按分类过滤。
|
||||
"""
|
||||
items = MOCK_TEMPLATES
|
||||
|
||||
# 按分类过滤
|
||||
if category:
|
||||
items = [item for item in items if item["category"] == category]
|
||||
|
||||
# 只返回启用的模板
|
||||
items = [item for item in items if item.get("is_active", True)]
|
||||
|
||||
return success_response(data={
|
||||
"items": [TroubleshootingTemplateResponse(**item).model_dump() for item in items],
|
||||
"total": len(items),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_troubleshooting_template(template_id: str):
|
||||
"""获取排查模板详情。"""
|
||||
for item in MOCK_TEMPLATES:
|
||||
if item["id"] == template_id:
|
||||
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_troubleshooting_template(request: TroubleshootingTemplateCreateRequest):
|
||||
"""新增排查模板(管理员)。"""
|
||||
new_template = {
|
||||
"id": f"tpl-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"name": request.name,
|
||||
"category": request.category,
|
||||
"path_steps": request.path_steps,
|
||||
"flowchart": request.flowchart,
|
||||
"is_active": request.is_active,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
MOCK_TEMPLATES.append(new_template)
|
||||
return success_response(data=TroubleshootingTemplateResponse(**new_template).model_dump())
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_troubleshooting_template(
|
||||
template_id: str,
|
||||
request: TroubleshootingTemplateUpdateRequest,
|
||||
):
|
||||
"""修改排查模板(管理员)。"""
|
||||
for item in MOCK_TEMPLATES:
|
||||
if item["id"] == template_id:
|
||||
if request.name is not None:
|
||||
item["name"] = request.name
|
||||
if request.category is not None:
|
||||
item["category"] = request.category
|
||||
if request.path_steps is not None:
|
||||
item["path_steps"] = request.path_steps
|
||||
if request.flowchart is not None:
|
||||
item["flowchart"] = request.flowchart
|
||||
if request.is_active is not None:
|
||||
item["is_active"] = request.is_active
|
||||
item["updated_at"] = datetime.now().isoformat()
|
||||
return success_response(data=TroubleshootingTemplateResponse(**item).model_dump())
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_troubleshooting_template(template_id: str):
|
||||
"""删除排查模板(管理员)。"""
|
||||
for i, item in enumerate(MOCK_TEMPLATES):
|
||||
if item["id"] == template_id:
|
||||
MOCK_TEMPLATES.pop(i)
|
||||
return success_response(data=None, message=f"排查模板 {template_id} 已删除")
|
||||
raise AppException(code=1003, message=f"排查模板 {template_id} 不存在")
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,276 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微回调 API
|
||||
# =============================================================================
|
||||
# 说明:处理企微服务器的回调请求,包括:
|
||||
# 1. GET /api/wecom/callback — 验证URL有效性(企微配置回调URL时调用)
|
||||
# 2. POST /api/wecom/callback — 接收企微推送的消息
|
||||
#
|
||||
# 重构记录(2026-06):
|
||||
# - 移除手动创建 Redis/WecomService/AIService 实例的模式
|
||||
# - 改用 dependencies 模块提供的共享服务实例
|
||||
# - 不再手动 close() 服务实例(由应用生命周期管理)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import _get_session_factory
|
||||
from app.dependencies import (
|
||||
get_shared_redis,
|
||||
get_shared_wecom_service,
|
||||
get_shared_ai_handler,
|
||||
)
|
||||
from app.services.ai_handler import AIHandler
|
||||
from app.services.cache_service import CacheService
|
||||
from app.services.message_router import MessageRouter
|
||||
from app.services.scoring_service import ScoringService
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.utils.wecom_crypto import WecomCrypto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
# 加解密工具实例(懒加载单例,避免导入时因无效配置导致 base64 解码失败)
|
||||
_wecom_crypto: WecomCrypto | None = None
|
||||
|
||||
|
||||
def _get_wecom_crypto() -> WecomCrypto:
|
||||
"""获取加解密工具单例(延迟初始化)。
|
||||
|
||||
在测试环境中,settings 中的 EncodingAESKey 可能是无效的占位值,
|
||||
延迟初始化可以避免模块导入时就触发 base64 解码错误。
|
||||
"""
|
||||
global _wecom_crypto
|
||||
if _wecom_crypto is None:
|
||||
from app.config import settings
|
||||
_wecom_crypto = WecomCrypto(
|
||||
token=settings.wecom_token,
|
||||
encoding_aes_key=settings.wecom_encoding_aes_key,
|
||||
corp_id=settings.wecom_corp_id,
|
||||
)
|
||||
return _wecom_crypto
|
||||
|
||||
|
||||
@router.get("/wecom/callback")
|
||||
async def verify_url(
|
||||
msg_signature: str = Query(..., description="企微签名"),
|
||||
timestamp: str = Query(..., description="时间戳"),
|
||||
nonce: str = Query(..., description="随机数"),
|
||||
echostr: str = Query(..., description="加密的验证字符串"),
|
||||
):
|
||||
"""验证企微回调URL有效性。
|
||||
|
||||
企微管理后台配置回调URL时,会发送 GET 请求验证。
|
||||
验证流程:
|
||||
1. 验证签名 SHA1(sort(token, timestamp, nonce, echostr))
|
||||
2. 解密 echostr
|
||||
3. 返回解密后的明文
|
||||
|
||||
Args:
|
||||
msg_signature: 企微签名
|
||||
timestamp: 时间戳
|
||||
nonce: 随机数
|
||||
echostr: 加密的验证字符串
|
||||
|
||||
Returns:
|
||||
str: 解密后的 echostr 明文
|
||||
"""
|
||||
try:
|
||||
# 验证签名并解密 echostr
|
||||
plaintext = _get_wecom_crypto().decrypt_echostr(
|
||||
msg_signature=msg_signature,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
echostr=echostr,
|
||||
)
|
||||
logger.info("企微回调URL验证成功")
|
||||
return Response(content=plaintext, media_type="text/plain")
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"企微回调URL验证失败: {e}")
|
||||
return Response(content=f"验证失败: {e}", media_type="text/plain", status_code=400)
|
||||
|
||||
|
||||
@router.post("/wecom/callback")
|
||||
async def receive_message(
|
||||
request: Request,
|
||||
msg_signature: str = Query(..., description="企微签名"),
|
||||
timestamp: str = Query(..., description="时间戳"),
|
||||
nonce: str = Query(..., description="随机数"),
|
||||
):
|
||||
"""接收企微推送的消息。
|
||||
|
||||
企微将员工发送的消息通过此接口推送过来。
|
||||
处理流程:
|
||||
1. 读取 XML 请求体
|
||||
2. 解密消息(验证签名 + AES 解密)
|
||||
3. 解析消息内容
|
||||
4. 路由到 MessageRouter 处理
|
||||
5. 返回 "success" 字符串(企微要求)
|
||||
|
||||
重构说明:使用 dependencies 模块提供的共享服务实例,
|
||||
不再手动创建/关闭 Redis、WecomService、AIService。
|
||||
|
||||
企微推送的消息格式(加密后):
|
||||
<xml>
|
||||
<ToUserName><![CDATA[corp_id]]></ToUserName>
|
||||
<AgentID>1000002</AgentID>
|
||||
<Encrypt><![CDATA[加密内容]]></Encrypt>
|
||||
</xml>
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象(读取 XML 请求体)
|
||||
msg_signature: 企微签名
|
||||
timestamp: 时间戳
|
||||
nonce: 随机数
|
||||
|
||||
Returns:
|
||||
str: "success" 字符串(企微要求的固定响应)
|
||||
"""
|
||||
try:
|
||||
# 1. 读取 XML 请求体
|
||||
xml_body = (await request.body()).decode("utf-8")
|
||||
logger.debug(f"收到企微回调: xml_length={len(xml_body)}")
|
||||
|
||||
# 2. 解密消息
|
||||
message_dict = _get_wecom_crypto().decrypt_message(
|
||||
xml_body=xml_body,
|
||||
msg_signature=msg_signature,
|
||||
timestamp=timestamp,
|
||||
nonce=nonce,
|
||||
)
|
||||
|
||||
# 3. 提取消息关键字段
|
||||
from_user_id = message_dict.get("FromUserName", "")
|
||||
content = message_dict.get("Content", "")
|
||||
msg_type = message_dict.get("MsgType", "text")
|
||||
agent_id = message_dict.get("AgentID", "")
|
||||
event = message_dict.get("Event", "")
|
||||
msg_id = message_dict.get("MsgId", "")
|
||||
|
||||
# 提取非文本消息的媒体字段(图片/语音/视频/文件/位置)
|
||||
media_id: str = message_dict.get("MediaId", "")
|
||||
pic_url: str = message_dict.get("PicUrl", "")
|
||||
msg_format: str = message_dict.get("Format", "")
|
||||
file_name: str = message_dict.get("FileName", "")
|
||||
file_size: str = message_dict.get("FileSize", "")
|
||||
# 位置消息字段
|
||||
location_x: str = message_dict.get("Location_X", "")
|
||||
location_y: str = message_dict.get("Location_Y", "")
|
||||
location_label: str = message_dict.get("Label", "")
|
||||
|
||||
# 4. 处理事件消息(如员工进入应用)
|
||||
if event:
|
||||
await _handle_event(event, from_user_id, message_dict)
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
# 5. 处理各类消息(文本 + 非文本)
|
||||
# 文本消息必须有 Content 字段;非文本消息(image/voice/video/file/location)
|
||||
# 没有 Content 字段,content 可能为空字符串,这是正常的
|
||||
if msg_type == "text" and (not from_user_id or not content):
|
||||
logger.warning("文本消息缺少发送者或内容,忽略")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
elif msg_type != "text" and not from_user_id:
|
||||
logger.warning("非文本消息缺少发送者,忽略")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
# 6. 路由消息到 MessageRouter(使用共享服务实例)
|
||||
session_factory = _get_session_factory()
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
# 获取共享服务实例(不再手动创建/关闭)
|
||||
wecom_service = get_shared_wecom_service()
|
||||
ai_handler = get_shared_ai_handler()
|
||||
redis_client = get_shared_redis()
|
||||
|
||||
# ScoringService 需要当前 db 会话,仍需按请求创建
|
||||
scoring_service = ScoringService(db)
|
||||
|
||||
# CacheService 使用共享 Redis 客户端
|
||||
cache_service = CacheService(redis_client)
|
||||
|
||||
# 创建消息路由器
|
||||
message_router = MessageRouter(
|
||||
db=db,
|
||||
wecom_service=wecom_service,
|
||||
scoring_service=scoring_service,
|
||||
ai_handler=ai_handler,
|
||||
cache_service=cache_service,
|
||||
)
|
||||
|
||||
# 构建 extra_data(存储各消息类型的额外元数据)
|
||||
extra_data: dict = {}
|
||||
if msg_type == "image":
|
||||
extra_data["pic_url"] = pic_url
|
||||
elif msg_type == "voice":
|
||||
extra_data["format"] = msg_format
|
||||
elif msg_type == "video":
|
||||
extra_data["thumb_media_id"] = message_dict.get("ThumbMediaId", "")
|
||||
elif msg_type == "location":
|
||||
extra_data["location_x"] = location_x
|
||||
extra_data["location_y"] = location_y
|
||||
extra_data["label"] = location_label
|
||||
extra_data["scale"] = message_dict.get("Scale", "")
|
||||
|
||||
# 路由消息
|
||||
await message_router.route_message(
|
||||
from_user_id=from_user_id,
|
||||
content=content,
|
||||
msg_type=msg_type,
|
||||
msg_id=msg_id if msg_id else None,
|
||||
media_id=media_id if media_id else None,
|
||||
extra_data=extra_data if extra_data else None,
|
||||
file_name=file_name if file_name else None,
|
||||
file_size=int(file_size) if file_size else None,
|
||||
)
|
||||
|
||||
# 提交事务
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"消息路由处理失败: {e}", exc_info=True)
|
||||
# 即使处理失败,也返回 "success" 避免企微重试
|
||||
# 但记录错误日志以便排查
|
||||
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
except ValueError as e:
|
||||
# 解密失败,记录日志但仍返回 success 避免企微重试
|
||||
logger.error(f"消息解密失败: {e}")
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
except Exception as e:
|
||||
# 其他未知错误,记录日志但仍返回 success
|
||||
logger.error(f"消息处理未知错误: {e}", exc_info=True)
|
||||
return Response(content="success", media_type="text/plain")
|
||||
|
||||
|
||||
async def _handle_event(
|
||||
event: str, from_user_id: str, message_dict: dict
|
||||
) -> None:
|
||||
"""处理企微事件消息。
|
||||
|
||||
事件类型:
|
||||
- subscribe: 员工关注应用
|
||||
- unsubscribe: 员工取消关注
|
||||
- enter_agent: 员工进入应用
|
||||
|
||||
Args:
|
||||
event: 事件类型
|
||||
from_user_id: 发送者企微 UserID
|
||||
message_dict: 完整消息字典
|
||||
"""
|
||||
if event == "enter_agent":
|
||||
logger.info(f"员工进入应用: user_id={from_user_id}")
|
||||
elif event == "subscribe":
|
||||
logger.info(f"员工关注应用: user_id={from_user_id}")
|
||||
elif event == "unsubscribe":
|
||||
logger.info(f"员工取消关注: user_id={from_user_id}")
|
||||
else:
|
||||
logger.info(f"收到事件消息: event={event}, user_id={from_user_id}")
|
||||
@@ -0,0 +1,227 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — AI Wingman API 路由
|
||||
# =============================================================================
|
||||
# 说明:坐席端 AI 智能副驾驶 API,包含 3 个核心端点:
|
||||
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
|
||||
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
|
||||
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
|
||||
#
|
||||
# 所有端点需要坐席认证(get_current_agent)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_wingman_service
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.utils.response import ERR_NOT_FOUND, success_response
|
||||
|
||||
# 复用坐席认证依赖
|
||||
from app.api.agents import get_current_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _validate_conversation(
|
||||
conversation_id: str,
|
||||
agent: Agent,
|
||||
db: AsyncSession,
|
||||
) -> Conversation:
|
||||
"""验证会话存在性并返回会话对象。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Conversation: 会话对象
|
||||
|
||||
Raises:
|
||||
AppException: 会话不存在
|
||||
"""
|
||||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
raise ERR_NOT_FOUND
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
async def _get_recent_messages(
|
||||
conversation_id: str,
|
||||
db: AsyncSession,
|
||||
limit: int = 20,
|
||||
) -> list[dict]:
|
||||
"""获取会话最近的消息历史(转换为字典列表)。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
limit: 获取的消息条数
|
||||
|
||||
Returns:
|
||||
list[dict]: 消息字典列表
|
||||
"""
|
||||
stmt = (
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
messages = list(result.scalars().all())
|
||||
|
||||
# 按时间正序排列(最早的在前)
|
||||
messages.reverse()
|
||||
|
||||
# 转换为字典列表
|
||||
return [
|
||||
{
|
||||
"id": msg.id,
|
||||
"sender_type": msg.sender_type,
|
||||
"sender_name": msg.sender_name,
|
||||
"content": msg.content,
|
||||
"msg_type": msg.msg_type,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else "",
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/draft
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/draft")
|
||||
async def generate_draft(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成 AI 草稿回复。
|
||||
|
||||
基于当前会话的消息历史,让 Wingman Agent 生成坐席可以采纳的草稿回复。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含草稿内容、置信度和推理说明
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的消息历史(最近 20 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=20)
|
||||
|
||||
# 3. 调用 WingmanService 生成草稿
|
||||
result = await wingman_service.generate_draft(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/summary
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/summary")
|
||||
async def generate_summary(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成会话自动摘要。
|
||||
|
||||
基于完整对话生成结构化摘要,包含问题、原因、解决方案。
|
||||
通常在结单时调用。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含问题、原因、解决方案
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的完整消息历史(最多 50 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=50)
|
||||
|
||||
# 3. 调用 WingmanService 生成摘要
|
||||
result = await wingman_service.generate_summary(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/tags
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/tags")
|
||||
async def suggest_tags(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""生成自动标签建议。
|
||||
|
||||
基于对话内容建议标签分类,包含标签列表、分类和优先级。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含建议标签、分类和优先级
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
conversation = await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 从数据库读取该会话的消息历史(最近 20 条)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=20)
|
||||
|
||||
# 3. 获取已有标签(用于避免重复建议)
|
||||
existing_tags = {}
|
||||
if hasattr(conversation, 'tags') and conversation.tags:
|
||||
existing_tags = conversation.tags if isinstance(conversation.tags, dict) else {}
|
||||
|
||||
# 4. 调用 WingmanService 生成标签建议
|
||||
result = await wingman_service.suggest_tags(
|
||||
conversation_id=conversation_id,
|
||||
messages=messages,
|
||||
existing_tags=existing_tags,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
@@ -0,0 +1,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}")
|
||||
Reference in New Issue
Block a user