A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过
This commit is contained in:
+152
-2
@@ -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 事件已发送")
|
||||
Reference in New Issue
Block a user