1
0
forked from erp-dev/erp
Files
erpnew/docs/sse_refactor.md

910 lines
24 KiB
Markdown
Raw 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.
# SSE 模块重构文档
## 1. 背景与问题
### 1.1 当前状态
SSEServer-Sent Events模块已被停用返回 405原因是出现了**致命的数据库连接泄漏问题**
- Django 的 SSE 长连接会无限制地消耗数据库连接Database Connection
- 连接长时间不释放,导致数据库连接池耗尽
- 严重影响系统稳定性,导致其他业务无法正常访问数据库
### 1.2 重构目标
将 SSE 模块迁移到**独立的 Golang 进程**运行,与 Django 后端解耦:
- Golang 的 goroutine 模型天然适合处理大量长连接
- 不依赖 Django 的数据库连接池
- 更好的资源管理和并发性能
- 系统解耦SSE 服务可独立扩展和部署
---
## 2. 当前架构概述
### 2.1 目录结构
```
sse/
├── __init__.py
├── admin.py
├── apps.py
├── auth_utils.py # JWT 认证工具
├── client_example.js # 前端客户端示例
├── migrations/
├── minimal_sse.py # 极简实现(尝试解决关闭问题)
├── models.py # 空模型文件
├── serializers.py # DRF 序列化器
├── services.py # 核心服务层(连接管理、消息推送)
├── simple_sse.py # 简化实现(尝试解决关闭问题)
└── views.py # API 视图(已禁用)
```
### 2.2 当前 API 端点
| 端点 | 方法 | 描述 | 状态 |
|------|------|------|------|
| `/sse/` | GET | SSE 事件流订阅 | ❌ 已禁用 |
| `/sse/push/` | POST | 推送测试事件 | ✅ 可用 |
| `/sse/status/` | GET | 获取连接状态 | ✅ 可用 |
| `/sse/shutdown/` | POST | 关闭商户连接 | ✅ 可用 |
---
## 3. 核心设计原则
### 3.1 透明转发架构(关键设计)
**Golang SSE 服务应该是"不关心业务内容的转发层"**,这样可以避免 Django 和 Golang 之间的消息类型重复定义。
```
┌─────────────┐ 任意 JSON ┌─────────────┐ 原样转发 ┌─────────────┐
│ Django │ ─────────────────► │ Golang SSE │ ─────────────────► │ 前端 │
│ (业务层) │ merchant_id + │ (转发层) │ │ (消费者) │
└─────────────┘ payload └─────────────┘ └─────────────┘
```
**核心原则**
1. **Django 是唯一定义消息类型的地方** - 所有业务事件类型只在 Django 定义
2. **Golang 只负责三件事** - 认证、路由(按 merchant_id、转发
3. **Golang 不解析业务内容** - 使用 `json.RawMessage` 原样转发
4. **新增消息类型 = 只改 Django** - Golang 服务一旦写好几乎不需要再改
**Golang 内部推送接口**
```go
// 只需要这一个结构体,永远不变
type BroadcastRequest struct {
MerchantID int64 `json:"merchant_id"`
Payload json.RawMessage `json:"payload"` // 不解析,直接转发
}
func handleBroadcast(w http.ResponseWriter, r *http.Request) {
var req BroadcastRequest
json.NewDecoder(r.Body).Decode(&req)
// 直接转发给该商户的所有连接,不解析 payload
broadcastToMerchant(req.MerchantID, req.Payload)
}
```
**Django 推送封装**
```python
# sse/client.py - Django 调用 Golang SSE 服务的客户端
import requests
from django.conf import settings
SSE_SERVICE_URL = getattr(settings, 'SSE_SERVICE_URL', 'http://localhost:8080')
def push_to_sse(merchant_id: int, payload: dict):
"""向 Golang SSE 服务推送消息(透明转发)"""
try:
requests.post(
f"{SSE_SERVICE_URL}/internal/broadcast",
json={
"merchant_id": merchant_id,
"payload": payload # 任意 JSONGolang 不解析
},
timeout=5
)
except Exception as e:
logger.warning(f"SSE push failed: {e}")
```
### 3.2 多租户隔离
系统是多商户Multi-tenant架构SSE 消息必须按商户隔离:
- 每个用户通过 JWT Token 中的 `user.employee.merchant_id` 确定所属商户
- 消息只推送给同一商户下的所有连接客户端
- 不同商户之间的消息完全隔离
### 3.3 认证机制
使用 JWTJSON Web Token认证
```
Authorization: Bearer <JWT_TOKEN>
```
认证流程:
1. 从请求头提取 Bearer Token
2. 使用 Django Simple JWT 验证 Token
3. 获取用户信息:`user = jwt_authenticator.get_user(validated_token)`
4. 获取商户 ID`merchant_id = user.employee.merchant_id`
5. 无商户关联的用户拒绝连接
**重要**Golang 服务需要能够验证 Django 签发的 JWT Token需要共享相同的 SECRET_KEY 或使用 RS256 算法。
---
## 4. 消息规范
### 4.1 消息格式
所有 SSE 消息均为 JSON 格式,通过 `data:` 前缀发送:
```
data: {"type": "xxx", ...}\n\n
```
### 4.2 系统消息类型
#### 连接成功消息
当客户端成功建立 SSE 连接后,立即发送:
```json
{
"type": "connected",
"message": "SSE connection established",
"merchant_id": 1
}
```
#### 心跳消息
保持连接活跃,使用 SSE 注释格式:
```
: heartbeat\n\n
```
或带时间戳的格式:
```
: heartbeat 1704067200.123\n\n
```
心跳间隔建议:**30 秒**
#### 服务器关闭消息
服务器主动关闭连接时发送:
```json
{
"type": "server_shutdown",
"message": "Server shutting down your connections, please reconnect later"
}
```
### 4.3 业务消息类型
业务消息统一格式:
```json
{
"mode": "simple_message",
"type": "<event_type>",
"message": "<human_readable_message>",
"object_id": <related_object_id>,
"merchant_id": <merchant_id>
}
```
字段说明:
- `mode`: 固定为 `"simple_message"`,标识这是业务消息
- `type`: 事件类型,用于前端区分处理逻辑
- `message`: 人类可读的消息内容(中文)
- `object_id`: 关联业务对象的 ID整数或字符串
- `merchant_id`: 商户 ID便于客户端二次验证
#### 已定义的业务事件类型
| 事件类型 | 说明 | 触发场景 |
|----------|------|----------|
| `order_paid` | 订单已支付 | 订单支付完成 |
| `stock_change` | 库存变动 | 库存入库/出库完成 |
**注意**未来可能增加更多事件类型Golang 服务应设计为可扩展的。
---
## 5. 服务接口设计
### 5.1 SSE 连接端点
**端点**: `GET /sse/`
**请求头**:
```
Authorization: Bearer <JWT_TOKEN>
Accept: text/event-stream
Cache-Control: no-cache
```
**响应头**:
```
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
Connection: keep-alive
```
**CORS 支持**:
```
Access-Control-Allow-Origin: <request_origin>
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: authorization, content-type, cache-control, accept
```
### 5.2 消息推送接口(透明转发)
Django 后端通过 HTTP API 将消息推送给 Golang SSE 服务。
**关键设计**Golang 不解析 `payload` 内容,只负责路由和转发。
**端点**: `POST /internal/broadcast`
```
POST /internal/broadcast
Content-Type: application/json
{
"merchant_id": 1,
"payload": {
"action": "invalidate",
"resources": ["stock-records"],
"message": "库存已更新",
"toast": true
}
}
```
**Golang 处理逻辑**
```go
type BroadcastRequest struct {
MerchantID int64 `json:"merchant_id"`
Payload json.RawMessage `json:"payload"` // 原样转发,不解析
}
func handleBroadcast(w http.ResponseWriter, r *http.Request) {
var req BroadcastRequest
json.NewDecoder(r.Body).Decode(&req)
// 直接转发,不解析 payload 内容
broadcastToMerchant(req.MerchantID, req.Payload)
w.WriteHeader(http.StatusOK)
}
```
**备选方案Redis Pub/Sub**
如果需要更高的可靠性或解耦:
- Django 发布消息到 Redis Channel`sse:merchant:{merchant_id}`
- Golang 服务订阅对应 Channel 并转发给客户端
- 优点:异步、解耦、可靠性更高
- 缺点:增加 Redis 依赖和复杂度
### 5.3 状态查询接口
**端点**: `GET /sse/status/`
**响应**:
```json
{
"status": "running",
"total_clients": 10,
"merchant_clients": 3,
"merchant_id": 1,
"message": "SSE server is running with 10 total connections, 3 for your merchant"
}
```
### 5.4 连接关闭接口
**端点**: `POST /sse/shutdown/`
关闭指定商户的所有 SSE 连接。
**响应**:
```json
{
"status": "ok",
"message": "Your merchant's SSE connections have been closed",
"merchant_id": 1,
"clients": 3
}
```
---
## 6. 连接管理
### 6.1 连接存储结构
按商户组织连接:
```go
// 伪代码
type ConnectionManager struct {
// merchant_id -> []Connection
connections map[int64][]*Connection
mutex sync.RWMutex
}
```
### 6.2 连接生命周期
1. **建立连接**
- 验证 JWT Token
- 提取 merchant_id
- 注册到连接管理器
- 发送 `connected` 消息
2. **维护连接**
- 定期发送心跳30 秒)
- 监听消息队列,转发给客户端
- 处理客户端断开
3. **关闭连接**
- 发送 `server_shutdown` 消息
- 从连接管理器移除
- 释放资源
### 6.3 异常处理
- **客户端断开**:自动检测并清理
- **消息队列满**:丢弃旧消息或断开慢客户端
- **认证失败**:返回 403 Forbidden
- **服务关闭**:优雅地关闭所有连接
---
## 7. 前端客户端参考
### 7.1 使用 Fetch API推荐
```javascript
function createSSEConnection(url, token, onMessage, onError, onClose) {
const controller = new AbortController();
fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
},
signal: controller.signal
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function processBuffer() {
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
try {
onMessage(JSON.parse(data));
} catch (e) {
console.error('Parse error:', e);
}
}
}
}
function read() {
return reader.read().then(({ done, value }) => {
if (done) { onClose?.(); return; }
buffer += decoder.decode(value, { stream: true });
processBuffer();
return read();
});
}
return read();
})
.catch(onError);
return { close: () => controller.abort() };
}
```
### 7.2 事件处理示例
```javascript
const sse = createSSEConnection('/sse/', jwtToken,
(event) => {
switch (event.type) {
case 'connected':
console.log(`连接成功,商户: ${event.merchant_id}`);
break;
case 'stock_change':
// 刷新库存列表
refreshStockList();
break;
case 'order_paid':
// 显示订单支付通知
showNotification(event.message);
break;
case 'server_shutdown':
// 准备重连
scheduleReconnect();
break;
}
},
(error) => {
console.error('SSE Error:', error);
scheduleReconnect();
},
() => console.log('SSE Closed')
);
```
---
## 8. 前端消息处理简化方案
### 8.1 问题分析
如果每种业务事件都需要前端单独处理,会造成:
- 前端需要维护一份事件类型清单
- 新增事件类型需要同时改 Django 和前端
- switch-case 代码越来越长
### 8.2 混合消息模式(推荐)
实际业务中存在两种不同的通知需求,需要用不同的模式处理:
| 模式 | 用途 | 典型场景 |
|------|------|----------|
| **资源失效** | 数据刷新,无特定对象 | 库存变动后刷新列表 |
| **对象事件** | 指向特定对象的通知 | 员工被指派处理特定任务 |
### 8.3 统一消息格式
```typescript
interface SSEMessage {
// 必填:动作类型
action: 'invalidate' | 'notify' | 'connected' | 'server_shutdown';
// 资源失效相关(可选)
resources?: string[]; // 需要刷新的资源列表
// 对象事件相关(可选)
object_type?: string; // 对象类型,如 'printing-job'
object_id?: number | string; // 具体对象ID
// 用户定向(可选)
target_user_ids?: number[]; // 目标用户ID列表为空表示广播给所有人
// 通知展示(可选)
message?: string; // 用户可见的提示消息
toast?: boolean; // 是否显示 Toast 通知
url?: string; // 点击通知后跳转的URL
}
```
### 8.4 消息示例
**模式1纯资源刷新**(不需要知道具体对象)
```json
{
"action": "invalidate",
"resources": ["stock-records", "inventory"],
"message": "库存已更新",
"toast": true
}
```
**模式2对象事件**(需要指向特定对象)
```json
{
"action": "notify",
"object_type": "printing-job",
"object_id": 123,
"target_user_ids": [5],
"message": "您有新的印染任务 #123",
"toast": true,
"url": "/printing-jobs/123"
}
```
**模式3混合**(既刷新数据,又指向特定对象)
```json
{
"action": "notify",
"object_type": "printing-job",
"object_id": 123,
"resources": ["printing-jobs"],
"message": "印染任务 #123 已更新",
"toast": true
}
```
### 8.5 目标用户过滤策略
**问题**:当消息需要通知特定用户时,过滤应该在哪里做?
| 方案 | 描述 | 优缺点 |
|------|------|--------|
| A. Django 多次推送 | Django 为每个目标用户单独调用推送 | 复杂,需要跟踪用户连接状态 |
| B. **前端过滤(推荐)** | 消息带 `target_user_ids`,前端自己过滤 | ✅ 简单,保持透明转发 |
| C. Golang 过滤 | Golang 解析 `target_user_ids` 只推给特定连接 | 打破透明转发原则 |
**选择方案 B 的理由**
1. **保持透明转发** - Golang 无需改动
2. **前端过滤逻辑极简** - 一行代码
3. **带宽浪费可忽略** - 同商户内并发用户数有限
### 8.6 前端通用处理器
```typescript
// hooks/useSSE.ts
import { useQueryClient } from '@tanstack/react-query';
import { message, Modal } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from './useCurrentUser';
interface SSEMessage {
action: 'invalidate' | 'notify' | 'connected' | 'server_shutdown';
resources?: string[];
object_type?: string;
object_id?: number | string;
target_user_ids?: number[];
message?: string;
toast?: boolean;
url?: string;
}
export function useSSEHandler() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { user } = useCurrentUser();
const handleMessage = (event: SSEMessage) => {
// ========== 用户过滤 ==========
// 如果指定了目标用户,且当前用户不在列表中,则忽略
if (event.target_user_ids && event.target_user_ids.length > 0) {
if (!event.target_user_ids.includes(user.id)) {
return; // 不是发给我的,忽略
}
}
// ========== 系统消息 ==========
if (event.action === 'connected') {
console.log('SSE 连接成功');
return;
}
if (event.action === 'server_shutdown') {
scheduleReconnect();
return;
}
// ========== 资源失效处理 ==========
if (event.resources && event.resources.length > 0) {
for (const resource of event.resources) {
queryClient.invalidateQueries({ queryKey: [resource] });
}
}
// ========== 通知展示 ==========
if (event.toast && event.message) {
if (event.url) {
// 可点击的通知
message.info(
<span onClick={() => navigate(event.url!)} style={{ cursor: 'pointer' }}>
{event.message}
</span>
);
} else {
message.info(event.message);
}
}
};
return { handleMessage };
}
```
### 8.7 Django 侧封装
```python
# sse/events.py - 统一的事件发送接口
from sse.client import push_to_sse
def notify_resource_changed(
merchant_id: int,
resources: list[str],
message: str = None,
toast: bool = False
):
"""
通知前端资源已变化,需要刷新(纯资源失效模式)
"""
push_to_sse(merchant_id, {
"action": "invalidate",
"resources": resources,
"message": message,
"toast": toast
})
def notify_object_event(
merchant_id: int,
object_type: str,
object_id: int,
message: str,
target_user_ids: list[int] = None,
resources: list[str] = None,
url: str = None,
toast: bool = True
):
"""
通知特定对象的事件(对象事件模式)
Args:
merchant_id: 商户ID
object_type: 对象类型,如 'printing-job', 'plate-order'
object_id: 对象ID
message: 用户可见的消息
target_user_ids: 目标用户ID列表为空表示通知所有人
resources: 可选,同时触发哪些资源刷新
url: 可选点击通知后跳转的URL
toast: 是否显示 Toast
"""
payload = {
"action": "notify",
"object_type": object_type,
"object_id": object_id,
"message": message,
"toast": toast
}
if target_user_ids:
payload["target_user_ids"] = target_user_ids
if resources:
payload["resources"] = resources
if url:
payload["url"] = url
push_to_sse(merchant_id, payload)
# ========== 业务代码调用示例 ==========
# 示例1库存变动纯资源刷新通知所有人
def on_stock_change(merchant_id, record_id):
notify_resource_changed(
merchant_id=merchant_id,
resources=["stock-records", "inventory"], # 资源为空意味着是一则普通的消息通知
message="库存已更新",
toast=True
)
# 示例2任务指派对象事件只通知被指派的人
def on_job_assigned(merchant_id, job_id, assigned_user_id):
notify_object_event(
merchant_id=merchant_id,
object_type="printing-job",
object_id=job_id,
message=f"您有新的印染任务 #{job_id}",
target_user_ids=[assigned_user_id], # 可能为空意味着需要全员接收该消息
resources=["printing-jobs"], # 同时刷新任务列表
url=f"/printing-jobs/{job_id}"
)
# 示例3订单状态变更通知相关人员
def on_order_status_changed(merchant_id, order_id, related_user_ids):
notify_object_event(
merchant_id=merchant_id,
object_type="printing-order",
object_id=order_id,
message=f"订单 #{order_id} 状态已更新",
target_user_ids=related_user_ids,
resources=["printing-orders"]
)
```
### 8.8 资源命名约定
建议 `resources``object_type` 使用与 API 路径一致的命名:
| 名称 | 对应 API | React Query Key |
|------|----------|-----------------|
| `printing-orders` | `/api/v1/printing-orders/` | `['printing-orders']` |
| `printing-jobs` | `/api/v1/printing-jobs/` | `['printing-jobs']` |
| `plate-orders` | `/api/v1/plate-orders/` | `['plate-orders']` |
| `stock-records` | `/api/v1/stock-change-records/` | `['stock-records']` |
| `inventory` | `/api/v1/inventory/` | `['inventory']` |
### 8.9 方案优势总结
| 维度 | 效果 |
|------|------|
| **新增消息类型** | 只改 DjangoGolang 和前端通用处理器不变 |
| **目标用户过滤** | 前端一行代码,保持透明转发 |
| **对象导航** | 支持 `object_type` + `object_id` + `url` |
| **灵活性** | 纯刷新、对象事件、混合模式都支持 |
---
## 9. 部署架构
### 9.1 推荐架构
```
┌─────────────────┐
│ Nginx/CDN │
└────────┬────────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Django API │ │ Golang SSE │ │ Static Files │
│ (Gunicorn) │ │ Service │ │ (CDN) │
└───────┬───────┘ └───────┬───────┘ └───────────────┘
│ │
│ HTTP/Redis │
└────────┬────────┘
┌────────▼────────┐
│ PostgreSQL/ │
│ Redis │
└─────────────────┘
```
### 9.2 Nginx 配置要点
```nginx
location /sse/ {
proxy_pass http://golang-sse-service:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
# SSE 超时配置
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 禁用缓冲
add_header X-Accel-Buffering no;
}
```
### 9.3 Docker Compose 示例
```yaml
services:
sse:
image: flower-sse:latest
ports:
- "8080:8080"
environment:
- JWT_SECRET=${SECRET_KEY}
- REDIS_URL=redis://redis:6379/0
depends_on:
- redis
```
---
## 10. 安全考虑
1. **JWT 验证**:必须验证 Token 有效性和过期时间
2. **商户隔离**:严格检查 merchant_id防止跨商户数据泄漏
3. **内部 API 保护**:消息推送 API 只允许内网访问
4. **连接限制**:限制单用户/单商户的最大连接数
5. **速率限制**:防止连接风暴
---
## 11. 监控指标
建议监控以下指标:
- 总连接数
- 各商户连接数
- 消息推送延迟
- 消息推送成功率
- 连接存活时长分布
- 心跳超时断开数
---
## 12. 迁移计划
### Phase 1: 开发与测试
1. 开发 Golang SSE 服务
2. 实现 JWT 认证(与 Django 共享密钥)
3. 实现消息推送接口
4. 单元测试和压力测试
### Phase 2: 并行运行
1. 部署 Golang SSE 服务
2. Django 同时向新旧 SSE 推送消息
3. 前端切换到新 SSE 端点
4. 监控对比
### Phase 3: 完全迁移
1. 废弃 Django SSE 模块
2. 移除相关代码
3. 更新文档
---
## 13. 附录:当前代码参考
### 13.1 核心服务函数签名
```python
# sse/services.py
def push_connection(merchant_id, conn_queue):
"""将新连接添加到指定商户的连接集合"""
def remove_connection(merchant_id, conn_queue):
"""从指定商户移除连接"""
def push_sse_event_to_merchant(merchant_id, event_data: dict):
"""向指定商户的所有客户端广播事件"""
def push_simple_message_with_object_id_to_merchant(
merchant_id, event_type: str, message: str, object_id
):
"""向指定商户广播简单业务消息"""
def shutdown_merchant_connections(merchant_id):
"""关闭指定商户的所有连接"""
```
### 13.2 Django 中的调用示例
```python
# stock/services.py
from sse.services import push_simple_message_with_object_id
# 库存变动后推送通知
push_simple_message_with_object_id(
event_type='stock_change',
message=f'产品ID {detail.product_id} 位于 {warehouse_id} 的库存已更新',
object_id=inventory_record.id
)
```
---