chore: 整理项目结构,清理归档文件,更新部署配置
This commit is contained in:
@@ -22,6 +22,7 @@ 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.schemas.conversation import (
|
||||
ConversationAssign,
|
||||
ConversationInvite,
|
||||
@@ -601,8 +602,8 @@ async def invite_participant(
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/join — 被邀请人加入会话
|
||||
# --------------------------------------------------------------------------
|
||||
# 注意:此端点允许被邀请的员工直接从H5加入,不需要坐席认证
|
||||
@router.post("/conversations/{conversation_id}/join")
|
||||
@require_permission("conversation", "update", "all")
|
||||
async def join_conversation(
|
||||
conversation_id: str,
|
||||
body: JoinConversationRequest,
|
||||
@@ -680,6 +681,7 @@ async def remove_participant(
|
||||
async def leave_as_participant(
|
||||
conversation_id: str,
|
||||
body: JoinConversationRequest,
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""参与者主动退出会话。
|
||||
|
||||
+33
-10
@@ -29,6 +29,7 @@ from typing import Optional
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
import redis
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
from slowapi import Limiter
|
||||
@@ -169,16 +170,38 @@ async def _get_current_employee(
|
||||
# =====================================================================
|
||||
if authorization:
|
||||
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
|
||||
if token and redis_client:
|
||||
try:
|
||||
employee_id_bytes = await redis_client.get(f"employee:token:{token}")
|
||||
if employee_id_bytes:
|
||||
# Redis 返回 bytes,需要解码
|
||||
return employee_id_bytes.decode("utf-8") if isinstance(employee_id_bytes, bytes) else employee_id_bytes
|
||||
except AppException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 读取失败: {e}")
|
||||
if token:
|
||||
if redis_client:
|
||||
try:
|
||||
employee_id_bytes = await redis_client.get(f"employee:token:{token}")
|
||||
if employee_id_bytes:
|
||||
# Redis 返回 bytes,需要解码
|
||||
return employee_id_bytes.decode("utf-8") if isinstance(employee_id_bytes, bytes) else employee_id_bytes
|
||||
except AppException:
|
||||
raise
|
||||
except redis.exceptions.TimeoutError as e:
|
||||
logger.error(f"Redis 连接超时,无法验证 Bearer Token: {e}")
|
||||
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 超时,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 读取失败,无法验证 Bearer Token: {e}")
|
||||
# Redis 不可用时:开发模式下降级使用 X-Employee-Id 明文头
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 异常,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
# 生产环境:返回明确的业务错误码,而非让框架转 500
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
else:
|
||||
# redis_client 为 None:dep_redis 返回了 None(Redis 连接创建失败)
|
||||
logger.error("Redis 客户端不可用,无法验证 Bearer Token")
|
||||
if x_employee_id and settings.mock_login_enabled:
|
||||
logger.warning("Redis 不可用,降级使用 X-Employee-Id 明文认证(仅开发环境)")
|
||||
return x_employee_id
|
||||
raise AppException(code=1003, message="认证服务暂不可用,请稍后重试")
|
||||
|
||||
# =====================================================================
|
||||
# 方式2:X-Employee-Id 明文头(仅开发环境,生产环境禁用)
|
||||
|
||||
+31
-10
@@ -170,7 +170,24 @@ async def send_message(
|
||||
if conversation.status == "resolved":
|
||||
raise ERR_CONVERSATION_RESOLVED
|
||||
|
||||
# 2. 创建消息记录
|
||||
# 2. 敏感词检测(#81 v0.6.0 内容审核)
|
||||
# 只对文本消息进行敏感词检测
|
||||
flagged_words = []
|
||||
if body.msg_type == "text" and body.content:
|
||||
from app.services.content_moderation_service import ContentModerationService
|
||||
moderation = ContentModerationService()
|
||||
result = moderation.moderate(body.content)
|
||||
if result.matched_words:
|
||||
flagged_words = result.matched_words
|
||||
# 检测到敏感词,但只警告不阻止发送
|
||||
logger.warning(
|
||||
f"[ContentModeration] 坐席消息含敏感词: "
|
||||
f"conversation={conv_id_str}, words={flagged_words}"
|
||||
)
|
||||
# 返回消息的同时带上警告信息(前端可选择显示提示)
|
||||
|
||||
# 3. 创建消息记录
|
||||
# (原步骤编号顺延)
|
||||
# 从会话的 assigned_agent_id 获取坐席信息
|
||||
agent_id = conversation.assigned_agent_id or "unknown"
|
||||
|
||||
@@ -197,7 +214,7 @@ async def send_message(
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# 3. 更新会话最后消息信息
|
||||
# 4. 更新会话最后消息信息
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.last_message_summary = body.content[:256]
|
||||
conversation.updated_at = datetime.now()
|
||||
@@ -205,7 +222,7 @@ async def send_message(
|
||||
|
||||
await db.flush() # 刷新以获取消息 ID
|
||||
|
||||
# 4. 调用企微 API 发送消息给员工
|
||||
# 5. 调用企微 API 发送消息给员工
|
||||
# 注意:只有 text 类型消息才需要调用企微 API 推送给员工
|
||||
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
|
||||
# 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时
|
||||
@@ -232,7 +249,7 @@ async def send_message(
|
||||
# 企微 API 调用失败不阻塞消息存储
|
||||
logger.warning(f"企微消息发送失败(消息已存储): {e}")
|
||||
|
||||
# 5. 更新消息状态为已发送
|
||||
# 6. 更新消息状态为已发送
|
||||
message.status = "sent"
|
||||
await db.flush()
|
||||
|
||||
@@ -249,6 +266,7 @@ async def send_message(
|
||||
async def poll_messages(
|
||||
conversation_id: str,
|
||||
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
|
||||
current_agent: Agent = Depends(get_current_agent), # 添加此参数以支持权限验证
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席轮询新消息。
|
||||
@@ -311,7 +329,7 @@ async def poll_messages(
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def recall_message(
|
||||
message_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""撤回消息(2分钟内)。
|
||||
@@ -325,12 +343,13 @@ async def recall_message(
|
||||
|
||||
Args:
|
||||
message_id: 消息ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
# 查询消息
|
||||
stmt = select(Message).where(Message.id == str(message_id))
|
||||
result = await db.execute(stmt)
|
||||
@@ -389,7 +408,7 @@ async def recall_message(
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def delete_message(
|
||||
message_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除坐席自己发送的消息。
|
||||
@@ -401,12 +420,13 @@ async def delete_message(
|
||||
|
||||
Args:
|
||||
message_id: 消息ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
# 查询消息
|
||||
stmt = select(Message).where(Message.id == str(message_id))
|
||||
result = await db.execute(stmt)
|
||||
@@ -433,7 +453,7 @@ async def delete_message(
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def mark_read(
|
||||
conversation_id: str,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""标记会话中所有员工未读消息为已读。
|
||||
@@ -449,12 +469,13 @@ async def mark_read(
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
agent: 当前坐席(鉴权依赖注入)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
agent = current_agent # 保持函数体内 agent 引用不变
|
||||
conv_id_str = str(conversation_id)
|
||||
|
||||
# P0-4 修复:先校验当前坐席有权访问此会话
|
||||
|
||||
@@ -170,8 +170,9 @@ async def switch_role(
|
||||
if not switch_success:
|
||||
raise AppException(4003, "角色切换失败")
|
||||
|
||||
# 获取目标角色的入口 URL
|
||||
redirect_url = _get_role_url(body.new_role)
|
||||
# 获取目标角色的入口 URL(传递 token 以便目标前端直接认证)
|
||||
token = credentials.credentials
|
||||
redirect_url = _get_role_url(body.new_role, token)
|
||||
|
||||
logger.info(f"用户 {current_user.employee_id} 切换角色到 {body.new_role}")
|
||||
|
||||
@@ -191,6 +192,7 @@ async def get_role_entry(
|
||||
role_name: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""获取角色对应的入口 URL。
|
||||
|
||||
@@ -198,6 +200,7 @@ async def get_role_entry(
|
||||
role_name: 角色标识
|
||||
current_user: 当前用户(通过认证依赖注入)
|
||||
db: 数据库会话
|
||||
credentials: HTTP Bearer Token
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含角色信息和入口 URL
|
||||
@@ -215,8 +218,9 @@ async def get_role_entry(
|
||||
if not target_role:
|
||||
raise AppException(4003, f"没有 {role_name} 角色权限")
|
||||
|
||||
# 获取入口 URL
|
||||
redirect_url = _get_role_url(role_name)
|
||||
# 获取入口 URL(传递 token 以便目标前端直接认证)
|
||||
token = credentials.credentials
|
||||
redirect_url = _get_role_url(role_name, token)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -230,20 +234,29 @@ async def get_role_entry(
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数:获取角色对应的 URL
|
||||
# --------------------------------------------------------------------------
|
||||
def _get_role_url(role_name: str) -> str:
|
||||
def _get_role_url(role_name: str, token: str = None) -> str:
|
||||
"""获取角色对应的前端 URL。
|
||||
|
||||
Args:
|
||||
role_name: 角色标识
|
||||
token: 可选的访问令牌,用于附加到重定向URL
|
||||
|
||||
Returns:
|
||||
str: 前端 URL
|
||||
str: 前端 URL(带token参数)
|
||||
"""
|
||||
role_urls = {
|
||||
"user": "/itdesk/",
|
||||
"agent": "/itagent/",
|
||||
"admin": "/itadmin/",
|
||||
}
|
||||
return role_urls.get(role_name, "/itdesk/")
|
||||
base_url = role_urls.get(role_name, "/itdesk/")
|
||||
|
||||
# 如果提供了token,附加到URL参数
|
||||
if token:
|
||||
# 添加 token 参数,使用 ? 或 & 连接
|
||||
separator = "&" if "?" in base_url else "?"
|
||||
return f"{base_url}{separator}token={token}"
|
||||
|
||||
return base_url
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 服务路由定义
|
||||
# =============================================================================
|
||||
# 说明:根据 SERVICE_NAME 环境变量定义各服务需要加载的路由
|
||||
# 支持 5 种服务:core, conversation, agent, ai, admin
|
||||
# 不设置 SERVICE_NAME 时加载全部路由(向后兼容)
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
from fastapi import APIRouter
|
||||
|
||||
# 路由模块导入
|
||||
from app.api import (
|
||||
wecom_callback,
|
||||
conversations,
|
||||
messages,
|
||||
agents,
|
||||
quick_replies,
|
||||
h5,
|
||||
agent_notes,
|
||||
system,
|
||||
wingman,
|
||||
todo_items,
|
||||
troubleshooting_templates,
|
||||
employees,
|
||||
upload,
|
||||
admin_api,
|
||||
portal,
|
||||
admin_roles,
|
||||
approval,
|
||||
wecom_jsapi,
|
||||
auth_qrcode,
|
||||
high_risk_routes,
|
||||
mfa,
|
||||
auth_wecom_sso,
|
||||
audit_logs,
|
||||
)
|
||||
# admin 子目录的路由需要单独导入
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
|
||||
# MFA 有两个 router,需要特殊处理
|
||||
_MFA_ROUTER = mfa.router
|
||||
_MFA_ADMIN_ROUTER = mfa.admin_router
|
||||
|
||||
|
||||
# 路由定义:模块名 -> (router对象, tags, prefix)
|
||||
# prefix 为空时使用路由对象默认的 prefix
|
||||
_ROUTE_MODULES = {
|
||||
# 企微回调
|
||||
"wecom_callback": (wecom_callback.router, ["企微回调"], None),
|
||||
# 会话管理
|
||||
"conversations": (conversations.router, ["会话管理"], None),
|
||||
"messages": (messages.router, ["消息管理"], None),
|
||||
# 坐席管理
|
||||
"agents": (agents.router, ["坐席管理"], None),
|
||||
"quick_replies": (quick_replies.router, ["快速回复"], None),
|
||||
# H5 用户端
|
||||
"h5": (h5.router, ["H5用户端"], None),
|
||||
# 坐席备注
|
||||
"agent_notes": (agent_notes.router, ["坐席备注"], None),
|
||||
# 系统管理
|
||||
"system": (system.router, ["系统管理"], None),
|
||||
# AI Wingman
|
||||
"wingman": (wingman.router, ["AI Wingman"], None),
|
||||
# 待办事项
|
||||
"todo_items": (todo_items.router, ["待办事项"], None),
|
||||
# 排查模板
|
||||
"troubleshooting_templates": (troubleshooting_templates.router, ["排查模板"], None),
|
||||
# 员工管理
|
||||
"employees": (employees.router, ["员工管理"], None),
|
||||
# 文件上传
|
||||
"upload": (upload.router, ["文件上传"], None),
|
||||
# 管理后台
|
||||
"admin_api": (admin_api.router, ["管理后台"], None),
|
||||
# Portal 统一入口
|
||||
"portal": (portal.router, ["统一入口"], None),
|
||||
# 角色管理
|
||||
"admin_roles": (admin_roles.router, ["角色管理"], None),
|
||||
# 审批流程
|
||||
"approval": (approval.router, ["审批流程"], None),
|
||||
# 企微 JS-SDK
|
||||
"wecom_jsapi": (wecom_jsapi.router, ["企微JS-SDK"], None),
|
||||
# 扫码登录
|
||||
"auth_qrcode": (auth_qrcode.router, ["扫码登录"], None),
|
||||
# 高危操作
|
||||
"high_risk_routes": (high_risk_routes.router, ["高危操作"], None),
|
||||
# MFA 二次认证(用户端和管理端分开)
|
||||
"mfa": (_MFA_ROUTER, ["MFA二次认证"], None),
|
||||
"mfa_admin": (_MFA_ADMIN_ROUTER, ["MFA管理(管理员)"], None),
|
||||
# 企微 SSO
|
||||
"auth_wecom_sso": (auth_wecom_sso.router, ["企微SSO"], None),
|
||||
# 审计日志
|
||||
"audit_logs": (audit_logs.router, ["审计日志"], None),
|
||||
# 终端安全对比
|
||||
"security_comparison": (security_comparison_router, ["终端安全对比"], None),
|
||||
}
|
||||
|
||||
|
||||
# 服务路由映射:服务名 -> 需要加载的路由模块列表
|
||||
SERVICE_ROUTE_MAP: Dict[str, List[str]] = {
|
||||
# Core 服务:核心服务(鉴权、员工、角色)
|
||||
"core": [
|
||||
"employees", # 员工管理
|
||||
"auth_qrcode", # 扫码登录
|
||||
"mfa", # MFA 二次认证
|
||||
"auth_wecom_sso", # 企微 SSO
|
||||
"portal", # 角色切换
|
||||
],
|
||||
# Conversation 服务:会话服务(会话、消息、H5、WebSocket)
|
||||
"conversation": [
|
||||
"wecom_callback", # 企微消息接收
|
||||
"conversations", # 会话管理
|
||||
"messages", # 消息管理
|
||||
"h5", # H5 用户端
|
||||
"todo_items", # 待办事项
|
||||
"troubleshooting_templates", # 排查模板
|
||||
],
|
||||
# Agent 服务:坐席服务
|
||||
"agent": [
|
||||
"agents", # 坐席管理
|
||||
"quick_replies", # 快速回复
|
||||
"agent_notes", # 坐席备注
|
||||
"approval", # 审批流程
|
||||
],
|
||||
# AI 服务:AI 服务(Dify 调用、Wingman)
|
||||
"ai": [
|
||||
"wingman", # AI Wingman
|
||||
],
|
||||
# Admin 服务:管理服务(仪表盘、配置、集成、审核)
|
||||
"admin": [
|
||||
"admin_api", # 管理后台 API(必须放第一个,因为 security_comparison 依赖它)
|
||||
"admin_roles", # 角色管理
|
||||
"audit_logs", # 审计日志
|
||||
"high_risk_routes", # 高危操作
|
||||
"system", # 系统管理
|
||||
"upload", # 文件上传
|
||||
"wecom_jsapi", # 企微 JS-SDK
|
||||
"security_comparison", # 终端安全对比
|
||||
"mfa_admin", # MFA 管理端
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_routes_for_service(service_name: str) -> List[Tuple]:
|
||||
"""根据服务名获取需要加载的路由列表
|
||||
|
||||
Args:
|
||||
service_name: 服务名 (core/conversation/agent/ai/admin) 或 None/空
|
||||
|
||||
Returns:
|
||||
路由元组列表:[(router, tags, prefix), ...]
|
||||
"""
|
||||
# 不设置 SERVICE_NAME 或设置为 "all" 时,加载全部路由(向后兼容)
|
||||
if not service_name or service_name.lower() == "all":
|
||||
return [
|
||||
(_ROUTE_MODULES[name][0], _ROUTE_MODULES[name][1], _ROUTE_MODULES[name][2])
|
||||
for name in _ROUTE_MODULES.keys()
|
||||
]
|
||||
|
||||
# 根据服务名获取路由列表
|
||||
route_names = SERVICE_ROUTE_MAP.get(service_name.lower(), [])
|
||||
|
||||
# 查找并返回路由对象
|
||||
result = []
|
||||
for name in route_names:
|
||||
if name in _ROUTE_MODULES:
|
||||
result.append((
|
||||
_ROUTE_MODULES[name][0],
|
||||
_ROUTE_MODULES[name][1],
|
||||
_ROUTE_MODULES[name][2],
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_current_service_name() -> str:
|
||||
"""获取当前服务名称
|
||||
|
||||
Returns:
|
||||
SERVICE_NAME 环境变量,或空字符串
|
||||
"""
|
||||
return os.getenv("SERVICE_NAME", "")
|
||||
|
||||
|
||||
def is_service_mode() -> bool:
|
||||
"""判断是否处于服务模式(非单体模式)
|
||||
|
||||
Returns:
|
||||
True 如果设置了有效的 SERVICE_NAME
|
||||
"""
|
||||
name = get_current_service_name()
|
||||
return bool(name and name.lower() != "all")
|
||||
@@ -115,7 +115,7 @@ async def websocket_endpoint(
|
||||
# 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无效或已过期")
|
||||
logger.warning(f"WebSocket 拒绝连接: agent_id={agent_id}, token={token[:20] if token else 'empty'}..., 原因=token无效或已过期")
|
||||
return
|
||||
|
||||
if stored_agent_id != agent_id:
|
||||
|
||||
Reference in New Issue
Block a user