chore: 整理项目结构,清理归档文件,更新部署配置

This commit is contained in:
Simon
2026-07-04 21:01:39 +08:00
parent 8bd4ab0366
commit 64ff1bf7d5
508 changed files with 43575 additions and 14129 deletions
+3 -1
View File
@@ -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
View File
@@ -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 为 Nonedep_redis 返回了 NoneRedis 连接创建失败)
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="认证服务暂不可用,请稍后重试")
# =====================================================================
# 方式2X-Employee-Id 明文头(仅开发环境,生产环境禁用)
+31 -10
View File
@@ -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 修复:先校验当前坐席有权访问此会话
+20 -7
View File
@@ -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
+193
View File
@@ -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")
+1 -1
View File
@@ -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: