252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微IT智能服务台 — 消息模型
|
|||
|
|
# =============================================================================
|
|||
|
|
# 说明:对应数据库 messages 表,存储会话中的所有消息
|
|||
|
|
# 消息来源:员工(employee)、坐席(agent)、AI(ai)、系统(system)
|
|||
|
|
# 消息类型:文本(text)、图片(image)、文件(file)、语音(voice)、系统提示(system)
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
from typing import Any, Dict, Optional
|
|||
|
|
|
|||
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
|
|||
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|||
|
|
|
|||
|
|
from app.database import Base
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Message(Base):
|
|||
|
|
"""消息模型 — 对应 messages 表。
|
|||
|
|
|
|||
|
|
每条消息都属于一个会话(Conversation),记录对话中的每一条信息。
|
|||
|
|
包含发送者信息、消息内容、消息类型等。
|
|||
|
|
|
|||
|
|
Attributes:
|
|||
|
|
id: 消息唯一标识(UUID,数据库自动生成)
|
|||
|
|
conversation_id: 所属会话ID(外键,关联 conversations 表)
|
|||
|
|
sender_type: 发送者类型(employee/agent/ai/system)
|
|||
|
|
sender_id: 发送者ID
|
|||
|
|
sender_name: 发送者姓名(冗余存储,减少关联查询)
|
|||
|
|
content: 消息内容(文本消息为文字,媒体消息为描述文字或URL)
|
|||
|
|
msg_type: 消息类型(text/image/file/voice/system)
|
|||
|
|
media_id: 企微媒体文件ID(图片/语音/视频消息,3天有效)
|
|||
|
|
media_url: 本地存储的媒体文件URL(下载后保存到服务器)
|
|||
|
|
file_name: 文件名(文件消息用)
|
|||
|
|
file_size: 文件大小(字节)
|
|||
|
|
extra_data: 扩展元数据(JSON,如图片尺寸、语音格式等)
|
|||
|
|
ai_suggestion: 是否为AI建议(坐席端展示用)
|
|||
|
|
status: 消息状态(sending/sent/delivered/read)
|
|||
|
|
recallable_until: 可撤回截止时间(创建时间+2分钟)
|
|||
|
|
is_read: 是否已读
|
|||
|
|
created_at: 创建时间
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# 表名(必须和架构文档 DDL 一致)
|
|||
|
|
__tablename__ = "messages"
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 字段定义
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# 主键:UUID,Python端生成(兼容PostgreSQL和SQLite)
|
|||
|
|
id: Mapped[str] = mapped_column(
|
|||
|
|
String(36),
|
|||
|
|
primary_key=True,
|
|||
|
|
default=lambda: str(uuid.uuid4()),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 所属会话ID(外键,关联 conversations 表)
|
|||
|
|
# ON DELETE CASCADE:删除会话时自动删除该会话的所有消息
|
|||
|
|
conversation_id: Mapped[str] = mapped_column(
|
|||
|
|
String(36),
|
|||
|
|
ForeignKey("conversations.id", ondelete="CASCADE"),
|
|||
|
|
nullable=False,
|
|||
|
|
comment="所属会话ID",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 发送者类型(CHECK 约束:只能取四种值)
|
|||
|
|
# employee: 员工发送的消息
|
|||
|
|
# agent: 坐席发送的消息
|
|||
|
|
# ai: AI生成的消息(第二步启用)
|
|||
|
|
# system: 系统消息(如"坐席已接入"等通知)
|
|||
|
|
sender_type: Mapped[str] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=False,
|
|||
|
|
comment="发送者类型: employee/agent/ai/system",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 发送者ID
|
|||
|
|
# 员工消息时为企微UserID,坐席消息时为坐席user_id
|
|||
|
|
sender_id: Mapped[str] = mapped_column(
|
|||
|
|
String(64),
|
|||
|
|
nullable=False,
|
|||
|
|
comment="发送者ID",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 发送者姓名(冗余存储,避免每次查消息都要关联用户表)
|
|||
|
|
sender_name: Mapped[str] = mapped_column(
|
|||
|
|
String(128),
|
|||
|
|
nullable=False,
|
|||
|
|
default="",
|
|||
|
|
comment="发送者姓名",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 消息内容
|
|||
|
|
# 文本消息时为文本内容,图片/文件消息时为媒体URL或描述文字
|
|||
|
|
content: Mapped[str] = mapped_column(
|
|||
|
|
Text,
|
|||
|
|
nullable=False,
|
|||
|
|
default="",
|
|||
|
|
comment="消息内容",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 消息类型(CHECK 约束)
|
|||
|
|
# text: 文本消息
|
|||
|
|
# image: 图片消息
|
|||
|
|
# file: 文件消息
|
|||
|
|
# voice: 语音消息
|
|||
|
|
# system: 系统消息
|
|||
|
|
msg_type: Mapped[str] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=False,
|
|||
|
|
default="text",
|
|||
|
|
comment="消息类型: text/image/file/voice/system",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 引用回复:指向被回复的消息ID(M1 新增)
|
|||
|
|
# 为 None 时表示普通消息,非 None 时表示对某条消息的回复
|
|||
|
|
# 前端根据此字段显示引用内容(被回复消息的摘要)
|
|||
|
|
reply_to_id: Mapped[Optional[str]] = mapped_column(
|
|||
|
|
String(36),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="引用回复:被回复的消息ID",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 企微媒体文件ID(图片/语音/视频消息携带)
|
|||
|
|
# 注意:MediaId 仅3天有效,收到消息后应尽快下载保存到本地
|
|||
|
|
# 下载接口:GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
|
|||
|
|
media_id: Mapped[Optional[str]] = mapped_column(
|
|||
|
|
String(256),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="企微媒体文件ID(3天有效)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 本地存储的媒体文件URL(下载后保存到服务器/NAS的访问路径)
|
|||
|
|
# 格式示例:/media/2026/06/03/abc123.jpg
|
|||
|
|
media_url: Mapped[Optional[str]] = mapped_column(
|
|||
|
|
String(512),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="本地存储的媒体文件URL",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 文件名(文件消息携带,或下载后自定义的文件名)
|
|||
|
|
file_name: Mapped[Optional[str]] = mapped_column(
|
|||
|
|
String(256),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="文件名",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 文件大小(字节,文件消息携带)
|
|||
|
|
file_size: Mapped[Optional[int]] = mapped_column(
|
|||
|
|
Integer,
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="文件大小(字节)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 扩展元数据(JSON格式,存储各消息类型的额外信息)
|
|||
|
|
# 示例:
|
|||
|
|
# 图片消息: {"pic_url": "https://...", "width": 1920, "height": 1080}
|
|||
|
|
# 语音消息: {"format": "amr", "duration_seconds": 15}
|
|||
|
|
# 视频消息: {"thumb_media_id": "...", "duration_seconds": 30}
|
|||
|
|
# 位置消息: {"location_x": 23.134, "location_y": 113.358, "label": "杭州市"}
|
|||
|
|
extra_data: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
|||
|
|
JSON,
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="扩展元数据(JSON)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 是否为AI建议(坐席端展示用)
|
|||
|
|
# True: 此消息为AI建议的回复,坐席可选择采纳/编辑/忽略
|
|||
|
|
# False: 正常消息
|
|||
|
|
ai_suggestion: Mapped[bool] = mapped_column(
|
|||
|
|
Boolean,
|
|||
|
|
nullable=False,
|
|||
|
|
default=False,
|
|||
|
|
comment="是否为AI建议",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 消息状态(新增:sending/sent/delivered/read)
|
|||
|
|
# sending: 发送中
|
|||
|
|
# sent: 已发送
|
|||
|
|
# delivered: 已送达
|
|||
|
|
# read: 已读
|
|||
|
|
status: Mapped[str] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=False,
|
|||
|
|
default="sent",
|
|||
|
|
comment="消息状态: sending/sent/delivered/read",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 可撤回截止时间(创���时间+2分钟)
|
|||
|
|
# 用于判断消息是否在可撤回时间窗口内
|
|||
|
|
recallable_until: Mapped[Optional[datetime]] = mapped_column(
|
|||
|
|
DateTime(timezone=True),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="可撤回截止时间",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 是否已读
|
|||
|
|
# 用于统计未读消息数(坐席端显示红点)
|
|||
|
|
is_read: Mapped[bool] = mapped_column(
|
|||
|
|
Boolean,
|
|||
|
|
nullable=False,
|
|||
|
|
default=False,
|
|||
|
|
comment="是否已读",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 坐席对 AI 建议的操作行为
|
|||
|
|
# 仅当 ai_suggestion=True 时有意义
|
|||
|
|
# accepted: 坐席直接采纳了AI建议
|
|||
|
|
# edited: 坐席编辑后采纳了AI建议
|
|||
|
|
# ignored: 坐席忽略了AI建议
|
|||
|
|
suggestion_action: Mapped[Optional[str]] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="AI建议操作行为: accepted/edited/ignored",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 创建时间
|
|||
|
|
created_at: Mapped[datetime] = mapped_column(
|
|||
|
|
DateTime(timezone=True),
|
|||
|
|
nullable=False,
|
|||
|
|
default=datetime.now,
|
|||
|
|
comment="创建时间",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 索引定义(和架构文档 DDL 严格一致)
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
__table_args__ = (
|
|||
|
|
# 按会话ID查询(如查询某会话的所有消息)
|
|||
|
|
Index("idx_messages_conversation_id", "conversation_id"),
|
|||
|
|
# 按创建时间查询(如按时间排序消息)
|
|||
|
|
Index("idx_messages_created_at", "created_at"),
|
|||
|
|
# 复合索引:按会话+时间查询(最常见的查询:获取某会话的消息列表)
|
|||
|
|
Index("idx_messages_conversation_created", "conversation_id", "created_at"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def __repr__(self) -> str:
|
|||
|
|
"""消息对象的字符串表示,方便调试。"""
|
|||
|
|
return (
|
|||
|
|
f"<Message(id={self.id}, conv={self.conversation_id}, "
|
|||
|
|
f"from={self.sender_type}, type={self.msg_type})>"
|
|||
|
|
)
|