535 lines
15 KiB
Markdown
535 lines
15 KiB
Markdown
|
|
# 消息功能详细方案
|
|||
|
|
|
|||
|
|
> **版本**: v1.0
|
|||
|
|
> **日期**: 2026-06-14
|
|||
|
|
> **优先级**: P0 - 最高优先级
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一、现状与目标
|
|||
|
|
|
|||
|
|
### 1.1 当前问题
|
|||
|
|
|
|||
|
|
| 问题 | 影响 | 优先级 |
|
|||
|
|
|------|------|--------|
|
|||
|
|
| 3秒轮询,实时性差 | 用户体验差 | P0 |
|
|||
|
|
| 无消息状态 | 不知道是否送达 | P0 |
|
|||
|
|
| 无表情回应 | 交互单调 | P1 |
|
|||
|
|
| 无截图功能 | 无法快速上报问题 | P1 |
|
|||
|
|
| 媒体处理耦合企微 | 3天失效风险 | P0 |
|
|||
|
|
|
|||
|
|
### 1.2 目标
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────┐
|
|||
|
|
│ 消息功能 V2 目标 │
|
|||
|
|
├─────────────────────────────────────────────────────────┤
|
|||
|
|
│ ✅ 实时性: WebSocket 推送,毫秒级响应 │
|
|||
|
|
│ ✅ 消息状态: sent→delivered→read │
|
|||
|
|
│ ✅ 表情回应: emoji reactions │
|
|||
|
|
│ ✅ 截图上传: 屏幕截图快速上报 │
|
|||
|
|
│ ✅ 媒体独立: 本地存储,解耦企微 │
|
|||
|
|
│ ✅ 已读回执: 双向可见 │
|
|||
|
|
└─────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二、架构设计
|
|||
|
|
|
|||
|
|
### 2.1 技术选型
|
|||
|
|
|
|||
|
|
| 组件 | 选型 | 说明 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| 实时通信 | WebSocket | 已有基础(ws_manager) |
|
|||
|
|
| 消息状态 | Redis Key-Event | 轻量实现 |
|
|||
|
|
| 媒体存储 | 本地文件系统 + NAS | 解耦企微 |
|
|||
|
|
| 截图工具 | html2canvas + 粘贴 | 浏览器原生 |
|
|||
|
|
|
|||
|
|
### 2.2 系统架构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────┐
|
|||
|
|
│ WebSocket │
|
|||
|
|
│ 实时推送 │
|
|||
|
|
└───────┬─────────┘
|
|||
|
|
│
|
|||
|
|
┌───────────────────┼───────────────────┐
|
|||
|
|
▼ ▼ ▼
|
|||
|
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|||
|
|
│H5用户端 │ │坐席工作台│ │管理后台 │
|
|||
|
|
└────┬────┘ └────┬────┘ └────┬────┘
|
|||
|
|
│ │ │
|
|||
|
|
└───────────────────┼───────────────────┘
|
|||
|
|
▼
|
|||
|
|
┌───────────────────────┐
|
|||
|
|
│ 后端 WebSocket │
|
|||
|
|
│ ws_manager │
|
|||
|
|
└───────────┬───────────┘
|
|||
|
|
│
|
|||
|
|
┌────────────────┼────────────────┐
|
|||
|
|
▼ ▼ ▼
|
|||
|
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|||
|
|
│消息状态 │ │媒体存储 │ │事件广播 │
|
|||
|
|
│Redis │ │本地/NAS │ │Channel │
|
|||
|
|
└──────────┘ └──���───────┘ └──────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 数据流
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
用户A发送消息
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
POST /messages (创建消息,status=sent)
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
WebSocket 广播 new_message 给用户B
|
|||
|
|
│
|
|||
|
|
├── 用户B收到 → status=delivered
|
|||
|
|
│
|
|||
|
|
├── 用户B读取 → status=read + 已读回执
|
|||
|
|
│
|
|||
|
|
└── 用户A收到回执 → 更新消息状态
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三、消息模型扩展
|
|||
|
|
|
|||
|
|
### 3.1 新增字段
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/models/message.py 新增
|
|||
|
|
|
|||
|
|
class Message(Base):
|
|||
|
|
# ... 现有字段 ...
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# V2 新增字段
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
# 消息状态(V2新增)
|
|||
|
|
# sent: 已发送
|
|||
|
|
# delivered: 已送达(对方收到)
|
|||
|
|
# read: 已读
|
|||
|
|
message_status: Mapped[str] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=False,
|
|||
|
|
default="sent",
|
|||
|
|
comment="消息状态: sent/delivered/read",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 表情回应(V2新增)
|
|||
|
|
# 存储格式: {"👍": "user_id", "👎": "user_id", "😊": "user_id"}
|
|||
|
|
# 每个用户只能对同一消息添加一个表情
|
|||
|
|
reactions: Mapped[Optional[Dict[str, str]]] = mapped_column(
|
|||
|
|
JSON,
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="表情回应: {emoji: user_id}",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 消息来源设备(V2新增)
|
|||
|
|
# mobile: 手机端发送
|
|||
|
|
# desktop: 桌面端发送
|
|||
|
|
device_type: Mapped[str] = mapped_column(
|
|||
|
|
String(20),
|
|||
|
|
nullable=False,
|
|||
|
|
default="desktop",
|
|||
|
|
comment="设备类型: mobile/desktop",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 已读用户列表(V2新增)
|
|||
|
|
# 存储已读该消息的用户ID列表
|
|||
|
|
read_by: Mapped[Optional[List[str]]] = mapped_column(
|
|||
|
|
JSON,
|
|||
|
|
nullable=True,
|
|||
|
|
default=None,
|
|||
|
|
comment="已读用户列表",
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 DDL
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 消息模型 V2 DDL
|
|||
|
|
|
|||
|
|
ALTER TABLE messages
|
|||
|
|
ADD COLUMN message_status VARCHAR(20) NOT NULL DEFAULT 'sent',
|
|||
|
|
ADD COLUMN reactions JSON,
|
|||
|
|
ADD COLUMN device_type VARCHAR(20) NOT NULL DEFAULT 'desktop',
|
|||
|
|
ADD COLUMN read_by JSON;
|
|||
|
|
|
|||
|
|
-- 新增索引
|
|||
|
|
CREATE INDEX idx_messages_status ON messages(message_status);
|
|||
|
|
CREATE INDEX idx_messages_conversation_status ON messages(conversation_id, message_status);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四、API 设计
|
|||
|
|
|
|||
|
|
### 4.1 现有 API(保持兼容)
|
|||
|
|
|
|||
|
|
| 端点 | 方法 | 状态 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `/messages` | GET | ✅ 兼容 |
|
|||
|
|
| `/messages` | POST | ✅ 兼容 |
|
|||
|
|
|
|||
|
|
### 4.2 新增 API
|
|||
|
|
|
|||
|
|
| 端点 | 方法 | 功能 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `/messages/{id}/status` | PATCH | 更新消息状态 |
|
|||
|
|
| `/messages/{id}/reactions` | POST | 添加表情回应 |
|
|||
|
|
| `/messages/{id}/reactions` | DELETE | 移除表情回应 |
|
|||
|
|
| `/messages/poll` | GET | 轮询(保留兼容) |
|
|||
|
|
|
|||
|
|
### 4.3 API 详情
|
|||
|
|
|
|||
|
|
#### 4.3.1 更新消息状态
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
PATCH /api/messages/{id}/status
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
{
|
|||
|
|
"status": "delivered" | "read"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response:
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"message_status": "delivered" | "read",
|
|||
|
|
"read_by": ["user_id_1", "user_id_2"]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3.2 添加表情回应
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
POST /api/messages/{id}/reactions
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
{
|
|||
|
|
"emoji": "👍" // emoji Unicode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response:
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"reactions": {
|
|||
|
|
"👍": "user_id_1",
|
|||
|
|
"😊": "user_id_2"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3.3 移除表情回应
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
DELETE /api/messages/{id}/reactions
|
|||
|
|
|
|||
|
|
Response:
|
|||
|
|
{
|
|||
|
|
"id": "uuid",
|
|||
|
|
"reactions": {
|
|||
|
|
"😊": "user_id_2"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五、WebSocket 事件
|
|||
|
|
|
|||
|
|
### 5.1 现有事件(保持)
|
|||
|
|
|
|||
|
|
| 事件名 | 方向 | 说明 |
|
|||
|
|
|--------|------|------|
|
|||
|
|
| `new_message` | Server→Client | 新消息 |
|
|||
|
|
| `conversation_updated` | Server→Client | 会话更新 |
|
|||
|
|
|
|||
|
|
### 5.2 新增事件
|
|||
|
|
|
|||
|
|
| 事件名 | 方向 | 说明 |
|
|||
|
|
|--------|------|------|
|
|||
|
|
| `message_status_changed` | Server→Client | 消息状态变更 |
|
|||
|
|
| `reaction_added` | Server→Client | 表情回应添加 |
|
|||
|
|
| `reaction_removed` | Server→Client | 表情回应移除 |
|
|||
|
|
| `typing` | Client→Server | 对方正在输入 |
|
|||
|
|
| `typing` | Server→Client | 对方正在输入通知 |
|
|||
|
|
|
|||
|
|
### 5.3 事件格式
|
|||
|
|
|
|||
|
|
#### 5.3.1 消息状态变更
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"event": "message_status_changed",
|
|||
|
|
"data": {
|
|||
|
|
"message_id": "uuid",
|
|||
|
|
"status": "delivered",
|
|||
|
|
"changed_by": "user_id",
|
|||
|
|
"timestamp": "2026-06-14T11:30:00Z"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.3.2 表情回应
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"event": "reaction_added",
|
|||
|
|
"data": {
|
|||
|
|
"message_id": "uuid",
|
|||
|
|
"emoji": "👍",
|
|||
|
|
"user_id": "user_id",
|
|||
|
|
"user_name": "张三"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 5.3.3 Typing 通知
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"event": "typing",
|
|||
|
|
"data": {
|
|||
|
|
"conversation_id": "uuid",
|
|||
|
|
"user_id": "user_id",
|
|||
|
|
"user_name": "张三",
|
|||
|
|
"is_typing": true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 六、媒体处理(截图/图片/文件)
|
|||
|
|
|
|||
|
|
### 6.1 架构
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 媒体处理架构 │
|
|||
|
|
├─────────────────────────────────────────────────────────────┤
|
|||
|
|
│ │
|
|||
|
|
│ 用户上传 ──▶ 前端压缩/裁剪 ──▶ 上传API ──▶ 本地存储 │
|
|||
|
|
│ │ │ │
|
|||
|
|
│ ▼ ▼ │
|
|||
|
|
│ 生成缩略图 返回 media_url │
|
|||
|
|
│ │ │ │
|
|||
|
|
│ ▼ ▼ │
|
|||
|
|
│ 消息内容引用 media_url │
|
|||
|
|
│ │
|
|||
|
|
└─────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 上传流程
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/api/upload.py
|
|||
|
|
|
|||
|
|
@router.post("/upload", dependencies=[require_auth])
|
|||
|
|
async def upload_media(
|
|||
|
|
file: UploadFile,
|
|||
|
|
file_type: str = Form(...), # image/file/screenshot
|
|||
|
|
):
|
|||
|
|
"""媒体文件上传"""
|
|||
|
|
|
|||
|
|
# 1. 验证文件类型
|
|||
|
|
allowed_types = {
|
|||
|
|
"image": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
|||
|
|
"file": ["application/pdf", "application/msword",
|
|||
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|||
|
|
"screenshot": ["image/png", "image/webp"],
|
|||
|
|
}
|
|||
|
|
if file.content_type not in allowed_types.get(file_type, []):
|
|||
|
|
raise HTTPException(400, "不支持的文件类型")
|
|||
|
|
|
|||
|
|
# 2. 验证文件大小(10MB)
|
|||
|
|
if file.size > 10 * 1024 * 1024:
|
|||
|
|
raise HTTPException(400, "文件大小不能超过10MB")
|
|||
|
|
|
|||
|
|
# 3. 生成存储路径
|
|||
|
|
date_str = datetime.now().strftime("%Y/%m/%d")
|
|||
|
|
file_ext = Path(file.filename).suffix
|
|||
|
|
unique_name = f"{uuid.uuid4()}{file_ext}"
|
|||
|
|
relative_path = f"/media/{date_str}/{unique_name}"
|
|||
|
|
|
|||
|
|
# 4. 保存到本地
|
|||
|
|
upload_dir = Path(settings.MEDIA_UPLOAD_DIR) / date_str
|
|||
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
file_path = upload_dir / unique_name
|
|||
|
|
content = await file.read()
|
|||
|
|
file_path.write_bytes(content)
|
|||
|
|
|
|||
|
|
# 5. 生成缩略图(图片)
|
|||
|
|
thumbnail_url = None
|
|||
|
|
if file_type == "image" or file_type == "screenshot":
|
|||
|
|
thumbnail_url = await generate_thumbnail(file_path, unique_name)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"media_url": relative_path,
|
|||
|
|
"thumbnail_url": thumbnail_url,
|
|||
|
|
"file_size": len(content),
|
|||
|
|
"file_name": file.filename,
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 截图功能
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend-h5/src/components/ChatInput.vue
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref } from 'vue'
|
|||
|
|
|
|||
|
|
const handlePaste = async (event) => {
|
|||
|
|
const items = event.clipboardData?.items
|
|||
|
|
if (!items) return
|
|||
|
|
|
|||
|
|
for (const item of items) {
|
|||
|
|
if (item.type.startsWith('image/')) {
|
|||
|
|
const blob = item.getAsFile()
|
|||
|
|
if (blob) {
|
|||
|
|
await uploadScreenshot(blob)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const uploadScreenshot = async (blob) => {
|
|||
|
|
const formData = new FormData()
|
|||
|
|
formData.append('file', blob, 'screenshot.png')
|
|||
|
|
formData.append('file_type', 'screenshot')
|
|||
|
|
|
|||
|
|
const response = await fetch('/api/upload', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData,
|
|||
|
|
})
|
|||
|
|
const data = await response.json()
|
|||
|
|
emit('image-uploaded', data.media_url)
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div @paste="handlePaste">
|
|||
|
|
<!-- 输入框区域 -->
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 七、前端交互设计
|
|||
|
|
|
|||
|
|
### 7.1 消息卡片(V2)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌────────────────────────────────────────────────────┐
|
|||
|
|
│ 👤 张三 11:30 ✓✓ 已读 │
|
|||
|
|
│ │
|
|||
|
|
│ 这是消息内容... │
|
|||
|
|
│ │
|
|||
|
|
│ ┌─────┐ │
|
|||
|
|
│ │图片 │ ← 点击可预览 │
|
|||
|
|
│ └─────┘ │
|
|||
|
|
│ │
|
|||
|
|
│ 👍👎😊 ← 表情回应(点击选择) │
|
|||
|
|
└────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.2 表情选择器
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌──────────────────────────┐
|
|||
|
|
│ 👍 👎 😊 😂 😢 😡 ❤️ 🔥 │
|
|||
|
|
│ │
|
|||
|
|
│ [自定义表情...] │
|
|||
|
|
└──────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 截图快捷键
|
|||
|
|
|
|||
|
|
| 平台 | 快捷键 |
|
|||
|
|
|------|--------|
|
|||
|
|
| Windows | `Win + Shift + S` / `Ctrl + V` 粘贴 |
|
|||
|
|
| macOS | `Cmd + Shift + 4` / `Cmd + V` 粘贴 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 八、实施计划
|
|||
|
|
|
|||
|
|
### 8.1 任务拆分
|
|||
|
|
|
|||
|
|
| 序号 | 任务 | 工作量 | 依赖 |
|
|||
|
|
|------|------|--------|------|
|
|||
|
|
| T1 | 消息模型扩展 | 1d | - |
|
|||
|
|
| T2 | 媒体上传API | 2d | T1 |
|
|||
|
|
| T3 | WebSocket事件 | 1d | - |
|
|||
|
|
| T4 | 消息状态API | 1d | T1 |
|
|||
|
|
| T5 | 表情回应API | 1d | T1 |
|
|||
|
|
| T6 | 坐席端V2 | 2d | T3,T4,T5 |
|
|||
|
|
| T7 | H5端V2 | 2d | T2,T3,T4,T5 |
|
|||
|
|
| T8 | 截图功能 | 1d | T2 |
|
|||
|
|
| T9 | 联调测试 | 2d | T6,T7,T8 |
|
|||
|
|
|
|||
|
|
### 8.2 时间估算
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
总工期: 12 工作日
|
|||
|
|
|
|||
|
|
Week 1: ████████░░░░░░░░░░
|
|||
|
|
模型+API+WS (5d)
|
|||
|
|
|
|||
|
|
Week 2: ░░░░░░░████████░░░
|
|||
|
|
前端+截图 (5d)
|
|||
|
|
|
|||
|
|
Week 3: ░░░░░░░░░░░░████
|
|||
|
|
联调测试 (2d)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 九、兼容性
|
|||
|
|
|
|||
|
|
### 9.1 向后兼容
|
|||
|
|
|
|||
|
|
| 场景 | 处理 |
|
|||
|
|
|------|------|
|
|||
|
|
| 旧客户端连接 | 消息状态字段有默认值,不影响 |
|
|||
|
|
| 轮询仍然工作 | 保留 `/messages/poll` 兼容 |
|
|||
|
|
| 媒体未迁移 | 企微MediaID仍然可用 |
|
|||
|
|
|
|||
|
|
### 9.2 降级策略
|
|||
|
|
|
|||
|
|
| 故障场景 | 降级方案 |
|
|||
|
|
|----------|----------|
|
|||
|
|
| WS连接失败 | 降级到轮询 |
|
|||
|
|
| 媒体上传失败 | 提示用户重试 |
|
|||
|
|
| 表情功能不可用 | 隐藏表情按钮 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 十、待确认事项
|
|||
|
|
|
|||
|
|
- [ ] 媒体存储路径(本地 vs NAS)
|
|||
|
|
- [ ] 文件大小限制(当前10MB)
|
|||
|
|
- [ ] 支持的截图快捷键
|
|||
|
|
- [ ] 表情包自定义权限
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 附录
|
|||
|
|
|
|||
|
|
### A. Emoji 列表(默认支持)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
常用: 👍 👎 😊 😂 😢 😡 ❤️ 🔥 👏 🎉 😎
|
|||
|
|
```
|