Files
wecom_it_smart_desk/docs/08-历史归档/消息功能详细方案-archived-20260704.md
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

535 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 消息功能详细方案
> **版本**: 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 列表(默认支持)
```
常用: 👍 👎 😊 😂 😢 😡 ❤️ 🔥 👏 🎉 😎
```