# 消息中心对接指南

统一消息中心支持应用向用户发送系统通知，并支持用户通过 WebSocket 实时接收消息。本指南将指导您如何接入消息发送与推送服务。

## 1. 核心概念

- **Message (私信)**: 用户与用户之间的点对点消息。
- **Notification (通知)**: 系统或应用发送给用户的业务提醒，支持 SSO 跳转。
- **WebSocket**: 客户端通过长连接实时接收推送。

## 1.1 接口权限说明

消息中心接口支持两种认证方式：用户认证（JWT Token）和应用认证（签名验证）。不同接口的权限如下：

| 接口 | 用户权限 | 应用权限 | 说明 |
|------|---------|---------|------|
| `POST /messages/` | ✅ 仅 MESSAGE | ✅ MESSAGE + NOTIFICATION + BROADCAST | 用户只能发私信；应用可发通知与**应用内**广播（映射用户） |
| `GET /messages/conversations` | ✅ | ❌ | 仅用户可查询 |
| `GET /messages/history/{id}` | ✅ | ❌ | 仅用户可查询 |
| `GET /messages/unread-count` | ✅ | ❌ | 仅用户可查询 |
| `GET /messages/{id}/callback-url` | ✅ | ❌ | 仅用户可调用，获取通知回调 URL |
| `PUT /messages/{id}/read` | ✅ | ❌ | 仅用户可操作 |
| `PUT /messages/read-all` | ✅ | ❌ | 仅用户可操作，收件箱全部标已读 |
| `PUT /messages/history/{id}/read-all` | ✅ | ❌ | 仅用户可操作，与 history 路径参数一致，按会话标已读 |
| `DELETE /messages/{id}` | ✅ | ❌ | 仅用户可操作 |
| `POST /messages/upload` | ✅ | ✅ | 用户和应用都可上传 |

## 1.2 客户端推荐调用顺序（未读与会话）

与《客户端接口 API 指南》第 2 节一致：

1. **WebSocket** 仅作提醒：收到 `NEW_MESSAGE` 后触发列表刷新。
2. **刷新时并行请求**：`GET /messages/unread-count`（全局未读整数）与 `GET /messages/conversations`（每会话 `unread_count`）。
3. **用户点入会话**：`GET /messages/history/{other_user_id}`，再 `PUT /messages/history/{other_user_id}/read-all`（`other_user_id` 与会话 `user_id` 相同）。
4. **一键全部已读**：`PUT /messages/read-all`。

详细请求参数、响应字段见 `client_api_guide.md` 第 2 节。

## 2. 用户登录认证 (Auth)

在对接消息中心之前，客户端（如 WebSocket）通常需要获取用户的访问令牌 (Token)。

### 2.1 用户登录 (OAuth2 表单)

标准 OAuth2 密码模式登录，适用于 Postman 或支持 OAuth2 的客户端。

- **接口地址**: `POST {{API_BASE_URL}}/auth/login`
- **Content-Type**: `application/x-www-form-urlencoded`

**请求参数 (Form Data):**

| 字段 | 必填 | 说明 |
|------|------|------|
| `username` | 是 | 用户手机号 |
| `password` | 是 | 用户密码 |

**响应示例 (JSON):**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR...",
  "token_type": "bearer"
}
```

### 2.2 用户登录 (JSON)

适用于前端 SPA 或移动端应用调用的 JSON 格式登录接口。

- **接口地址**: `POST {{API_BASE_URL}}/auth/login/json`
- **Content-Type**: `application/json`

**请求参数 (JSON):**

```json
{
  "mobile": "13800138000",
  "password": "your_password",
  "remember_me": false
}
```

**响应示例 (JSON):**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR...",
  "token_type": "bearer"
}
```

## 3. 消息发送接口 (HTTP)

应用端通过 HTTP 接口向指定用户发送消息。

- **接口地址**: `POST {{API_BASE_URL}}/messages/`
- **认证方式**:
  - **应用调用 (Server-to-Server)**: 使用应用签名头信息。
  - **用户调用 (Client-to-Server)**: 使用 Bearer Token。

### 3.1 应用调用示例 (签名认证)

适用于业务系统后端向用户推送通知。签名生成规则请参考 API 安全规范 (简单来说：`sign = HMAC-SHA256(secret, app_id=your_app_id_string&timestamp=1700000000)`)。

**完整 HTTP 请求示例:**

```
POST {{API_BASE_URL}}/messages/ HTTP/1.1
Host: api.yourdomain.com
Content-Type: application/json
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)

{
  "app_id": "your_app_id_string",
  "app_user_id": "zhangsan_oa",
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "OA审批提醒",
  "content": "您有一条新的报销单待审批",
  "auto_sso": true,
  "target_url": "http://oa.com/audit/123",
  "action_text": "立即处理"
}
```

### 3.2 用户调用示例 (Token 认证)

适用于用户在前端直接发送私信（如用户 A 发送给用户 B）。

**完整 HTTP 请求示例:**

```
POST {{API_BASE_URL}}/messages/ HTTP/1.1
Host: api.yourdomain.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...

{
  "receiver_id": 2048,             // 接收用户 ID
  "type": "MESSAGE",               // 私信
  "content_type": "TEXT",
  "title": "私信",
  "content": "你好，请问这个流程怎么走？"
}
```

### 3.3 广播消息接口 (Broadcast)

广播消息接口允许应用向「当前签名身份对应应用」下已有用户映射的活跃用户发送系统通知。适用于面向本应用用户的公告、运维通知等场景。

**重要说明：**
- **仅支持系统通知**：广播模式仅支持 `type: "NOTIFICATION"`，不支持私信类型
- **仅应用可调用**：广播功能仅限应用通过签名认证调用，普通用户无权使用
- **发送范围（该应用内）**：系统在 `X-App-Id` 对应应用下，查找 **存在有效用户映射**（`is_active=true`）、且平台用户状态为 `ACTIVE`、`is_deleted=0` 的用户，为其中每个用户创建一条消息记录（全平台未绑定该应用的用户不会收到）
- **实时推送**：上述用户中当前在线者会通过 WebSocket 实时收到推送

**接口地址**: `POST {{API_BASE_URL}}/messages/`

**认证方式**: 应用签名认证（必须使用应用签名头信息）

**请求参数:**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `is_broadcast` | boolean | 是 | 设置为 `true` 启用广播模式 |
| `type` | string | 是 | 必须为 `"NOTIFICATION"` |
| `title` | string | 是 | 消息标题 |
| `content` | string | 是 | 消息内容 |
| `content_type` | string | 否 | 内容类型，默认为 `"TEXT"` |
| `auto_sso` | boolean | 否 | 是否启用 SSO 自动跳转，默认为 `false` |
| `target_url` | string | 否 | 目标业务页面 URL（当 `auto_sso=true` 时使用） |
| `action_url` | string | 否 | 自定义跳转链接（不使用 SSO 时使用） |
| `action_text` | string | 否 | 操作按钮文案 |

**注意：** 广播模式下，**不需要**提供 `receiver_id` 或 `app_user_id`，系统会自动向「本应用下已有有效映射」的活跃用户发送。

**完整 HTTP 请求示例 (应用签名认证):**

```
POST {{API_BASE_URL}}/messages/ HTTP/1.1
Host: api.yourdomain.com
Content-Type: application/json
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)

{
  "is_broadcast": true,
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "系统维护通知",
  "content": "系统将于今晚 22:00-24:00 进行维护，期间可能无法访问，请提前做好准备。",
  "auto_sso": false,
  "action_text": "查看详情"
}
```

**带 SSO 跳转的广播示例:**

```
POST {{API_BASE_URL}}/messages/ HTTP/1.1
Host: api.yourdomain.com
Content-Type: application/json
X-App-Id: your_app_id_string
X-Timestamp: 1708848000
X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)

{
  "is_broadcast": true,
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "重要公告",
  "content": "请查看最新的系统更新说明",
  "auto_sso": true,
  "target_url": "http://your-app.com/announcements/2024-02",
  "action_text": "立即查看"
}
```

**响应示例:**

接口返回的是第一条创建的消息记录（用于签名验证）；符合上述范围的用户各有一条独立消息记录：

```json
{
  "id": 1001,
  "sender_id": null,
  "receiver_id": 1,
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "系统维护通知",
  "content": "系统将于今晚 22:00-24:00 进行维护...",
  "action_url": null,
  "action_text": "查看详情",
  "is_read": false,
  "created_at": "2026-02-25T10:00:00",
  "app_id": "your_app_id_string",
  "app_name": "您的应用名称"
}
```

**Python 调用示例:**

```python
import requests
import time
import hmac
import hashlib

# 配置
API_URL = "{{API_BASE_URL}}/messages/"
APP_ID = "your_app_id_string"
APP_SECRET = "your_app_secret"

def generate_signature(app_id, app_secret):
    """生成签名"""
    timestamp = str(int(time.time()))
    params = {"app_id": app_id, "timestamp": timestamp}
    query_string = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
    sign = hmac.new(app_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256).hexdigest()
    return timestamp, sign

timestamp, sign = generate_signature(APP_ID, APP_SECRET)

headers = {
    "X-App-Id": APP_ID,
    "X-Timestamp": timestamp,
    "X-Sign": sign,
    "Content-Type": "application/json"
}

# 广播消息
payload = {
    "is_broadcast": True,  # 启用广播模式
    "type": "NOTIFICATION",  # 必须是 NOTIFICATION
    "content_type": "TEXT",
    "title": "系统维护通知",
    "content": "系统将于今晚 22:00-24:00 进行维护，期间可能无法访问。",
    "auto_sso": False,
    "action_text": "查看详情"
}

response = requests.post(API_URL, json=payload, headers=headers)
print(f"Status: {response.status_code}")
print(f"Response: {response.json()}")
```

**注意事项：**

1. **权限限制**：只有通过应用签名认证的请求才能使用广播功能，普通用户 Token 认证无法使用
2. **消息类型限制**：广播仅支持 `NOTIFICATION` 类型，不支持 `MESSAGE` 类型
3. **用户范围**：广播仅面向「当前应用下存在有效映射」且平台账号为 `ACTIVE`、`is_deleted=0` 的用户；若该条件下无用户则返回 `400`
4. **性能考虑**：如果映射用户数量很大，广播操作可能需要一些时间，建议在后台异步处理
5. **实时推送**：在线的接收者会通过 WebSocket 实时收到推送，离线用户可以在下次登录时查看
6. **消息记录**：每个接收用户都会收到一条独立的消息记录，可以单独标记已读或删除

### 3.4 如何获取接收者ID (receiver_id) 和应用ID (app_id)

在实际开发中，通常不会直接记住用户ID和应用ID，而是通过查询接口先找到对应对象，再取出其 ID。

#### 3.3.1 通过用户查询接口获取 receiver_id

可通过用户搜索接口按手机号 / 姓名 / 英文名查询用户，然后从结果中读取 `id` 作为 `receiver_id`：

- **接口地址**: `GET {{API_BASE_URL}}/users/search?q=关键词`
- **说明**: 仅返回未删除、状态为 `ACTIVE` 的用户，并自动排除当前操作者本人。

**示例：**

```http
// 1. 先搜索用户 (按手机号 / 姓名 / 英文名)
GET {{API_BASE_URL}}/users/search?q=13800138000

// 2. 响应示例 (节选)
[
  {
    "id": 2048,
    "mobile": "13800138000",
    "name": "张三",
    "english_name": "zhangsan"
  }
]

// 3. 发送消息时使用 id 作为 receiver_id
POST {{API_BASE_URL}}/messages/
Content-Type: application/json
Authorization: Bearer xxx

{
  "receiver_id": 2048,
  "type": "MESSAGE",
  "content_type": "TEXT",
  "title": "私信",
  "content": "你好"
}
```

#### 3.3.2 分页获取联系人列表

如果需要分页获取联系人列表（如消息中心选择联系人），可以使用以下接口：

**接口1：用户搜索接口（推荐用于普通用户）**

- **接口地址**: `GET {{API_BASE_URL}}/users/search?q=关键词&limit=数量`
- **权限**: 所有登录用户可用
- **特点**:
  - 支持关键词搜索（手机号、姓名、英文名）
  - 有 `limit` 参数（默认20，可调整）
  - **不支持 `skip` 参数**，无法跳过前面的记录
  - 只返回活跃用户（status == "ACTIVE"）
  - 自动排除当前用户自己

**请求示例：**

```http
// 搜索用户（不支持真正的分页）
GET {{API_BASE_URL}}/users/search?q=张三&limit=50
Authorization: Bearer xxx

// 响应示例
[
  {
    "id": 2048,
    "mobile": "13800138000",
    "name": "张三",
    "english_name": "zhangsan"
  },
  {
    "id": 2049,
    "mobile": "13900139000",
    "name": "张三丰",
    "english_name": "zhangsanfeng"
  }
]
```

**接口2：用户列表接口（完整分页，需管理员权限）**

- **接口地址**: `GET {{API_BASE_URL}}/users/?skip=偏移量&limit=数量&keyword=关键词&status=状态`
- **权限**: **仅超级管理员可用**（普通用户会返回 403）
- **特点**:
  - 支持完整分页（`skip` 和 `limit`）
  - 支持多种筛选条件（status, role, mobile, name, english_name, keyword）
  - 返回格式：`{"total": 总数, "items": [用户列表]}`

**请求示例：**

```http
// 分页获取用户列表（需要超级管理员权限）
GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三&status=ACTIVE
Authorization: Bearer xxx

// 响应示例
{
  "total": 100,
  "items": [
    {
      "id": 2048,
      "mobile": "13800138000",
      "name": "张三",
      "english_name": "zhangsan",
      "status": "ACTIVE"
    }
  ]
}
```

**使用建议：**

- **普通用户场景**：使用 `GET /users/search` 接口，通过 `limit` 参数控制返回数量（建议设置为 50-100）
- **管理员场景**：使用 `GET /users/` 接口，支持完整的分页功能

**JavaScript 示例：**

```javascript
// 方案1：普通用户 - 使用搜索接口（限制数量）
const fetchContacts = async (keyword = '', limit = 50) => {
  const res = await api.get('/users/search', {
    params: { q: keyword, limit }
  })
  return res.data
}

// 方案2：超级管理员 - 使用完整分页接口
const fetchContactsPaginated = async (page = 1, pageSize = 20, keyword = '') => {
  const res = await api.get('/users/', {
    params: {
      skip: (page - 1) * pageSize,
      limit: pageSize,
      keyword,
      status: 'ACTIVE'  // 只获取活跃用户
    }
  })
  return {
    users: res.data.items,
    total: res.data.total
  }
}
```

#### 3.3.3 通过应用列表获取 app_id

如果是“用户调用 + 使用 `app_user_id`”的方式发送消息，需要在 Body 中同时提供 `app_id`，可以通过应用列表接口查询：

- **接口地址**: `GET {{API_BASE_URL}}/apps/?search=关键字`
- **说明**: 支持按应用名称 / `app_id` 模糊搜索，返回结构中既包含内部自增主键 `id`（整数），也包含对外使用的 `app_id` 字段（字符串类型，消息接口使用此字段）。

**示例：**

```http
// 查询包含“OA”的应用
GET {{API_BASE_URL}}/apps/?search=OA

// 响应示例 (节选)
{
  "total": 1,
  "items": [
    {
      "id": 101,              // 数据库主键 (内部使用)
      "app_id": "oa_system",  // 字符串类型的应用ID (消息接口使用此字段)
      "app_name": "OA系统"
    }
  ]
}

// 用户以 app_user_id 方式发送消息时示例
POST {{API_BASE_URL}}/messages/
Content-Type: application/json
Authorization: Bearer xxx

{
  "app_id": "oa_system",       // 使用 items[0].app_id (字符串类型)
  "app_user_id": "zhangsan_oa",
  "type": "NOTIFICATION",
  "content_type": "TEXT",
  "title": "OA审批提醒",
  "content": "您有一条新的报销单待审批"
}
```

#### 3.3.4 应用自调用时的 app_id 行为说明

- **应用通过签名调用接口时**：系统会自动根据 `X-App-Id` 解析出当前应用，并将 `message_in.app_id` 强制设置为该应用的字符串类型 app_id，Body 中传入的 `app_id` 会被忽略。
- **用户调用并使用 `app_user_id` 时**：`app_id` 必须在 Body 中显式给出（字符串类型），用于从 `app_user_mapping` 表中解析真实用户。

## 4. 消息查询接口

用户端通过以下接口查询和管理消息。

### 4.1 获取会话列表

获取当前用户的所有会话（类似微信首页的会话列表）。

- **接口地址**: `GET {{API_BASE_URL}}/messages/conversations`
- **认证方式**: `Authorization: Bearer <JWT_TOKEN>`
- **权限**: 仅用户可调用

**响应示例:**

```json
[
  {
    "user_id": 0,
    "username": "System",
    "full_name": "系统通知",
    "unread_count": 5,
    "last_message": "您的密码已重置",
    "last_message_type": "TEXT",
    "updated_at": "2026-02-23T10:05:00",
    "is_system": true,
    "app_id": null,
    "app_name": null,
    "remarks": null
  },
  {
    "user_id": 102,
    "username": "13800138000",
    "full_name": "李四",
    "unread_count": 0,
    "last_message": "[IMAGE]",
    "last_message_type": "IMAGE",
    "updated_at": "2026-02-22T18:30:00",
    "is_system": false,
    "app_id": null,
    "app_name": null,
    "remarks": "某某科技有限公司"
  }
]
```

**说明:**

- `user_id: 0` 表示兼容的「全部系统通知」会话（无 `app_id` 的旧数据）
- `unread_count` 表示该会话的未读消息数
- `last_message` 显示最后一条消息内容（多媒体类型显示为 `[TYPE]`）
- `remarks`：有 `app_id` 的应用通知为「应用通知」；无 `app_id` 的旧系统通知为 `null`；私信为对端组织名，无组织为 `null`

### 4.2 获取聊天历史记录

获取与特定用户的聊天记录（支持分页）。

- **接口地址**: `GET {{API_BASE_URL}}/messages/history/{other_user_id}`
- **路径参数**: `other_user_id` - 对方用户ID（0 表示系统通知）
- **查询参数**:
  - `skip`: 分页偏移（默认 0）
  - `limit`: 每页条数（默认 50）

**请求示例:**

```
GET {{API_BASE_URL}}/messages/history/123?skip=0&limit=50
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

**响应示例:**

```json
[
  {
    "id": 501,
    "sender_id": 123,
    "receiver_id": 456,
    "type": "MESSAGE",
    "content_type": "TEXT",
    "title": "私信",
    "content": "你好，这是一条消息",
    "is_read": true,
    "created_at": "2026-02-23T10:00:00"
  }
]
```

### 4.3 获取未读消息数

获取当前用户的总未读消息数。

- **接口地址**: `GET {{API_BASE_URL}}/messages/unread-count`
- **响应**: 返回数字，表示未读消息总数

**响应示例:**

```
5
```

### 4.4 获取消息列表

获取当前用户的所有消息列表（支持分页和筛选）。

- **接口地址**: `GET {{API_BASE_URL}}/messages/`
- **查询参数**:
  - `skip`: 分页偏移（默认 0）
  - `limit`: 每页条数（默认 100）
  - `unread_only`: 是否只获取未读消息（默认 false）

**请求示例:**

```
GET {{API_BASE_URL}}/messages/?skip=0&limit=100&unread_only=false
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

## 5. 消息状态管理接口

用于标记消息已读、删除消息等操作。

### 5.1 标记单条消息已读

- **接口地址**: `PUT {{API_BASE_URL}}/messages/{message_id}/read`
- **路径参数**: `message_id` - 消息ID
- **权限**: 只能标记自己接收的消息为已读

**请求示例:**

```
PUT {{API_BASE_URL}}/messages/501/read
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

**批量标记已读示例 (JavaScript):**

```javascript
// 获取未读消息ID列表
const unreadIds = messages
  .filter(m => !m.is_read && m.receiver_id === currentUserId)
  .map(m => m.id)

// 批量标记为已读
await Promise.all(
  unreadIds.map(id => api.put(`/messages/${id}/read`))
)
```

### 5.2 标记全部消息已读

- **接口地址**: `PUT {{API_BASE_URL}}/messages/read-all`
- **响应**: 返回更新的消息数量
- **说明**: 将当前用户收件箱内**全部**未读消息标为已读（不区分会话）

**请求示例:**

```
PUT {{API_BASE_URL}}/messages/read-all
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

**响应:**

```json
{
  "updated_count": 10
}
```

### 5.2.1 按会话标记全部已读

- **接口地址**: `PUT {{API_BASE_URL}}/messages/history/{other_user_id}/read-all`
- **路径参数**: `other_user_id` 与 `GET /messages/history/{other_user_id}`、会话列表中的 `user_id` 一致（`>0` 私信对方；`0` 全部系统通知视图；`<0` 某应用通知，值为 `-applications.id`）
- **响应**: `{ "updated_count": number }`
- **说明**: 仅将该会话范围内、当前用户作为接收方且未读的消息标为已读

**请求示例:**

```
PUT {{API_BASE_URL}}/messages/history/2048/read-all
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...

PUT {{API_BASE_URL}}/messages/history/-101/read-all
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

### 5.3 删除消息

- **接口地址**: `DELETE {{API_BASE_URL}}/messages/{message_id}`
- **路径参数**: `message_id` - 消息ID
- **权限**: 只能删除自己接收的消息

**请求示例:**

```
DELETE {{API_BASE_URL}}/messages/501
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

### 5.4 获取消息回调 URL

用于获取通知消息的 SSO 跳转回调 URL。当用户点击通知消息的操作按钮时，前端应调用此接口获取实时的 callback URL（包含 ticket），然后打开该 URL。

**重要说明：**
- 此接口会实时生成 ticket，确保 ticket 不会过期
- 只有消息的接收者可以调用此接口
- 仅适用于带有 `action_url` 的通知消息

- **接口地址**: `GET {{API_BASE_URL}}/messages/{message_id}/callback-url`
- **路径参数**: `message_id` - 消息ID
- **认证方式**: `Authorization: Bearer <JWT_TOKEN>`
- **权限**: 仅用户可调用，且只能获取自己接收的消息

**请求示例:**

```
GET {{API_BASE_URL}}/messages/501/callback-url
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
```

**响应示例:**

```json
{
  "callback_url": "http://your-app.com/callback?ticket=abc123&next=http://your-business.com/detail/123"
}
```

**前端调用示例 (JavaScript):**

```javascript
// 处理通知操作按钮点击
const handleNotificationAction = async (message) => {
  if (message.action_url) {
    try {
      // 调用接口获取 callback URL（实时生成 ticket）
      const res = await api.get(`/messages/${message.id}/callback-url`)
      
      if (res.data && res.data.callback_url) {
        // 打开回调 URL
        window.open(res.data.callback_url, '_blank')
      } else {
        console.error('获取跳转链接失败')
      }
    } catch (error) {
      console.error('获取跳转链接失败:', error)
    }
  }
}
```

**工作流程：**

1. **创建消息时**：消息的 `action_url` 存储的是 jump 接口 URL（格式：`/api/v1/simple/sso/jump?app_id=xxx&redirect_to=xxx`），不包含 ticket
2. **用户点击按钮时**：
   - 前端调用 `GET /messages/{id}/callback-url` 接口
   - 后端实时生成 ticket，并构造最终的 callback URL
   - 返回 JSON 格式的 `callback_url`
   - 前端打开返回的 callback URL

**错误响应：**

- `404`: 消息未找到
- `403`: 无权访问此消息（不是消息接收者）
- `400`: 消息没有配置跳转链接或跳转链接格式无效

**注意事项：**

- Ticket 有效期为 60 秒，因此必须在用户点击时实时生成
- 如果消息的 `action_url` 不是 jump 接口格式，将返回 400 错误
- 此接口会验证用户身份，确保只有消息接收者可以获取 callback URL

## 6. 文件上传接口

用于上传图片、视频、文档等附件，上传成功后可用于发送多媒体消息。

- **接口地址**: `POST {{API_BASE_URL}}/messages/upload`
- **Content-Type**: `multipart/form-data`
- **认证方式**: `Authorization: Bearer <JWT_TOKEN>`
- **权限**: 用户和应用都可调用

**请求参数:**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `file` | File | 是 | 上传的文件（支持图片、视频、文档等） |

**文件限制:**

- 最大文件大小: 50MB
- 支持的文件类型: JPEG, PNG, GIF, WebP, MP4, PDF, DOC, DOCX, XLS, XLSX, TXT 等

**请求示例 (JavaScript):**

```javascript
const formData = new FormData()
formData.append('file', file)

const uploadRes = await api.post('/messages/upload', formData, {
  headers: { 'Content-Type': 'multipart/form-data' }
})

// 响应示例
{
  "url": "https://minio.example.com/messages/1/2026/02/uuid.jpg",
  "key": "messages/1/2026/02/uuid.jpg",
  "filename": "image.jpg",
  "content_type": "image/jpeg",
  "size": 50200
}
```

**上传后发送消息示例:**

```javascript
// 1. 先上传文件
const uploadRes = await api.post('/messages/upload', formData, {
  headers: { 'Content-Type': 'multipart/form-data' }
})

// 2. 使用返回的 key 发送消息
const payload = {
  receiver_id: 123,
  content: uploadRes.data.key,
  type: 'MESSAGE',
  content_type: 'IMAGE',
  title: '图片'
}

await api.post('/messages/', payload)
```

## 7. WebSocket 实时接入

前端客户端通过 WebSocket 连接接收实时推送。

- **连接地址**: `ws://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN`
- **HTTPS 环境**: `wss://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN`
- **心跳机制**: 客户端每 30 秒发送 `ping`，服务端回复 `pong`
- **断线重连**: 建议客户端实现自动重连机制

**连接示例 (JavaScript):**

```javascript
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${localStorage.getItem('token')}`

const ws = new WebSocket(wsUrl)

// 连接成功
ws.onopen = () => {
  console.log('WebSocket 连接成功')
  
  // 启动心跳（每30秒发送一次）
  setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send('ping')
    }
  }, 30000)
}

// 接收消息
ws.onmessage = (event) => {
  // 心跳响应
  if (event.data === 'pong') {
    return
  }
  
  try {
    const msg = JSON.parse(event.data)
    if (msg.type === 'NEW_MESSAGE') {
      const newMessage = msg.data
      // 处理新消息
      console.log('收到新消息:', newMessage)
    }
  } catch (e) {
    console.error('解析消息失败:', e)
  }
}

// 连接关闭
ws.onclose = () => {
  console.log('WebSocket 连接关闭')
  // 实现重连逻辑
  setTimeout(() => {
    // 重新连接
  }, 3000)
}

// 连接错误
ws.onerror = (error) => {
  console.error('WebSocket 错误:', error)
}
```

**推送消息格式 (Server -> Client):**

```json
{
  "type": "NEW_MESSAGE",
  "data": {
    "id": 1024,
    "sender_id": 102,
    "type": "NOTIFICATION",
    "content_type": "TEXT",
    "title": "OA审批提醒",
    "content": "您有一条新的报销单待审批",
    "action_url": "http://api.com/sso/jump?app_id=your_app_id_string&redirect_to=...",
    "action_text": "立即处理",
    "created_at": "2026-02-25T10:00:00"
  }
}
```

**消息字段说明:**

| 字段 | 说明 |
|------|------|
| `id` | 消息ID |
| `sender_id` | 发送者ID（null 表示系统通知） |
| `type` | 消息类型：MESSAGE（私信）或 NOTIFICATION（通知） |
| `content_type` | 内容类型：TEXT, IMAGE, VIDEO, FILE |
| `title` | 消息标题 |
| `content` | 消息内容（多媒体类型为预签名URL） |
| `action_url` | 跳转链接（通知类型通常包含SSO跳转） |
| `action_text` | 跳转按钮文案 |
| `receiver_id` | 接收者ID（前端用于判断消息归属） |

### 7.1 WebSocket 连接建立与用户识别机制

#### 7.1.1 连接建立流程

**服务端识别用户的过程：**

1. **客户端连接时携带 JWT Token**
   ```
   ws://host/api/v1/ws/messages?token=JWT_TOKEN
   ```

2. **服务端验证 Token 并解析用户ID**
   - 服务端从 Token 中解析出 `user_id`（当前登录用户的ID）
   - 验证用户是否存在且有效

3. **将 WebSocket 连接与用户ID关联存储**
   - 服务端使用 `ConnectionManager` 管理连接
   - 存储格式：`{user_id: [WebSocket1, WebSocket2, ...]}`
   - 一个用户可以有多个设备同时在线（手机、电脑、平板等）

**服务端代码逻辑：**

```python
# 1. 验证 Token 并获取用户
user = await get_user_from_token(token, db)  # 从 Token 解析 user_id

# 2. 将连接与用户ID关联
await manager.connect(websocket, user.id)  # user.id 就是当前用户的ID

# 3. ConnectionManager 内部存储
# active_connections[user.id] = [websocket连接]
```

#### 7.1.2 消息推送机制

**服务端如何知道推送给哪个用户：**

1. **消息创建时确定接收者**
   - 消息保存到数据库时，`receiver_id` 字段记录了接收者的用户ID

2. **根据 receiver_id 查找连接**
   - 服务端使用 `receiver_id` 作为 key，从 `active_connections` 中查找该用户的所有在线连接

3. **向所有在线设备推送**
   - 如果用户有多个设备在线，所有设备都会收到消息

**服务端推送代码逻辑：**

```python
# 消息创建后，后台任务推送
background_tasks.add_task(
    manager.send_personal_message, 
    push_payload,           # 消息内容
    final_receiver_id       # 接收者的用户ID（从消息的 receiver_id 字段获取）
)

# ConnectionManager 根据 receiver_id 查找连接
async def send_personal_message(self, message: dict, user_id: int):
    """
    向特定用户的所有在线设备推送消息
    user_id 就是消息的 receiver_id
    """
    if user_id in self.active_connections:
        # 找到该用户的所有在线连接（可能多个设备）
        connections = self.active_connections[user_id][:]
        for connection in connections:
            await connection.send_json(message)  # 推送到每个设备
```

**关键点：**
- **连接时**：通过 Token 解析出当前用户的 `user_id`，将连接存储到 `active_connections[user_id]`
- **推送时**：使用消息的 `receiver_id` 作为 key，从 `active_connections[receiver_id]` 中查找连接并推送
- **多设备支持**：一个用户多个设备在线时，所有设备都会收到消息

### 7.2 前端接收消息与更新聊天窗口

#### 7.2.1 消息接收处理

**前端如何判断消息是否属于当前用户：**

前端通过 WebSocket 接收到的消息中，服务端已经根据 `receiver_id` 进行了路由，所以**收到的消息都是发给当前用户的**。前端需要判断的是：

1. **消息是否属于当前打开的聊天窗口**
2. **消息是别人发给我的，还是我自己从其他设备发送的**

**消息处理逻辑：**

```javascript
ws.onmessage = (event) => {
  if (event.data === 'pong') return  // 心跳响应，忽略
  
  try {
    const msg = JSON.parse(event.data)
    if (msg.type === 'NEW_MESSAGE') {
      const newMessage = msg.data
      handleNewMessage(newMessage)
    }
  } catch (e) {
    console.error('解析消息失败:', e)
  }
}

const handleNewMessage = (newMessage) => {
  const currentUserId = currentUserId.value  // 当前登录用户ID
  const currentChatId = currentChatId.value  // 当前打开的聊天窗口的用户ID
  
  // 情况1：收到的是当前聊天窗口的消息
  // - 对方发给我：sender_id !== currentUserId && 当前窗口是 sender_id
  // - 我发给对方（多设备同步）：sender_id === currentUserId && 当前窗口是 receiver_id
  if (
    (newMessage.sender_id !== currentUserId && currentChatId === newMessage.sender_id) ||
    (newMessage.sender_id === currentUserId && currentChatId === newMessage.receiver_id)
  ) {
    // 直接添加到当前聊天窗口的消息列表
    messages.value.push(newMessage)
    scrollToBottom()  // 滚动到底部显示新消息
  }
  
  // 情况2：收到的是其他会话的消息
  // 更新会话列表的预览和未读数
  updateConversationPreview(
    newMessage.sender_id === currentUserId 
      ? newMessage.receiver_id   // 我发送的，更新接收者会话
      : newMessage.sender_id,     // 我接收的，更新发送者会话
    newMessage.content,
    newMessage.content_type
  )
  
  // 情况3：如果消息不是当前聊天窗口的，且是别人发给我的
  if (newMessage.sender_id !== currentUserId && currentChatId !== newMessage.sender_id) {
    // 增加未读数
    const conv = conversations.value.find(c => c.user_id === newMessage.sender_id)
    if (conv) {
      conv.unread_count = (conv.unread_count || 0) + 1
    }
  }
}
```

#### 7.2.2 更新会话列表

**更新会话列表的预览信息：**

```javascript
const updateConversationPreview = (userId, content, type) => {
  // 找到或创建会话
  let conv = conversations.value.find(c => c.user_id === userId)
  
  if (conv) {
    // 更新最后一条消息
    conv.last_message = type === 'TEXT' ? content : `[${type}]`
    conv.last_message_type = type
    conv.updated_at = new Date().toISOString()
    
    // 将会话移到最前面（最新消息在顶部）
    conversations.value = [
      conv, 
      ...conversations.value.filter(c => c.user_id !== userId)
    ]
  } else {
    // 新会话，重新获取会话列表
    fetchConversations()
  }
}
```

#### 7.2.3 完整的前端消息处理示例

**完整的消息中心实现：**

```javascript
// 初始化 WebSocket
const initWebSocket = () => {
  if (!currentUser.value) return
  
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${localStorage.getItem('token')}`
  
  const ws = new WebSocket(wsUrl)
  
  // 连接成功
  ws.onopen = () => {
    console.log('WebSocket 连接成功')
    
    // 启动心跳（每30秒发送一次）
    setInterval(() => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send('ping')
      }
    }, 30000)
  }
  
  // 接收消息
  ws.onmessage = (event) => {
    if (event.data === 'pong') return
    
    try {
      const msg = JSON.parse(event.data)
      if (msg.type === 'NEW_MESSAGE') {
        handleNewMessage(msg.data)
      }
    } catch (e) {
      console.error('解析消息失败:', e)
    }
  }
  
  // 连接关闭
  ws.onclose = () => {
    console.log('WebSocket 连接关闭')
    // 实现重连逻辑
    setTimeout(() => {
      initWebSocket()  // 重新连接
    }, 3000)
  }
  
  // 连接错误
  ws.onerror = (error) => {
    console.error('WebSocket 错误:', error)
  }
}

// 处理新消息
const handleNewMessage = (newMessage) => {
  const currentUserId = currentUserId.value
  const currentChatId = currentChatId.value
  
  // 1. 判断是否属于当前聊天窗口
  const isCurrentChat = 
    (newMessage.sender_id !== currentUserId && currentChatId === newMessage.sender_id) ||
    (newMessage.sender_id === currentUserId && currentChatId === newMessage.receiver_id)
  
  if (isCurrentChat) {
    // 添加到当前窗口的消息列表
    messages.value.push(newMessage)
    scrollToBottom()
    
    // 如果是别人发给我的，标记为已读
    if (newMessage.sender_id !== currentUserId) {
      api.put(`/messages/${newMessage.id}/read`)
    }
  }
  
  // 2. 更新会话列表
  const otherUserId = newMessage.sender_id === currentUserId 
    ? newMessage.receiver_id 
    : newMessage.sender_id
  
  updateConversationPreview(
    otherUserId,
    newMessage.content,
    newMessage.content_type
  )
  
  // 3. 更新未读数（如果不是当前窗口且是别人发给我的）
  if (newMessage.sender_id !== currentUserId && !isCurrentChat) {
    const conv = conversations.value.find(c => c.user_id === newMessage.sender_id)
    if (conv) {
      conv.unread_count = (conv.unread_count || 0) + 1
    }
  }
}

// 更新会话预览
const updateConversationPreview = (userId, content, type) => {
  const conv = conversations.value.find(c => c.user_id === userId)
  
  if (conv) {
    conv.last_message = type === 'TEXT' ? content : `[${type}]`
    conv.last_message_type = type
    conv.updated_at = new Date().toISOString()
    
    // 移到最前面
    conversations.value = [
      conv,
      ...conversations.value.filter(c => c.user_id !== userId)
    ]
  } else {
    // 新会话，重新获取
    fetchConversations()
  }
}

// 滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (scrollContainer.value) {
      scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
    }
  })
}
```

### 7.3 完整流程图

```
┌─────────────────────────────────────────────────────────────┐
│ 1. 用户A登录 → 获取 JWT Token                               │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 2. 建立 WebSocket 连接                                       │
│    ws://host/ws/messages?token=TOKEN                        │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 3. 服务端验证 Token → 解析出 user_id = A                     │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 4. 存储连接                                                  │
│    active_connections[A] = [WebSocket连接]                   │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 5. 用户B发送消息给用户A                                      │
│    POST /messages/ { receiver_id: A, ... }                   │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 6. 消息保存到数据库                                          │
│    receiver_id = A                                           │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 7. 后台任务推送                                              │
│    manager.send_personal_message(payload, receiver_id=A)     │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 8. 查找连接                                                  │
│    active_connections[A] → 找到用户A的所有连接               │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 9. 推送消息                                                  │
│    用户A的所有设备都收到消息                                 │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 10. 前端接收消息                                             │
│     ws.onmessage → handleNewMessage()                        │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 11. 判断消息类型和当前窗口                                   │
│     - 是否属于当前聊天窗口？                                 │
│     - 是别人发给我的，还是我自己发的？                       │
└─────────────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────────────┐
│ 12. 更新UI                                                   │
│     - 更新消息列表（如果当前窗口）                           │
│     - 更新会话列表（最后一条消息、未读数）                   │
│     - 滚动到底部（如果当前窗口）                             │
└─────────────────────────────────────────────────────────────┘
```

### 7.4 关键设计点总结

1. **用户识别**：连接时通过 JWT Token 解析 `user_id`，推送时使用 `receiver_id` 查找连接
2. **多设备支持**：一个 `user_id` 可以对应多个 WebSocket 连接，所有设备都会收到消息
3. **消息路由**：服务端根据 `receiver_id` 自动路由到正确的用户，前端只需判断是否属于当前窗口
4. **实时更新**：收到消息后自动更新消息列表、会话列表、未读数，无需手动刷新

## 8. 前端完整调用示例

以下示例展示前端如何完整地使用消息中心功能。

### 8.1 初始化消息中心

```javascript
// 1. 页面加载时获取会话列表
onMounted(() => {
  fetchConversations()
})

// 2. 获取会话列表
const fetchConversations = async () => {
  try {
    const res = await api.get('/messages/conversations')
    conversations.value = res.data
    initWebSocket()  // 初始化 WebSocket
  } catch (e) {
    console.error('获取会话列表失败:', e)
  }
}
```

### 8.2 选择会话并加载历史消息

```javascript
const selectChat = async (chat) => {
  currentChatId.value = chat.user_id
  await loadHistory(chat.user_id)
}

const loadHistory = async (userId) => {
  try {
    const res = await api.get(`/messages/history/${userId}`, {
      params: { skip: 0, limit: 50 }
    })
    messages.value = res.data.reverse()  // API返回最新在前，需要反转显示
    
    // 标记未读消息为已读
    const unreadIds = messages.value
      .filter(m => !m.is_read && m.receiver_id === currentUserId.value)
      .map(m => m.id)
    
    if (unreadIds.length > 0) {
      await Promise.all(unreadIds.map(id => api.put(`/messages/${id}/read`)))
    }
  } catch (e) {
    console.error('加载历史消息失败:', e)
  }
}
```

### 8.3 发送文本消息

```javascript
const sendMessage = async () => {
  if (!inputMessage.value.trim() || !currentChatId.value) return
  
  const payload = {
    receiver_id: currentChatId.value,
    content: inputMessage.value,
    type: 'MESSAGE',
    content_type: 'TEXT',
    title: '私信'
  }
  
  try {
    const res = await api.post('/messages/', payload)
    messages.value.push(res.data)
    inputMessage.value = ''
  } catch (e) {
    ElMessage.error('发送失败')
  }
}
```

### 8.4 上传文件并发送

```javascript
const handleUpload = async (options) => {
  const formData = new FormData()
  formData.append('file', options.file)
  
  try {
    // 1. 先上传文件
    const uploadRes = await api.post('/messages/upload', formData, {
      headers: { 'Content-Type': 'multipart/form-data' }
    })
    
    // 2. 再发送消息
    const payload = {
      receiver_id: currentChatId.value,
      content: uploadRes.data.key,
      type: 'MESSAGE',
      content_type: 'IMAGE',
      title: '图片'
    }
    
    const res = await api.post('/messages/', payload)
    messages.value.push(res.data)
  } catch (e) {
    ElMessage.error('上传失败')
  }
}
```

## 9. 调用示例 (Python)

以下示例展示如何使用 Python 发送通知。

```python
import requests
import time
import hmac
import hashlib

# 配置
API_URL = "{{API_BASE_URL}}/messages/"
APP_ID = "your_app_id_string"  # 字符串类型的 app_id
APP_SECRET = "your_app_secret"

def generate_signature(app_id, app_secret):
    """生成签名"""
    timestamp = str(int(time.time()))
    params = {"app_id": app_id, "timestamp": timestamp}
    query_string = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
    sign = hmac.new(app_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256).hexdigest()
    return timestamp, sign

timestamp, sign = generate_signature(APP_ID, APP_SECRET)

# Debug: 打印签名信息
query_string = "&".join([f"{k}={v}" for k, v in sorted({"app_id": APP_ID, "timestamp": timestamp}.items())])
print(f"Debug - Query string for signature: {query_string}")
print(f"Debug - Generated signature: {sign}")
print(f"Debug - Timestamp: {timestamp}")

headers = {
    "X-App-Id": APP_ID,
    "X-Timestamp": timestamp,
    "X-Sign": sign,
    "Content-Type": "application/json"
}

payload = {
    "app_id": APP_ID,  
    "app_user_id": "admin",
    "type": "NOTIFICATION",
    "content_type": "TEXT",
    "title": "测试通知",
    "content": "这是一条测试通知消息2",
    "auto_sso": True,  # 开启自动 SSO
    "target_url": "http://your-business-system.com/detail/123",  # 最终要跳转的业务页面
    "action_text": "查看详情"
}

print(f"Debug - Request headers: {headers}")
print(f"Debug - Request payload: {payload}")

resp = requests.post(API_URL, json=payload, headers=headers)
print(f"Status: {resp.status_code}")
print(f"Response Headers: {dict(resp.headers)}")

# Safely handle response - check if it's JSON
try:
    if resp.text:
        print(f"Response Text: {resp.text}")
        try:
            print(f"Response JSON: {resp.json()}")
        except requests.exceptions.JSONDecodeError:
            print("Response is not valid JSON")
    else:
        print("Response body is empty")
except Exception as e:
    print(f"Error reading response: {e}")
```
