A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过

This commit is contained in:
Simon
2026-07-02 19:06:12 +08:00
parent 78f60c6857
commit fc22de7f4d
12 changed files with 1813 additions and 106 deletions
+152 -2
View File
@@ -10,17 +10,19 @@
# 6. POST /api/conversations/{id}/mark-read — 标记已读
# 7. POST /api/messages/image — 上传图片
# 8. POST /api/messages/file — 上传文件
# 9. GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04
# 消息发送需同时:存数据库 + 调用企微API发送给员工
# =============================================================================
import logging
import os
import time
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 import select, update, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -29,7 +31,12 @@ 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
# RBAC 权限装饰器
from app.dependencies import require_permission
from app.services.wecom_service import WecomService
from app.services.ws_manager import manager
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
logger = logging.getLogger(__name__)
@@ -48,6 +55,7 @@ RECALLABLE_WINDOW_MINUTES = 2
# GET /api/conversations/{id}/messages — 获取会话消息列表
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages")
@require_permission("conversation", "read", "all")
async def list_messages(
conversation_id: str,
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
@@ -129,6 +137,7 @@ async def list_messages(
# POST /api/conversations/{id}/messages — 坐席发送消息
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/messages")
@require_permission("conversation", "create", "all")
async def send_message(
conversation_id: str,
body: MessageCreate,
@@ -184,6 +193,7 @@ async def send_message(
status="sending", # 初始状态为发送中
recallable_until=recallable_until,
is_read=True, # 坐席自己发的消息默认已读
server_timestamp=int(time.time() * 1000), # [MSG-P0-03] 服务端时间戳(毫秒)
)
db.add(message)
@@ -235,6 +245,7 @@ async def send_message(
# GET /api/conversations/{id}/messages/poll — 坐席轮询新消息
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages/poll")
@require_permission("conversation", "read", "all")
async def poll_messages(
conversation_id: str,
after_message_id: Optional[str] = Query(None, description="返回此消息ID之后的新消息"),
@@ -297,6 +308,7 @@ async def poll_messages(
# POST /api/messages/{id}/recall — 撤回消息(2分钟内)
# --------------------------------------------------------------------------
@router.post("/messages/{message_id}/recall")
@require_permission("conversation", "update", "own")
async def recall_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
@@ -342,8 +354,31 @@ async def recall_message(
# 将消息内容置为空,表示已撤回
message.content = "[消息已撤回]"
message.status = "recalled"
message.is_recalled = True # MSG-P1-01: 标记为已撤回
await db.flush()
# MSG-P1-01: 通过 WebSocket 广播撤回事件给所有参与者
conv_stmt = select(Conversation).where(Conversation.id == message.conversation_id)
conv_result = await db.execute(conv_stmt)
conversation = conv_result.scalars().first()
if conversation:
participant_ids = []
if conversation.assigned_agent_id:
participant_ids.append(conversation.assigned_agent_id)
if conversation.employee_id:
participant_ids.append(conversation.employee_id)
# 广播撤回事件
await manager.broadcast_message_status(
conv_id=message.conversation_id,
msg_id=message.id,
status="recalled",
participant_ids=participant_ids,
extra={
"recall_by": agent.user_id,
"recall_at": datetime.now().isoformat(),
},
)
return success_response(message="消息撤回成功")
@@ -351,6 +386,7 @@ async def recall_message(
# DELETE /api/messages/{id} — 删除消息
# --------------------------------------------------------------------------
@router.delete("/messages/{message_id}")
@require_permission("conversation", "update", "own")
async def delete_message(
message_id: str,
agent: Agent = Depends(get_current_agent),
@@ -394,6 +430,7 @@ async def delete_message(
# POST /api/conversations/{id}/mark-read — 标记已读
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/mark-read")
@require_permission("conversation", "update", "own")
async def mark_read(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
@@ -557,4 +594,117 @@ async def upload_message_file(
"file_size": file_size,
"content_type": file.content_type,
}
)
)
# --------------------------------------------------------------------------
# GET /api/conversations/{id}/messages/search — 搜索消息(MSG-P1-04
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}/messages/search")
async def search_messages(
conversation_id: str,
keyword: str = Query(..., description="搜索关键词"),
limit: int = Query(20, ge=1, le=100, description="返回结果数量限制"),
db: AsyncSession = Depends(get_db),
):
"""搜索会话消息(按关键词)。
使用 LIKE 查询匹配消息内容,支持模糊搜索。
Args:
conversation_id: 会话ID
keyword: 搜索关键词
limit: 返回结果数量限制
db: 数据库会话
Returns:
Dict: 统一响应格式,包含匹配的消息列表
"""
# 校验会话存在
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
# 构建搜索查询(使用 LIKE 进行模糊匹配)
# 排除已撤回的消息
search_pattern = f"%{keyword}%"
stmt = (
select(Message)
.where(Message.conversation_id == conv_id_str)
.where(Message.is_recalled == False) # 排除已撤回的消息
.where(Message.content.ilike(search_pattern)) # 不区分大小写匹配
.order_by(Message.created_at.desc()) # 最新消息在前
.limit(limit)
)
result = await db.execute(stmt)
messages = list(result.scalars().all())
# 转换为响应格式
items = [MessageResponse.model_validate(m).model_dump() for m in messages]
return success_response(
data={
"items": items,
"total": len(items),
"keyword": keyword,
}
)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/typing — 发送 typing 事件(MSG-P1-03
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/typing")
@require_permission("conversation", "read", "all")
async def send_typing_event(
conversation_id: str,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""发送 typing 事件,通知对方正在输入。
通过 WebSocket 广播 typing 事件给会话参与者。
Args:
conversation_id: 会话ID
agent: 当前坐席
db: 数据库会话
Returns:
Dict: 统一响应格式
"""
# 校验会话存在
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
# 构建参与者列表
participant_ids = []
if conversation.assigned_agent_id:
participant_ids.append(conversation.assigned_agent_id)
if conversation.employee_id:
participant_ids.append(conversation.employee_id)
# 广播 typing 事件(排除发送者本人)
payload = {
"type": "typing",
"conv_id": conv_id_str,
"sender_id": agent.user_id,
"sender_name": agent.name or "坐席",
}
for pid in participant_ids:
if pid != agent.user_id: # 不发给自己
if pid in manager.active_connections:
await manager.send_to_agent(pid, payload)
elif pid in manager.employee_connections:
await manager.send_to_employee(pid, payload)
return success_response(message="typing 事件已发送")