liuq 1 miesiąc temu
rodzic
commit
577439be9f

+ 658 - 0
frontend/public/docs/account_management_api.md

@@ -0,0 +1,658 @@
+# 平台账号管理接口文档
+
+**版本**: V1.0  
+**日期**: 2026-01-XX  
+**状态**: 已发布
+
+本文档详细描述了统一认证平台(UAP)的平台账号管理相关接口,包括查询用户账号映射列表和 SSO 单点登录功能。
+
+---
+
+## 1. 概述
+
+平台账号管理接口允许用户查询和管理自己在各个第三方应用中的账号映射关系,并支持通过 SSO 方式快速登录到目标应用。
+
+### 1.1 核心概念
+
+- **账号映射 (User Mapping)**: 统一认证平台用户与第三方应用账号的关联关系
+- **映射账号 (Mapped Key)**: 用户在第三方系统中的唯一标识
+- **映射邮箱 (Mapped Email)**: 用户在第三方系统中的邮箱地址
+- **SSO 登录**: 单点登录,用户无需再次输入密码即可进入第三方应用
+
+### 1.2 接口权限说明
+
+| 接口 | 认证方式 | 说明 |
+|------|---------|------|
+| `GET /simple/me/mappings` | Bearer Token (用户认证) | 仅用户可查询自己的账号映射 |
+| `POST /simple/sso-login` | Bearer Token (可选) 或 用户名密码 | 支持会话认证和凭据认证两种模式 |
+
+---
+
+## 2. 认证方式
+
+### 2.1 用户认证 (Bearer Token)
+
+适用于前端应用或已登录的用户。
+
+**请求头格式:**
+```
+Authorization: Bearer {access_token}
+```
+
+**获取 Token:**
+- 通过 `POST /auth/login/json` 接口登录获取
+- 或通过 `POST /simple/login` 接口(不提供 app_id)获取平台 access_token
+
+### 2.2 凭据认证 (用户名密码)
+
+适用于用户未登录 UAP 平台的场景,直接使用用户名和密码进行认证。
+
+---
+
+## 3. 接口定义
+
+### 3.1 获取我的账号映射列表
+
+查询当前用户在所有第三方应用中的账号映射关系。
+
+- **接口地址**: `GET {{API_BASE_URL}}/simple/me/mappings`
+- **认证方式**: Bearer Token (用户认证)
+- **Content-Type**: `application/json`
+
+#### 3.1.1 请求参数 (Query Parameters)
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| `skip` | integer | 否 | 0 | 跳过的记录数(用于分页) |
+| `limit` | integer | 否 | 10 | 每页返回的记录数 |
+| `app_name` | string | 否 | - | 应用名称(支持模糊搜索) |
+
+#### 3.1.2 响应格式
+
+**成功响应 (200 OK):**
+
+```json
+{
+  "total": 25,
+  "items": [
+    {
+      "app_name": "OA办公系统",
+      "app_id": "oa_system_001",
+      "protocol_type": "SIMPLE_API",
+      "mapped_key": "zhangsan_oa",
+      "mapped_email": "zhangsan@company.com",
+      "is_active": true
+    },
+    {
+      "app_name": "CRM客户管理",
+      "app_id": "crm_system_001",
+      "protocol_type": "SIMPLE_API",
+      "mapped_key": "zhangsan_crm",
+      "mapped_email": null,
+      "is_active": true
+    }
+  ]
+}
+```
+
+**响应字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `total` | integer | 总记录数 |
+| `items` | array | 映射列表 |
+| `items[].app_name` | string | 应用名称 |
+| `items[].app_id` | string | 应用ID |
+| `items[].protocol_type` | string | 协议类型:`SIMPLE_API` 或 `OIDC` |
+| `items[].mapped_key` | string | 映射账号(第三方系统账号) |
+| `items[].mapped_email` | string\|null | 映射邮箱(可能为空) |
+| `items[].is_active` | boolean | 是否激活(true: 正常, false: 已禁用) |
+
+#### 3.1.3 错误响应
+
+**401 Unauthorized - 未认证:**
+```json
+{
+  "detail": "Not authenticated"
+}
+```
+
+**403 Forbidden - 无权限:**
+```json
+{
+  "detail": "Not enough permissions"
+}
+```
+
+#### 3.1.4 请求示例
+
+**cURL:**
+```bash
+curl -X GET "{{API_BASE_URL}}/simple/me/mappings?skip=0&limit=10&app_name=OA" \
+  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+```
+
+**JavaScript (Fetch API):**
+```javascript
+const response = await fetch('{{API_BASE_URL}}/simple/me/mappings?skip=0&limit=10&app_name=OA', {
+  method: 'GET',
+  headers: {
+    'Authorization': `Bearer ${accessToken}`,
+    'Content-Type': 'application/json'
+  }
+});
+
+const data = await response.json();
+console.log('总记录数:', data.total);
+console.log('映射列表:', data.items);
+```
+
+**Python (requests):**
+```python
+import requests
+
+headers = {
+    'Authorization': f'Bearer {access_token}',
+    'Content-Type': 'application/json'
+}
+
+params = {
+    'skip': 0,
+    'limit': 10,
+    'app_name': 'OA'  # 可选,模糊搜索
+}
+
+response = requests.get(
+    '{{API_BASE_URL}}/simple/me/mappings',
+    headers=headers,
+    params=params
+)
+
+data = response.json()
+print(f"总记录数: {data['total']}")
+for mapping in data['items']:
+    print(f"应用: {mapping['app_name']}, 映射账号: {mapping['mapped_key']}")
+```
+
+---
+
+### 3.2 SSO 单点登录
+
+通过 SSO 方式登录到目标应用,支持两种认证模式:
+1. **会话认证模式**: 用户已登录 UAP 平台,使用 Bearer Token
+2. **凭据认证模式**: 用户未登录,使用用户名和密码
+
+- **接口地址**: `POST {{API_BASE_URL}}/simple/sso-login`
+- **认证方式**: 
+  - Bearer Token (会话认证,推荐)
+  - 或 用户名密码 (凭据认证)
+- **Content-Type**: `application/json`
+- **限制**: 仅支持 `SIMPLE_API` 协议类型的应用
+
+#### 3.2.1 请求参数 (Request Body)
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `app_id` | string | 是 | 目标应用的ID |
+| `username` | string | 条件必填 | 用户名(手机号、映射key或映射email)。如果已登录UAP平台,可不提供 |
+| `password` | string | 条件必填 | 用户密码。如果已登录UAP平台,可不提供 |
+
+**注意:**
+- 如果用户已登录 UAP 平台(请求头包含有效的 Bearer Token),则 `username` 和 `password` 可以省略
+- 如果用户未登录,则必须提供 `username` 和 `password`
+
+#### 3.2.2 响应格式
+
+**成功响应 (200 OK):**
+
+```json
+{
+  "redirect_url": "https://oa.company.com/login?ticket=TICKET-7f8e9d0a-1234-5678-9abc-def012345678"
+}
+```
+
+**响应字段说明:**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `redirect_url` | string | 包含 SSO Ticket 的完整跳转URL,客户端应在新窗口/标签页中打开 |
+
+#### 3.2.3 错误响应
+
+**400 Bad Request - 应用未找到或协议不支持:**
+```json
+{
+  "detail": "应用未找到"
+}
+```
+或
+```json
+{
+  "detail": "SSO 登录仅支持简易 API 应用。OIDC 请使用标准流程。"
+}
+```
+
+**401 Unauthorized - 认证失败:**
+```json
+{
+  "detail": "认证失败"
+}
+```
+
+**400 Bad Request - 用户已禁用:**
+```json
+{
+  "detail": "用户已禁用"
+}
+```
+
+**400 Bad Request - 应用未配置重定向URI:**
+```json
+{
+  "detail": "应用未配置重定向 URI"
+}
+```
+
+#### 3.2.4 请求示例
+
+**场景1: 用户已登录 UAP 平台(会话认证)**
+
+**cURL:**
+```bash
+curl -X POST "{{API_BASE_URL}}/simple/sso-login" \
+  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
+  -H "Content-Type: application/json" \
+  -d '{
+    "app_id": "oa_system_001"
+  }'
+```
+
+**JavaScript (Fetch API):**
+```javascript
+// 用户已登录,使用 Bearer Token
+const response = await fetch('{{API_BASE_URL}}/simple/sso-login', {
+  method: 'POST',
+  headers: {
+    'Authorization': `Bearer ${accessToken}`,
+    'Content-Type': 'application/json'
+  },
+  body: JSON.stringify({
+    app_id: 'oa_system_001'
+  })
+});
+
+const data = await response.json();
+if (data.redirect_url) {
+  // 在新标签页中打开
+  window.open(data.redirect_url, '_blank');
+}
+```
+
+**场景2: 用户未登录(凭据认证)**
+
+**cURL:**
+```bash
+curl -X POST "{{API_BASE_URL}}/simple/sso-login" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "app_id": "oa_system_001",
+    "username": "13800138000",
+    "password": "user_password_123"
+  }'
+```
+
+**JavaScript (Fetch API):**
+```javascript
+// 用户未登录,使用用户名密码
+const response = await fetch('{{API_BASE_URL}}/simple/sso-login', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json'
+  },
+  body: JSON.stringify({
+    app_id: 'oa_system_001',
+    username: '13800138000',
+    password: 'user_password_123'
+  })
+});
+
+const data = await response.json();
+if (data.redirect_url) {
+  window.open(data.redirect_url, '_blank');
+}
+```
+
+**Python (requests):**
+```python
+import requests
+
+# 场景1: 会话认证(已登录)
+headers = {
+    'Authorization': f'Bearer {access_token}',
+    'Content-Type': 'application/json'
+}
+
+data = {
+    'app_id': 'oa_system_001'
+}
+
+response = requests.post(
+    '{{API_BASE_URL}}/simple/sso-login',
+    headers=headers,
+    json=data
+)
+
+result = response.json()
+print(f"跳转URL: {result['redirect_url']}")
+
+# 场景2: 凭据认证(未登录)
+data = {
+    'app_id': 'oa_system_001',
+    'username': '13800138000',
+    'password': 'user_password_123'
+}
+
+response = requests.post(
+    '{{API_BASE_URL}}/simple/sso-login',
+    headers={'Content-Type': 'application/json'},
+    json=data
+)
+
+result = response.json()
+print(f"跳转URL: {result['redirect_url']}")
+```
+
+---
+
+## 4. 完整集成示例
+
+### 4.1 前端 Vue.js 示例
+
+```vue
+<template>
+  <div class="mappings-container">
+    <el-table :data="mappings" v-loading="loading">
+      <el-table-column prop="app_name" label="应用名称" />
+      <el-table-column prop="mapped_key" label="映射账号" />
+      <el-table-column prop="is_active" label="状态">
+        <template #default="scope">
+          <el-tag :type="scope.row.is_active ? 'success' : 'danger'">
+            {{ scope.row.is_active ? '正常' : '已禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作">
+        <template #default="scope">
+          <el-button 
+            type="primary" 
+            size="small"
+            :disabled="!scope.row.is_active || scope.row.protocol_type !== 'SIMPLE_API'"
+            :loading="loginLoading[scope.row.app_id]"
+            @click="handleSsoLogin(scope.row)"
+          >
+            进入
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, reactive } from 'vue'
+import { ElMessage } from 'element-plus'
+import api from '../utils/request'
+
+interface Mapping {
+  app_name: string
+  app_id: string
+  protocol_type: string
+  mapped_key: string
+  mapped_email: string | null
+  is_active: boolean
+}
+
+const mappings = ref<Mapping[]>([])
+const loading = ref(false)
+const loginLoading = reactive<Record<string, boolean>>({})
+
+// 获取账号映射列表
+const fetchMappings = async () => {
+  loading.value = true
+  try {
+    const res = await api.get('/simple/me/mappings', {
+      params: { skip: 0, limit: 100 }
+    })
+    if (res.data) {
+      mappings.value = res.data.items
+    }
+  } catch (error: any) {
+    ElMessage.error(error.response?.data?.detail || '获取列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// SSO 登录
+const handleSsoLogin = async (mapping: Mapping) => {
+  if (!mapping.is_active) {
+    ElMessage.warning('该账号已禁用,无法登录')
+    return
+  }
+  
+  if (mapping.protocol_type !== 'SIMPLE_API') {
+    ElMessage.warning('仅支持简易 API 类型的应用')
+    return
+  }
+  
+  loginLoading[mapping.app_id] = true
+  
+  try {
+    // 使用会话认证(用户已登录 UAP 平台)
+    const res = await api.post('/simple/sso-login', {
+      app_id: mapping.app_id
+    }, {
+      headers: {
+        'Authorization': `Bearer ${localStorage.getItem('access_token')}`
+      }
+    })
+    
+    if (res.data && res.data.redirect_url) {
+      // 在新标签页中打开目标应用
+      window.open(res.data.redirect_url, '_blank')
+      ElMessage.success('正在新标签页中打开应用...')
+    } else {
+      ElMessage.error('SSO 登录失败:未返回跳转地址')
+    }
+  } catch (error: any) {
+    console.error('SSO 登录失败:', error)
+    ElMessage.error(error.response?.data?.detail || 'SSO 登录失败')
+  } finally {
+    loginLoading[mapping.app_id] = false
+  }
+}
+
+onMounted(() => {
+  fetchMappings()
+})
+</script>
+```
+
+### 4.2 后端 Python 示例
+
+```python
+import requests
+from typing import List, Dict, Optional
+
+class UAPAccountManager:
+    """统一认证平台账号管理客户端"""
+    
+    def __init__(self, base_url: str, access_token: str):
+        self.base_url = base_url.rstrip('/')
+        self.access_token = access_token
+        self.headers = {
+            'Authorization': f'Bearer {access_token}',
+            'Content-Type': 'application/json'
+        }
+    
+    def get_my_mappings(
+        self, 
+        skip: int = 0, 
+        limit: int = 10, 
+        app_name: Optional[str] = None
+    ) -> Dict:
+        """
+        获取我的账号映射列表
+        
+        Args:
+            skip: 跳过的记录数
+            limit: 每页返回的记录数
+            app_name: 应用名称(模糊搜索)
+        
+        Returns:
+            {
+                'total': 总记录数,
+                'items': [映射列表]
+            }
+        """
+        params = {'skip': skip, 'limit': limit}
+        if app_name:
+            params['app_name'] = app_name
+        
+        response = requests.get(
+            f'{self.base_url}/simple/me/mappings',
+            headers=self.headers,
+            params=params
+        )
+        response.raise_for_status()
+        return response.json()
+    
+    def sso_login(
+        self, 
+        app_id: str, 
+        username: Optional[str] = None,
+        password: Optional[str] = None
+    ) -> str:
+        """
+        SSO 单点登录
+        
+        Args:
+            app_id: 目标应用ID
+            username: 用户名(可选,如果已登录UAP可不提供)
+            password: 密码(可选,如果已登录UAP可不提供)
+        
+        Returns:
+            跳转URL
+        """
+        data = {'app_id': app_id}
+        if username and password:
+            data['username'] = username
+            data['password'] = password
+        
+        # 如果提供了用户名密码,不使用 Bearer Token
+        headers = self.headers if not username else {'Content-Type': 'application/json'}
+        
+        response = requests.post(
+            f'{self.base_url}/simple/sso-login',
+            headers=headers,
+            json=data
+        )
+        response.raise_for_status()
+        result = response.json()
+        return result['redirect_url']
+
+
+# 使用示例
+if __name__ == '__main__':
+    # 初始化客户端
+    manager = UAPAccountManager(
+        base_url='{{API_BASE_URL}}',
+        access_token='your_access_token_here'
+    )
+    
+    # 获取账号映射列表
+    result = manager.get_my_mappings(skip=0, limit=10, app_name='OA')
+    print(f"总记录数: {result['total']}")
+    for mapping in result['items']:
+        print(f"应用: {mapping['app_name']}, 映射账号: {mapping['mapped_key']}")
+    
+    # SSO 登录(会话认证)
+    redirect_url = manager.sso_login(app_id='oa_system_001')
+    print(f"跳转URL: {redirect_url}")
+    
+    # SSO 登录(凭据认证)
+    redirect_url = manager.sso_login(
+        app_id='oa_system_001',
+        username='13800138000',
+        password='user_password_123'
+    )
+    print(f"跳转URL: {redirect_url}")
+```
+
+---
+
+## 5. 注意事项
+
+### 5.1 协议类型限制
+
+- `GET /simple/me/mappings` 接口返回所有类型的应用映射
+- `POST /simple/sso-login` 接口**仅支持 `SIMPLE_API` 协议类型**的应用
+- 对于 `OIDC` 协议类型的应用,请使用标准的 OIDC 流程进行登录
+
+### 5.2 账号状态
+
+- 只有 `is_active: true` 的账号映射才能进行 SSO 登录
+- 如果账号被禁用,SSO 登录会返回错误
+
+### 5.3 安全建议
+
+1. **Token 安全**: Bearer Token 应妥善保管,避免泄露
+2. **HTTPS**: 生产环境必须使用 HTTPS 协议
+3. **Token 过期**: 注意处理 Token 过期的情况,及时刷新或重新登录
+4. **错误处理**: 建议实现完善的错误处理和重试机制
+
+### 5.4 分页建议
+
+- 默认每页返回 10 条记录
+- 建议根据实际需求设置合适的 `limit` 值(最大建议 100)
+- 使用 `skip` 和 `limit` 实现分页导航
+
+---
+
+## 6. 常见问题 (FAQ)
+
+### Q1: 为什么 SSO 登录返回 400 错误,提示"协议不支持"?
+
+**A:** `POST /simple/sso-login` 接口仅支持 `SIMPLE_API` 协议类型的应用。如果您的应用是 `OIDC` 类型,请使用标准的 OIDC 认证流程。
+
+### Q2: 如何判断用户是否已登录 UAP 平台?
+
+**A:** 如果您的应用中有有效的 `access_token`,可以在请求头中携带 `Authorization: Bearer {token}`。如果 Token 有效,则无需提供 `username` 和 `password`。
+
+### Q3: 账号映射列表中的 `mapped_email` 为什么可能为空?
+
+**A:** 不是所有第三方应用都要求或提供邮箱信息,因此 `mapped_email` 字段可能为 `null`。这是正常情况。
+
+### Q4: 如何实现"记住我"功能?
+
+**A:** 建议在客户端(浏览器)安全地存储 `access_token`(如使用 httpOnly Cookie 或安全的 localStorage),并在每次请求时携带该 Token。
+
+### Q5: SSO 登录后,Ticket 的有效期是多久?
+
+**A:** Ticket 的有效期由平台配置决定,通常为 5-10 分钟。建议在获取 `redirect_url` 后立即跳转,避免 Ticket 过期。
+
+---
+
+## 7. 更新日志
+
+| 版本 | 日期 | 更新内容 |
+|------|------|---------|
+| V1.0 | 2026-01-XX | 初始版本发布 |
+
+---
+
+## 8. 技术支持
+
+如有问题或建议,请联系技术支持团队或查看项目文档。
+
+**文档地址**: `{{DOCS_BASE_URL}}/docs/account_management_api.md`

+ 1182 - 0
frontend/public/docs/message_integration.md

@@ -0,0 +1,1182 @@
+# 消息中心对接指南
+
+统一消息中心支持应用向用户发送系统通知,并支持用户通过 WebSocket 实时接收消息。本指南将指导您如何接入消息发送与推送服务。
+
+## 1. 核心概念
+
+- **Message (私信)**: 用户与用户之间的点对点消息。
+- **Notification (通知)**: 系统或应用发送给用户的业务提醒,支持 SSO 跳转。
+- **WebSocket**: 客户端通过长连接实时接收推送。
+
+## 1.1 接口权限说明
+
+消息中心接口支持两种认证方式:用户认证(JWT Token)和应用认证(签名验证)。不同接口的权限如下:
+
+| 接口 | 用户权限 | 应用权限 | 说明 |
+|------|---------|---------|------|
+| `POST /messages/` | ✅ 仅 MESSAGE | ✅ MESSAGE + NOTIFICATION | 用户只能发私信,应用可发通知 |
+| `GET /messages/conversations` | ✅ | ❌ | 仅用户可查询 |
+| `GET /messages/history/{id}` | ✅ | ❌ | 仅用户可查询 |
+| `GET /messages/unread-count` | ✅ | ❌ | 仅用户可查询 |
+| `PUT /messages/{id}/read` | ✅ | ❌ | 仅用户可操作 |
+| `PUT /messages/read-all` | ✅ | ❌ | 仅用户可操作 |
+| `DELETE /messages/{id}` | ✅ | ❌ | 仅用户可操作 |
+| `POST /messages/upload` | ✅ | ✅ | 用户和应用都可上传 |
+
+## 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=101&timestamp=1700000000)`)。
+
+**完整 HTTP 请求示例:**
+
+```
+POST {{API_BASE_URL}}/messages/ HTTP/1.1
+Host: api.yourdomain.com
+Content-Type: application/json
+X-App-Id: 101
+X-Timestamp: 1708848000
+X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
+
+{
+  "app_id": 101,
+  "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 如何获取接收者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 对应此字段)
+      "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": 101,              // 使用 items[0].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` 强制设置为该应用的内部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"
+  },
+  {
+    "user_id": 102,
+    "username": "13800138000",
+    "full_name": "李四",
+    "unread_count": 0,
+    "last_message": "[IMAGE]",
+    "last_message_type": "IMAGE",
+    "updated_at": "2026-02-22T18:30:00"
+  }
+]
+```
+
+**说明:**
+
+- `user_id: 0` 表示系统通知会话
+- `unread_count` 表示该会话的未读消息数
+- `last_message` 显示最后一条消息内容(多媒体类型显示为 `[TYPE]`)
+
+### 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.3 删除消息
+
+- **接口地址**: `DELETE {{API_BASE_URL}}/messages/{message_id}`
+- **路径参数**: `message_id` - 消息ID
+- **权限**: 只能删除自己接收的消息
+
+**请求示例:**
+
+```
+DELETE {{API_BASE_URL}}/messages/501
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+```
+
+## 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=101&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
+import json
+
+# 配置
+API_URL = "{{API_BASE_URL}}/messages/"
+APP_ID = "101"
+APP_SECRET = "your_app_secret"
+
+# 1. 构造消息体
+payload = {
+    "app_id": int(APP_ID),
+    "app_user_id": "zhangsan_oa",
+    "type": "NOTIFICATION",
+    "title": "请假审批",
+    "content": "张三申请年假3天",
+    "auto_sso": True,
+    "target_url": "http://oa.example.com/leave/123"
+}
+
+# 2. 生成签名 Headers
+timestamp = str(int(time.time()))
+
+# 签名参数 (注意:消息接口签名仅包含 app_id 和 timestamp)
+params = {
+    "app_id": APP_ID,
+    "timestamp": timestamp
+}
+
+# 排序并拼接: app_id=101&timestamp=1700000000
+sorted_keys = sorted(params.keys())
+query_string = "&".join([f"{k}={params[k]}" for k in sorted_keys])
+
+# HMAC-SHA256
+sign = hmac.new(
+    APP_SECRET.encode('utf-8'),
+    query_string.encode('utf-8'),
+    hashlib.sha256
+).hexdigest()
+
+headers = {
+    "X-App-Id": APP_ID,
+    "X-Timestamp": timestamp,
+    "X-Sign": sign,
+    "Content-Type": "application/json"
+}
+
+# 3. 发送
+print(f"Signing string: {query_string}")
+resp = requests.post(API_URL, json=payload, headers=headers)
+print(resp.json())
+```

+ 416 - 0
frontend/public/docs/minio_file_permissions_guide.md

@@ -0,0 +1,416 @@
+# 消息文件存储与权限控制开发者指南
+
+**版本**: V1.0  
+**日期**: 2026-01-XX  
+**适用场景**: 聊天记录中的图片、视频、文件等附件存储与访问
+
+---
+
+## 1. 概述
+
+统一消息平台使用 MinIO 对象存储服务来管理聊天记录中的文件附件(图片、视频、文档等)。为了确保文件安全,系统采用**双重权限控制机制**:
+
+1. **数据库层权限控制**:用户只能查询自己相关的消息
+2. **存储层权限控制**:通过预签名 URL(Presigned URL)实现临时访问授权
+
+### 核心特性
+
+- ✅ **私有存储**:所有文件存储在私有 Bucket 中,无法直接访问
+- ✅ **权限隔离**:只有有权限查看消息的用户才能获取文件访问链接
+- ✅ **时效控制**:预签名 URL 默认 1 小时有效,过期后需重新获取
+- ✅ **路径隔离**:文件路径包含用户 ID,便于后续扩展细粒度权限
+
+---
+
+## 2. 权限控制架构
+
+### 2.1 存储层设计
+
+```
+MinIO Bucket: unified-message-files
+├── messages/
+│   ├── {user_id}/
+│   │   ├── {year}/
+│   │   │   ├── {month}/
+│   │   │   │   ├── {uuid}.{ext}
+```
+
+**文件路径格式**: `messages/{user_id}/{year}/{month}/{uuid}.{ext}`
+
+**示例**: `messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg`
+
+### 2.2 权限控制流程
+
+```
+1. 用户上传文件 (需认证)
+   ↓
+2. 存储到私有Bucket
+   ↓
+3. 返回object_key
+   ↓
+4. 保存消息记录(content=object_key)
+   ↓
+5. 返回预签名URL(1小时有效)
+
+6. 用户查询消息列表 (需认证)
+   ↓
+7. 权限过滤(仅查询自己的消息)
+   ↓
+8. 返回消息列表
+   ↓
+9. 为文件类型生成预签名URL
+   ↓
+10. 返回消息(含签名URL)
+```
+
+---
+
+## 3. 开发者使用指南
+
+### 3.1 文件上传
+
+#### 接口说明
+
+- **Endpoint**: `POST /api/v1/messages/upload`
+- **认证方式**: Bearer Token (用户认证) 或 应用签名认证
+- **Content-Type**: `multipart/form-data`
+
+#### 请求示例
+
+```bash
+curl -X POST "{{API_BASE_URL}}/messages/upload" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -F "file=@/path/to/image.jpg"
+```
+
+#### 响应示例
+
+```json
+{
+  "url": "https://minio.example.com/bucket/messages/123/2026/01/uuid.jpg?X-Amz-Algorithm=...&X-Amz-Expires=3600",
+  "key": "messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg",
+  "filename": "image.jpg",
+  "content_type": "image/jpeg",
+  "size": 102400
+}
+```
+
+#### 字段说明
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `url` | string | 预签名访问 URL(1小时有效) |
+| `key` | string | MinIO 对象键(object_key),用于存储到消息表 |
+| `filename` | string | 原始文件名 |
+| `content_type` | string | MIME 类型 |
+| `size` | int | 文件大小(字节) |
+
+#### 前端示例 (JavaScript)
+
+```javascript
+async function uploadFile(file) {
+  const formData = new FormData();
+  formData.append('file', file);
+
+  const response = await fetch('/api/v1/messages/upload', {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${token}`
+    },
+    body: formData
+  });
+
+  const result = await response.json();
+  
+  // 使用 result.key 发送消息
+  return result;
+}
+```
+
+#### 文件类型限制
+
+系统支持以下文件类型:
+
+- **图片**: `image/jpeg`, `image/png`, `image/gif`, `image/webp`
+- **视频**: `video/mp4`, `video/quicktime`, `video/x-msvideo`
+- **文档**: `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.*`, `text/plain`
+
+**文件大小限制**: 最大 50MB
+
+---
+
+### 3.2 发送带文件的消息
+
+上传文件后,使用返回的 `key` 作为消息内容发送。
+
+#### 请求示例
+
+```bash
+curl -X POST "{{API_BASE_URL}}/messages/" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "receiver_id": 456,
+    "type": "MESSAGE",
+    "content_type": "IMAGE",
+    "title": "分享图片",
+    "content": "messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg"
+  }'
+```
+
+**重要提示**:
+- `content` 字段应存储 `key`(object_key),而不是完整的 URL
+- 系统会在查询消息时自动为文件类型生成预签名 URL
+
+---
+
+### 3.3 查询消息(自动生成文件访问链接)
+
+#### 接口说明
+
+- **Endpoint**: `GET /api/v1/messages/`
+- **认证方式**: Bearer Token(仅用户认证)
+- **权限**: 用户只能查询自己作为接收者的消息
+
+#### 请求示例
+
+```bash
+curl -X GET "{{API_BASE_URL}}/messages/?skip=0&limit=20" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN"
+```
+
+#### 响应示例
+
+```json
+[
+  {
+    "id": 1001,
+    "sender_id": 123,
+    "receiver_id": 456,
+    "type": "MESSAGE",
+    "content_type": "IMAGE",
+    "title": "分享图片",
+    "content": "https://minio.example.com/bucket/messages/123/2026/01/uuid.jpg?X-Amz-Algorithm=...&X-Amz-Expires=3600",
+    "is_read": false,
+    "created_at": "2026-01-15T10:30:00"
+  }
+]
+```
+
+#### 权限控制说明
+
+1. **数据库层过滤**:API 自动过滤,只返回 `receiver_id == current_user.id` 的消息
+2. **自动签名**:对于 `content_type` 为 `IMAGE`、`VIDEO`、`FILE` 的消息,系统自动将 `content` 字段中的 `key` 转换为预签名 URL
+3. **URL 时效**:预签名 URL 默认 1 小时有效
+
+#### 前端处理示例
+
+```javascript
+async function fetchMessages() {
+  const response = await fetch('/api/v1/messages/', {
+    headers: {
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  
+  const messages = await response.json();
+  
+  messages.forEach(msg => {
+    if (['IMAGE', 'VIDEO', 'FILE'].includes(msg.content_type)) {
+      // content 已经是预签名 URL,直接使用
+      console.log('文件URL:', msg.content);
+    }
+  });
+  
+  return messages;
+}
+```
+
+---
+
+### 3.4 聊天历史记录
+
+#### 接口说明
+
+- **Endpoint**: `GET /api/v1/messages/history/{other_user_id}`
+- **认证方式**: Bearer Token
+- **权限**: 只能查询与当前用户的聊天记录
+
+#### 请求示例
+
+```bash
+curl -X GET "{{API_BASE_URL}}/messages/history/456?skip=0&limit=50" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN"
+```
+
+#### 权限验证逻辑
+
+系统会验证:
+- 消息的 `sender_id` 或 `receiver_id` 必须包含当前用户
+- 只有通过验证的消息才会返回,并自动生成文件预签名 URL
+
+---
+
+## 4. 预签名 URL 机制详解
+
+### 4.1 什么是预签名 URL?
+
+预签名 URL(Presigned URL)是 MinIO/S3 提供的一种临时访问授权机制。它允许在不需要公开 Bucket 的情况下,为特定对象生成一个有时效性的访问链接。
+
+### 4.2 生成时机
+
+预签名 URL 在以下场景自动生成:
+
+1. **文件上传后**:上传接口返回的 `url` 字段
+2. **查询消息时**:`_process_message_content()` 函数自动处理
+3. **WebSocket 推送时**:实时消息推送中的文件内容
+
+### 4.3 URL 有效期
+
+- **默认有效期**: 1 小时(3600 秒)
+- **过期处理**: URL 过期后,前端需要重新调用消息查询接口获取新的预签名 URL
+
+### 4.4 URL 格式示例
+
+```
+https://minio.example.com:9004/unified-message-files/messages/123/2026/01/uuid.jpg
+?X-Amz-Algorithm=AWS4-HMAC-SHA256
+&X-Amz-Credential=ACCESS_KEY%2F20260115%2Fus-east-1%2Fs3%2Faws4_request
+&X-Amz-Date=20260115T103000Z
+&X-Amz-Expires=3600
+&X-Amz-SignedHeaders=host
+&X-Amz-Signature=abc123...
+```
+
+---
+
+## 5. 安全最佳实践
+
+### 5.1 前端开发建议
+
+1. **不要缓存预签名 URL**
+   - URL 有时效性,建议每次显示消息时重新获取
+   - 如果 URL 过期,重新调用消息查询接口
+
+2. **错误处理**
+   ```javascript
+   async function loadImage(url) {
+     try {
+       const response = await fetch(url);
+       if (!response.ok) {
+         // URL 可能已过期,重新获取消息
+         await refreshMessages();
+       }
+       return response.blob();
+     } catch (error) {
+       console.error('加载图片失败:', error);
+     }
+   }
+   ```
+
+3. **图片预览优化**
+   - 对于大文件,考虑在前端添加加载状态
+   - 可以添加图片压缩或缩略图功能
+
+### 5.2 后端开发建议
+
+1. **不要直接暴露 object_key**
+   - 消息表中的 `content` 字段存储的是 `key`,不是完整 URL
+   - 只有在有权限查看消息时才生成预签名 URL
+
+2. **权限验证**
+   - 所有消息查询接口都已内置权限验证
+   - 不要绕过权限检查直接生成预签名 URL
+
+3. **文件清理**
+   - 考虑实现定期清理机制,删除长时间未访问的文件
+   - 可以在删除消息时同步删除 MinIO 中的文件
+
+---
+
+## 6. 常见问题 (FAQ)
+
+### Q1: 为什么上传文件后返回的 URL 有时效性?
+
+**A**: 为了安全考虑,所有文件访问都通过预签名 URL。即使 URL 过期,用户仍可以通过查询消息接口重新获取有效的 URL。
+
+### Q2: 如何延长文件访问 URL 的有效期?
+
+**A**: 目前默认 1 小时有效期。如需调整,可以修改 `backend/app/core/minio.py` 中的 `get_presigned_url()` 方法的 `expires` 参数。
+
+```python
+# 修改为 24 小时
+presigned_url = minio_storage.get_presigned_url(object_name, expires=timedelta(hours=24))
+```
+
+### Q3: 用户能否直接访问 MinIO 中的文件?
+
+**A**: 不能。Bucket 设置为私有模式,所有文件必须通过预签名 URL 访问。即使知道文件路径,没有有效的预签名 URL 也无法访问。
+
+### Q4: 如何实现文件下载功能?
+
+**A**: 预签名 URL 支持直接下载。可以在前端添加下载按钮:
+
+```javascript
+function downloadFile(url, filename) {
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.click();
+}
+```
+
+### Q5: 预签名 URL 过期后如何处理?
+
+**A**: 前端检测到 URL 过期(HTTP 403/404)时,重新调用消息查询接口获取新的预签名 URL。
+
+### Q6: 能否为其他用户生成文件访问链接?
+
+**A**: 不能。系统会验证消息权限,只有有权限查看消息的用户才能获取文件访问链接。这确保了文件访问的安全性。
+
+### Q7: 文件路径中的 user_id 是上传者还是接收者?
+
+**A**: 是上传者的 `user_id`。文件路径格式为 `messages/{uploader_user_id}/{year}/{month}/{uuid}.{ext}`。
+
+---
+
+## 7. 技术实现细节
+
+### 7.1 核心代码位置
+
+- **MinIO 存储类**: `backend/app/core/minio.py`
+- **文件上传接口**: `backend/app/api/v1/endpoints/messages_upload.py`
+- **消息查询接口**: `backend/app/api/v1/endpoints/messages.py`
+- **URL 生成逻辑**: `_process_message_content()` 函数
+
+### 7.2 配置说明
+
+MinIO 相关配置在 `backend/app/core/config.py` 中:
+
+```python
+MINIO_ENDPOINT: str = "https://api.hnyunzhu.com:9004"
+MINIO_ACCESS_KEY: str = "your_access_key"
+MINIO_SECRET_KEY: str = "your_secret_key"
+MINIO_BUCKET_NAME: str = "unified-message-files"
+MINIO_SECURE: bool = False
+```
+
+---
+
+## 8. 更新日志
+
+### V1.0 (2026-01-XX)
+- 初始版本
+- 实现基于预签名 URL 的文件权限控制
+- 支持图片、视频、文档等文件类型
+- 默认 1 小时 URL 有效期
+
+---
+
+## 9. 技术支持
+
+如有问题或建议,请联系开发团队或提交 Issue。
+
+**相关文档**:
+- [消息系统 API 文档](./api_message_system_v1.md)
+- [消息集成指南](./message_integration.md)

+ 55 - 36
frontend/src/views/Help.vue

@@ -8,43 +8,35 @@
       </el-button>
     </div>
     
-    <el-tabs v-model="activeTab" class="help-tabs">
-      <el-tab-pane label="平台对接概述" name="integration-overview">
-        <IntegrationOverview />
-      </el-tab-pane>
-
-      <el-tab-pane label="快速对接 (Redirect)" name="fast-integration">
-        <FastIntegration />
-      </el-tab-pane>
-
-      <el-tab-pane label="自定义登录页面" name="custom-login">
-        <CustomLogin />
-      </el-tab-pane>
-
-      <el-tab-pane label="票据交互" name="ticket-exchange">
-        <TicketExchange />
-      </el-tab-pane>
-
-      <el-tab-pane label="接口对比" name="api-comparison">
-        <ApiComparison />
-      </el-tab-pane>
-
-      <el-tab-pane label="账号同步 (M2M)" name="account-sync">
-        <AccountSync />
-      </el-tab-pane>
-
-      <el-tab-pane label="全量用户同步" name="user-sync-pull">
-        <UserSyncPull />
-      </el-tab-pane>
-
-      <el-tab-pane label="消息中心对接" name="message-integration">
-        <MessageIntegration />
-      </el-tab-pane>
+    <div class="tab-selector">
+      <el-select 
+        v-model="activeTab" 
+        placeholder="请选择帮助主题"
+        size="large"
+        style="width: 100%; max-width: 500px;"
+      >
+        <el-option
+          v-for="tab in tabOptions"
+          :key="tab.value"
+          :label="tab.label"
+          :value="tab.value"
+        />
+      </el-select>
+    </div>
 
-      <el-tab-pane label="其他帮助 (待定)" name="tbd">
-        <Tbd />
-      </el-tab-pane>
-    </el-tabs>
+    <div class="tab-content">
+      <IntegrationOverview v-if="activeTab === 'integration-overview'" />
+      <FastIntegration v-if="activeTab === 'fast-integration'" />
+      <CustomLogin v-if="activeTab === 'custom-login'" />
+      <TicketExchange v-if="activeTab === 'ticket-exchange'" />
+      <ApiComparison v-if="activeTab === 'api-comparison'" />
+      <AccountSync v-if="activeTab === 'account-sync'" />
+      <UserSyncPull v-if="activeTab === 'user-sync-pull'" />
+      <MessageIntegration v-if="activeTab === 'message-integration'" />
+      <MinIOFilePermissions v-if="activeTab === 'minio-file-permissions'" />
+      <AccountManagement v-if="activeTab === 'account-management'" />
+      <Tbd v-if="activeTab === 'tbd'" />
+    </div>
   </div>
 </template>
 
@@ -62,10 +54,27 @@ import ApiComparison from './help/ApiComparison.vue'
 import AccountSync from './help/AccountSync.vue'
 import UserSyncPull from './help/UserSyncPull.vue'
 import MessageIntegration from './help/MessageIntegration.vue'
+import MinIOFilePermissions from './help/MinIOFilePermissions.vue'
+import AccountManagement from './help/AccountManagement.vue'
 import Tbd from './help/Tbd.vue'
 
 const activeTab = ref('integration-overview')
 const { openSwagger } = useHelpDocs()
+
+// Tab选项配置
+const tabOptions = [
+  { label: '平台对接概述', value: 'integration-overview' },
+  { label: '快速对接 (Redirect)', value: 'fast-integration' },
+  { label: '自定义登录页面', value: 'custom-login' },
+  { label: '票据交互', value: 'ticket-exchange' },
+  { label: '接口对比', value: 'api-comparison' },
+  { label: '账号同步 (M2M)', value: 'account-sync' },
+  { label: '全量用户同步', value: 'user-sync-pull' },
+  { label: '消息中心对接', value: 'message-integration' },
+  { label: '文件存储权限控制', value: 'minio-file-permissions' },
+  { label: '平台账号管理', value: 'account-management' },
+  { label: '其他帮助 (待定)', value: 'tbd' }
+]
 </script>
 
 <style scoped>
@@ -89,6 +98,16 @@ const { openSwagger } = useHelpDocs()
   padding-bottom: 15px;
 }
 
+.tab-selector {
+  margin-bottom: 30px;
+  display: flex;
+  justify-content: flex-start;
+}
+
+.tab-content {
+  margin-top: 20px;
+}
+
 h1 {
   font-size: 28px;
   color: #1f2f3d;

+ 309 - 0
frontend/src/views/help/AccountManagement.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="help-content">
+    <div class="content-header">
+      <h2>平台账号管理接口文档</h2>
+      <el-button type="primary" size="small" plain @click="downloadDoc('/docs/account_management_api.md', 'Account_Management_API.md')">
+        <el-icon style="margin-right: 5px"><Download /></el-icon>
+        下载 开发文档
+      </el-button>
+    </div>
+    <p class="intro">平台账号管理接口允许用户查询和管理自己在各个第三方应用中的账号映射关系,并支持通过 SSO 方式快速登录到目标应用。</p>
+
+    <div class="section">
+      <h3>1. 核心概念</h3>
+      <ul>
+        <li><strong>账号映射 (User Mapping)</strong>: 统一认证平台用户与第三方应用账号的关联关系</li>
+        <li><strong>映射账号 (Mapped Key)</strong>: 用户在第三方系统中的唯一标识</li>
+        <li><strong>映射邮箱 (Mapped Email)</strong>: 用户在第三方系统中的邮箱地址</li>
+        <li><strong>SSO 登录</strong>: 单点登录,用户无需再次输入密码即可进入第三方应用</li>
+      </ul>
+    </div>
+
+    <div class="section">
+      <h3>1.1 接口权限说明</h3>
+      <table class="param-table">
+        <thead>
+          <tr><th>接口</th><th>认证方式</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><code>GET /simple/me/mappings</code></td>
+            <td>Bearer Token (用户认证)</td>
+            <td>仅用户可查询自己的账号映射</td>
+          </tr>
+          <tr>
+            <td><code>POST /simple/sso-login</code></td>
+            <td>Bearer Token (可选) 或 用户名密码</td>
+            <td>支持会话认证和凭据认证两种模式</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
+    <div class="section">
+      <h3>2. 认证方式</h3>
+      
+      <h4>2.1 用户认证 (Bearer Token)</h4>
+      <p>适用于前端应用或已登录的用户。</p>
+      <p><strong>请求头格式:</strong></p>
+      <div class="code-block">
+        <pre>Authorization: Bearer {access_token}</pre>
+      </div>
+      <p><strong>获取 Token:</strong></p>
+      <ul>
+        <li>通过 <code>POST /auth/login/json</code> 接口登录获取</li>
+        <li>或通过 <code>POST /simple/login</code> 接口(不提供 app_id)获取平台 access_token</li>
+      </ul>
+
+      <h4>2.2 凭据认证 (用户名密码)</h4>
+      <p>适用于用户未登录 UAP 平台的场景,直接使用用户名和密码进行认证。</p>
+    </div>
+
+    <div class="section">
+      <h3>3. 接口定义</h3>
+      
+      <h4>3.1 获取我的账号映射列表</h4>
+      <p>查询当前用户在所有第三方应用中的账号映射关系。</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/simple/me/mappings</code></li>
+        <li><strong>认证方式</strong>: Bearer Token (用户认证)</li>
+        <li><strong>Content-Type</strong>: <code>application/json</code></li>
+      </ul>
+
+      <h5>3.1.1 请求参数 (Query Parameters)</h5>
+      <table class="param-table">
+        <thead>
+          <tr><th>字段</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><code>skip</code></td>
+            <td>integer</td>
+            <td><span class="tag-optional">否</span></td>
+            <td>0</td>
+            <td>跳过的记录数(用于分页)</td>
+          </tr>
+          <tr>
+            <td><code>limit</code></td>
+            <td>integer</td>
+            <td><span class="tag-optional">否</span></td>
+            <td>10</td>
+            <td>每页返回的记录数</td>
+          </tr>
+          <tr>
+            <td><code>app_name</code></td>
+            <td>string</td>
+            <td><span class="tag-optional">否</span></td>
+            <td>-</td>
+            <td>应用名称(支持模糊搜索)</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <h5>3.1.2 响应格式</h5>
+      <p><strong>成功响应 (200 OK):</strong></p>
+      <div class="code-block">
+        <pre>
+{
+  "total": 25,
+  "items": [
+    {
+      "app_name": "OA办公系统",
+      "app_id": "oa_system_001",
+      "protocol_type": "SIMPLE_API",
+      "mapped_key": "zhangsan_oa",
+      "mapped_email": "zhangsan@company.com",
+      "is_active": true
+    }
+  ]
+}
+        </pre>
+      </div>
+
+      <h5>3.1.3 请求示例</h5>
+      <p><strong>cURL:</strong></p>
+      <div class="code-block">
+        <pre v-pre>
+curl -X GET "{{API_BASE_URL}}/simple/me/mappings?skip=0&limit=10&app_name=OA" \
+  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+        </pre>
+      </div>
+
+      <p><strong>JavaScript (Fetch API):</strong></p>
+      <div class="code-block">
+        <pre v-pre>
+const response = await fetch('{{API_BASE_URL}}/simple/me/mappings?skip=0&limit=10&app_name=OA', {
+  method: 'GET',
+  headers: {
+    'Authorization': `Bearer ${accessToken}`,
+    'Content-Type': 'application/json'
+  }
+});
+
+const data = await response.json();
+console.log('总记录数:', data.total);
+console.log('映射列表:', data.items);
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>3.2 SSO 单点登录</h3>
+      <p>通过 SSO 方式登录到目标应用,支持两种认证模式:</p>
+      <ol>
+        <li><strong>会话认证模式</strong>: 用户已登录 UAP 平台,使用 Bearer Token</li>
+        <li><strong>凭据认证模式</strong>: 用户未登录,使用用户名和密码</li>
+      </ol>
+      <ul>
+        <li><strong>接口地址</strong>: <code>POST /api/v1/simple/sso-login</code></li>
+        <li><strong>认证方式</strong>: Bearer Token (会话认证,推荐) 或 用户名密码 (凭据认证)</li>
+        <li><strong>Content-Type</strong>: <code>application/json</code></li>
+        <li><strong>限制</strong>: 仅支持 <code>SIMPLE_API</code> 协议类型的应用</li>
+      </ul>
+
+      <h5>3.2.1 请求参数 (Request Body)</h5>
+      <table class="param-table">
+        <thead>
+          <tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><code>app_id</code></td>
+            <td>string</td>
+            <td><span class="tag-required">是</span></td>
+            <td>目标应用的ID</td>
+          </tr>
+          <tr>
+            <td><code>username</code></td>
+            <td>string</td>
+            <td><span class="tag-conditional">条件必填</span></td>
+            <td>用户名(手机号、映射key或映射email)。如果已登录UAP平台,可不提供</td>
+          </tr>
+          <tr>
+            <td><code>password</code></td>
+            <td>string</td>
+            <td><span class="tag-conditional">条件必填</span></td>
+            <td>用户密码。如果已登录UAP平台,可不提供</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <p><strong>注意:</strong> 如果用户已登录 UAP 平台(请求头包含有效的 Bearer Token),则 <code>username</code> 和 <code>password</code> 可以省略。</p>
+
+      <h5>3.2.2 响应格式</h5>
+      <p><strong>成功响应 (200 OK):</strong></p>
+      <div class="code-block">
+        <pre>
+{
+  "redirect_url": "https://oa.company.com/login?ticket=TICKET-7f8e9d0a-1234-5678-9abc-def012345678"
+}
+        </pre>
+      </div>
+
+      <h5>3.2.3 请求示例</h5>
+      <p><strong>场景1: 用户已登录 UAP 平台(会话认证)</strong></p>
+      <div class="code-block">
+        <pre v-pre>
+// JavaScript (Fetch API)
+const response = await fetch('{{API_BASE_URL}}/simple/sso-login', {
+  method: 'POST',
+  headers: {
+    'Authorization': `Bearer ${accessToken}`,
+    'Content-Type': 'application/json'
+  },
+  body: JSON.stringify({
+    app_id: 'oa_system_001'
+  })
+});
+
+const data = await response.json();
+if (data.redirect_url) {
+  // 在新标签页中打开
+  window.open(data.redirect_url, '_blank');
+}
+        </pre>
+      </div>
+
+      <p><strong>场景2: 用户未登录(凭据认证)</strong></p>
+      <div class="code-block">
+        <pre v-pre>
+// JavaScript (Fetch API)
+const response = await fetch('{{API_BASE_URL}}/simple/sso-login', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json'
+  },
+  body: JSON.stringify({
+    app_id: 'oa_system_001',
+    username: '13800138000',
+    password: 'user_password_123'
+  })
+});
+
+const data = await response.json();
+if (data.redirect_url) {
+  window.open(data.redirect_url, '_blank');
+}
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>4. 注意事项</h3>
+      
+      <div class="feature-card">
+        <h4>⚠️ 协议类型限制</h4>
+        <ul>
+          <li><code>GET /simple/me/mappings</code> 接口返回所有类型的应用映射</li>
+          <li><code>POST /simple/sso-login</code> 接口<strong>仅支持 <code>SIMPLE_API</code> 协议类型</strong>的应用</li>
+          <li>对于 <code>OIDC</code> 协议类型的应用,请使用标准的 OIDC 流程进行登录</li>
+        </ul>
+      </div>
+
+      <div class="feature-card">
+        <h4>🔒 安全建议</h4>
+        <ul>
+          <li><strong>Token 安全</strong>: Bearer Token 应妥善保管,避免泄露</li>
+          <li><strong>HTTPS</strong>: 生产环境必须使用 HTTPS 协议</li>
+          <li><strong>Token 过期</strong>: 注意处理 Token 过期的情况,及时刷新或重新登录</li>
+          <li><strong>错误处理</strong>: 建议实现完善的错误处理和重试机制</li>
+        </ul>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>5. 常见问题 (FAQ)</h3>
+      
+      <div class="faq-item">
+        <h4>Q1: 为什么 SSO 登录返回 400 错误,提示"协议不支持"?</h4>
+        <p><strong>A:</strong> <code>POST /simple/sso-login</code> 接口仅支持 <code>SIMPLE_API</code> 协议类型的应用。如果您的应用是 <code>OIDC</code> 类型,请使用标准的 OIDC 认证流程。</p>
+      </div>
+
+      <div class="faq-item">
+        <h4>Q2: 如何判断用户是否已登录 UAP 平台?</h4>
+        <p><strong>A:</strong> 如果您的应用中有有效的 <code>access_token</code>,可以在请求头中携带 <code>Authorization: Bearer {token}</code>。如果 Token 有效,则无需提供 <code>username</code> 和 <code>password</code>。</p>
+      </div>
+
+      <div class="faq-item">
+        <h4>Q3: 账号映射列表中的 <code>mapped_email</code> 为什么可能为空?</h4>
+        <p><strong>A:</strong> 不是所有第三方应用都要求或提供邮箱信息,因此 <code>mapped_email</code> 字段可能为 <code>null</code>。这是正常情况。</p>
+      </div>
+
+      <div class="faq-item">
+        <h4>Q4: SSO 登录后,Ticket 的有效期是多久?</h4>
+        <p><strong>A:</strong> Ticket 的有效期由平台配置决定,通常为 5-10 分钟。建议在获取 <code>redirect_url</code> 后立即跳转,避免 Ticket 过期。</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Download } from '@element-plus/icons-vue'
+import { useHelpDocs } from '../../composables/useHelpDocs'
+
+const { downloadDoc } = useHelpDocs()
+</script>
+
+<style scoped>
+@import './help.css';
+</style>

+ 849 - 5
frontend/src/views/help/MessageIntegration.vue

@@ -2,6 +2,10 @@
   <div class="help-content">
     <div class="content-header">
       <h2>消息中心对接指南</h2>
+      <el-button type="primary" size="small" plain @click="downloadDoc('/docs/message_integration.md', 'Message_Integration_Guide.md')">
+        <el-icon style="margin-right: 5px"><Download /></el-icon>
+        下载 开发文档
+      </el-button>
     </div>
     <p class="intro">统一消息中心支持应用向用户发送系统通知,并支持用户通过 WebSocket 实时接收消息。本指南将指导您如何接入消息发送与推送服务。</p>
 
@@ -14,6 +18,66 @@
       </ul>
     </div>
 
+    <div class="section">
+      <h3>1.1 接口权限说明</h3>
+      <p>消息中心接口支持两种认证方式:用户认证(JWT Token)和应用认证(签名验证)。不同接口的权限如下:</p>
+      <table class="param-table">
+        <thead>
+          <tr><th>接口</th><th>用户权限</th><th>应用权限</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><code>POST /messages/</code></td>
+            <td>✅ 仅 MESSAGE</td>
+            <td>✅ MESSAGE + NOTIFICATION</td>
+            <td>用户只能发私信,应用可发通知</td>
+          </tr>
+          <tr>
+            <td><code>GET /messages/conversations</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可查询</td>
+          </tr>
+          <tr>
+            <td><code>GET /messages/history/{id}</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可查询</td>
+          </tr>
+          <tr>
+            <td><code>GET /messages/unread-count</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可查询</td>
+          </tr>
+          <tr>
+            <td><code>PUT /messages/{id}/read</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可操作</td>
+          </tr>
+          <tr>
+            <td><code>PUT /messages/read-all</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可操作</td>
+          </tr>
+          <tr>
+            <td><code>DELETE /messages/{id}</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可操作</td>
+          </tr>
+          <tr>
+            <td><code>POST /messages/upload</code></td>
+            <td>✅</td>
+            <td>✅</td>
+            <td>用户和应用都可上传</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+
     <div class="section">
       <h3>2. 用户登录认证 (Auth)</h3>
       <p>在对接消息中心之前,客户端(如 WebSocket)通常需要获取用户的访问令牌 (Token)。</p>
@@ -134,17 +198,504 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
 }
         </pre>
       </div>
+
+      <h4>3.3 如何获取接收者ID (receiver_id) 和应用ID (app_id)</h4>
+      <p>在实际开发中,通常不会直接记住用户ID和应用ID,而是通过查询接口先找到对应对象,再取出其 ID。</p>
+
+      <h5>3.3.1 通过用户查询接口获取 receiver_id</h5>
+      <p>可通过用户搜索接口按手机号 / 姓名 / 英文名查询用户,然后从结果中读取 <code>id</code> 作为 <code>receiver_id</code>:</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/users/search?q=关键词</code></li>
+        <li><strong>说明</strong>: 仅返回未删除、状态为 <code>ACTIVE</code> 的用户,并自动排除当前操作者本人。</li>
+      </ul>
+      <div class="code-block">
+        <pre>
+// 1. 先搜索用户 (按手机号 / 姓名 / 英文名)
+GET /api/v1/users/search?q=13800138000
+
+// 2. 响应示例 (节选)
+[
+  {
+    "id": 2048,
+    "mobile": "13800138000",
+    "name": "张三",
+    "english_name": "zhangsan"
+  }
+]
+
+// 3. 发送消息时使用 id 作为 receiver_id
+{
+  "receiver_id": 2048,
+  "type": "MESSAGE",
+  "content_type": "TEXT",
+  "title": "私信",
+  "content": "你好"
+}
+        </pre>
+      </div>
+
+      <h5>3.3.2 分页获取联系人列表</h5>
+      <p>如果需要分页获取联系人列表(如消息中心选择联系人),可以使用以下接口:</p>
+
+      <p><strong>接口1:用户搜索接口(推荐用于普通用户)</strong></p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/users/search?q=关键词&limit=数量</code></li>
+        <li><strong>权限</strong>: 所有登录用户可用</li>
+        <li><strong>特点</strong>:
+          <ul>
+            <li>支持关键词搜索(手机号、姓名、英文名)</li>
+            <li>有 <code>limit</code> 参数(默认20,可调整)</li>
+            <li><strong>不支持 <code>skip</code> 参数</strong>,无法跳过前面的记录</li>
+            <li>只返回活跃用户(status == "ACTIVE")</li>
+            <li>自动排除当前用户自己</li>
+          </ul>
+        </li>
+      </ul>
+      <div class="code-block">
+        <pre>
+// 搜索用户(不支持真正的分页)
+GET /api/v1/users/search?q=张三&limit=50
+Authorization: Bearer xxx
+
+// 响应示例
+[
+  {
+    "id": 2048,
+    "mobile": "13800138000",
+    "name": "张三",
+    "english_name": "zhangsan"
+  }
+]
+        </pre>
+      </div>
+
+      <p><strong>接口2:用户列表接口(完整分页,需管理员权限)</strong></p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/users/?skip=偏移量&limit=数量&keyword=关键词&status=状态</code></li>
+        <li><strong>权限</strong>: <strong>仅超级管理员可用</strong>(普通用户会返回 403)</li>
+        <li><strong>特点</strong>:
+          <ul>
+            <li>支持完整分页(<code>skip</code> 和 <code>limit</code>)</li>
+            <li>支持多种筛选条件(status, role, mobile, name, english_name, keyword)</li>
+            <li>返回格式:<code>{"total": 总数, "items": [用户列表]}</code></li>
+          </ul>
+        </li>
+      </ul>
+      <div class="code-block">
+        <pre>
+// 分页获取用户列表(需要超级管理员权限)
+GET /api/v1/users/?skip=0&limit=20&keyword=张三&status=ACTIVE
+Authorization: Bearer xxx
+
+// 响应示例
+{
+  "total": 100,
+  "items": [
+    {
+      "id": 2048,
+      "mobile": "13800138000",
+      "name": "张三",
+      "english_name": "zhangsan",
+      "status": "ACTIVE"
+    }
+  ]
+}
+        </pre>
+      </div>
+
+      <p><strong>使用建议:</strong></p>
+      <ul>
+        <li><strong>普通用户场景</strong>:使用 <code>GET /users/search</code> 接口,通过 <code>limit</code> 参数控制返回数量(建议设置为 50-100)</li>
+        <li><strong>管理员场景</strong>:使用 <code>GET /users/</code> 接口,支持完整的分页功能</li>
+      </ul>
+
+      <p><strong>JavaScript 示例:</strong></p>
+      <div class="code-block">
+        <pre>
+// 方案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
+  }
+}
+        </pre>
+      </div>
+
+      <h5>3.3.3 通过应用列表获取 app_id</h5>
+      <p>如果是“用户调用 + 使用 <code>app_user_id</code>”的方式发送消息,需要在 Body 中同时提供 <code>app_id</code>,可以通过应用列表接口查询:</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/apps/?search=关键字</code></li>
+        <li><strong>说明</strong>: 支持按应用名称 / <code>app_id</code> 模糊搜索,返回结构中既包含内部自增主键 <code>id</code>,也包含对外使用的 <code>app_id</code> 字段。</li>
+      </ul>
+      <div class="code-block">
+        <pre>
+// 查询包含“OA”的应用
+GET /api/v1/apps/?search=OA
+
+// 响应示例 (节选)
+{
+  "total": 1,
+  "items": [
+    {
+      "id": 101,              // 数据库主键 (消息表中的 app_id 对应此字段)
+      "app_id": "oa_system",  // 对外展示的应用ID (如开放接口使用)
+      "app_name": "OA系统"
+    }
+  ]
+}
+
+// 用户以 app_user_id 方式发送消息时示例
+{
+  "app_id": 101,              // 使用 items[0].id
+  "app_user_id": "zhangsan_oa",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "OA审批提醒",
+  "content": "您有一条新的报销单待审批"
+}
+        </pre>
+      </div>
+
+      <h5>3.3.4 应用自调用时的 app_id 行为说明</h5>
+      <ul>
+        <li><strong>应用通过签名调用接口时</strong>:系统会自动根据 <code>X-App-Id</code> 解析出当前应用,并将 <code>message_in.app_id</code> 强制设置为该应用的内部ID,Body 中传入的 <code>app_id</code> 会被忽略。</li>
+        <li><strong>用户调用并使用 <code>app_user_id</code> 时</strong>:<code>app_id</code> 必须在 Body 中显式给出,用于从 <code>app_user_mapping</code> 表中解析真实用户。</li>
+      </ul>
+    </div>
+
+    <div class="section">
+      <h3>4. 消息查询接口</h3>
+      <p>用户端通过以下接口查询和管理消息。</p>
+
+      <h4>4.1 获取会话列表</h4>
+      <p>获取当前用户的所有会话(类似微信首页的会话列表)。</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/messages/conversations</code></li>
+        <li><strong>认证方式</strong>: <code>Authorization: Bearer &lt;JWT_TOKEN&gt;</code></li>
+        <li><strong>权限</strong>: 仅用户可调用</li>
+      </ul>
+      <p><strong>响应示例:</strong></p>
+      <div class="code-block">
+        <pre>
+[
+  {
+    "user_id": 0,
+    "username": "System",
+    "full_name": "系统通知",
+    "unread_count": 5,
+    "last_message": "您的密码已重置",
+    "last_message_type": "TEXT",
+    "updated_at": "2026-02-23T10:05:00"
+  },
+  {
+    "user_id": 102,
+    "username": "13800138000",
+    "full_name": "李四",
+    "unread_count": 0,
+    "last_message": "[IMAGE]",
+    "last_message_type": "IMAGE",
+    "updated_at": "2026-02-22T18:30:00"
+  }
+]
+        </pre>
+      </div>
+      <p><strong>说明:</strong></p>
+      <ul>
+        <li><code>user_id: 0</code> 表示系统通知会话</li>
+        <li><code>unread_count</code> 表示该会话的未读消息数</li>
+        <li><code>last_message</code> 显示最后一条消息内容(多媒体类型显示为 <code>[TYPE]</code>)</li>
+      </ul>
+
+      <h4>4.2 获取聊天历史记录</h4>
+      <p>获取与特定用户的聊天记录(支持分页)。</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/messages/history/{other_user_id}</code></li>
+        <li><strong>路径参数</strong>: <code>other_user_id</code> - 对方用户ID(0 表示系统通知)</li>
+        <li><strong>查询参数</strong>:
+          <ul>
+            <li><code>skip</code>: 分页偏移(默认 0)</li>
+            <li><code>limit</code>: 每页条数(默认 50)</li>
+          </ul>
+        </li>
+      </ul>
+      <p><strong>请求示例:</strong></p>
+      <div class="code-block">
+        <pre>
+GET /api/v1/messages/history/123?skip=0&limit=50
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+        </pre>
+      </div>
+      <p><strong>响应示例:</strong></p>
+      <div class="code-block">
+        <pre>
+[
+  {
+    "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"
+  }
+]
+        </pre>
+      </div>
+
+      <h4>4.3 获取未读消息数</h4>
+      <p>获取当前用户的总未读消息数。</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/messages/unread-count</code></li>
+        <li><strong>响应</strong>: 返回数字,表示未读消息总数</li>
+      </ul>
+      <p><strong>响应示例:</strong></p>
+      <div class="code-block">
+        <pre>
+5
+        </pre>
+      </div>
+
+      <h4>4.4 获取消息列表</h4>
+      <p>获取当前用户的所有消息列表(支持分页和筛选)。</p>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/messages/</code></li>
+        <li><strong>查询参数</strong>:
+          <ul>
+            <li><code>skip</code>: 分页偏移(默认 0)</li>
+            <li><code>limit</code>: 每页条数(默认 100)</li>
+            <li><code>unread_only</code>: 是否只获取未读消息(默认 false)</li>
+          </ul>
+        </li>
+      </ul>
+      <p><strong>请求示例:</strong></p>
+      <div class="code-block">
+        <pre>
+GET /api/v1/messages/?skip=0&limit=100&unread_only=false
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+        </pre>
+      </div>
     </div>
 
     <div class="section">
-      <h3>4. WebSocket 实时接入</h3>
+      <h3>5. 消息状态管理接口</h3>
+      <p>用于标记消息已读、删除消息等操作。</p>
+
+      <h4>5.1 标记单条消息已读</h4>
+      <ul>
+        <li><strong>接口地址</strong>: <code>PUT /api/v1/messages/{message_id}/read</code></li>
+        <li><strong>路径参数</strong>: <code>message_id</code> - 消息ID</li>
+        <li><strong>权限</strong>: 只能标记自己接收的消息为已读</li>
+      </ul>
+      <p><strong>请求示例:</strong></p>
+      <div class="code-block">
+        <pre>
+PUT /api/v1/messages/501/read
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+        </pre>
+      </div>
+      <p><strong>批量标记已读示例 (JavaScript):</strong></p>
+      <div class="code-block">
+        <pre>
+// 获取未读消息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`))
+)
+        </pre>
+      </div>
+
+      <h4>5.2 标记全部消息已读</h4>
+      <ul>
+        <li><strong>接口地址</strong>: <code>PUT /api/v1/messages/read-all</code></li>
+        <li><strong>响应</strong>: 返回更新的消息数量</li>
+      </ul>
+      <p><strong>请求示例:</strong></p>
+      <div class="code-block">
+        <pre>
+PUT /api/v1/messages/read-all
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+
+// 响应
+{
+  "updated_count": 10
+}
+        </pre>
+      </div>
+
+      <h4>5.3 删除消息</h4>
+      <ul>
+        <li><strong>接口地址</strong>: <code>DELETE /api/v1/messages/{message_id}</code></li>
+        <li><strong>路径参数</strong>: <code>message_id</code> - 消息ID</li>
+        <li><strong>权限</strong>: 只能删除自己接收的消息</li>
+      </ul>
+      <p><strong>请求示例:</strong></p>
+      <div class="code-block">
+        <pre>
+DELETE /api/v1/messages/501
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>6. 文件上传接口</h3>
+      <p>用于上传图片、视频、文档等附件,上传成功后可用于发送多媒体消息。</p>
+      
+      <ul>
+        <li><strong>接口地址</strong>: <code>POST /api/v1/messages/upload</code></li>
+        <li><strong>Content-Type</strong>: <code>multipart/form-data</code></li>
+        <li><strong>认证方式</strong>: <code>Authorization: Bearer &lt;JWT_TOKEN&gt;</code></li>
+        <li><strong>权限</strong>: 用户和应用都可调用</li>
+      </ul>
+
+      <p><strong>请求参数:</strong></p>
+      <table class="param-table">
+        <thead>
+          <tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr><td><code>file</code></td><td>File</td><td>是</td><td>上传的文件(支持图片、视频、文档等)</td></tr>
+        </tbody>
+      </table>
+
+      <p><strong>文件限制:</strong></p>
+      <ul>
+        <li>最大文件大小: 50MB</li>
+        <li>支持的文件类型: JPEG, PNG, GIF, WebP, MP4, PDF, DOC, DOCX, XLS, XLSX, TXT 等</li>
+      </ul>
+
+      <p><strong>请求示例 (JavaScript):</strong></p>
+      <div class="code-block">
+        <pre>
+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
+}
+        </pre>
+      </div>
+
+      <p><strong>上传后发送消息示例:</strong></p>
+      <div class="code-block">
+        <pre>
+// 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,  // 使用返回的 key
+  type: 'MESSAGE',
+  content_type: 'IMAGE',
+  title: '图片'
+}
+
+await api.post('/messages/', payload)
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>7. WebSocket 实时接入</h3>
       <p>前端客户端通过 WebSocket 连接接收实时推送。</p>
       
       <ul>
         <li><strong>连接地址</strong>: <code>ws://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN</code></li>
-        <li><strong>心跳机制</strong>: 客户端发送 ping,服务端回复 pong。</li>
+        <li><strong>HTTPS 环境</strong>: <code>wss://YOUR_DOMAIN/api/v1/ws/messages?token=JWT_TOKEN</code></li>
+        <li><strong>心跳机制</strong>: 客户端每 30 秒发送 <code>ping</code>,服务端回复 <code>pong</code></li>
+        <li><strong>断线重连</strong>: 建议客户端实现自动重连机制</li>
       </ul>
 
+      <p><strong>连接示例 (JavaScript):</strong></p>
+      <div class="code-block">
+        <pre>
+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)
+}
+        </pre>
+      </div>
+
       <p><strong>推送消息格式 (Server -> Client):</strong></p>
       <div class="code-block">
         <pre>
@@ -152,19 +703,309 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
   "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=101&redirect_to=...", 
+    "action_url": "http://api.com/sso/jump?app_id=101&redirect_to=...",
+    "action_text": "立即处理",
     "created_at": "2026-02-25T10:00:00"
   }
 }
         </pre>
       </div>
+
+      <p><strong>消息字段说明:</strong></p>
+      <table class="param-table">
+        <thead>
+          <tr><th>字段</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr><td><code>id</code></td><td>消息ID</td></tr>
+          <tr><td><code>sender_id</code></td><td>发送者ID(null 表示系统通知)</td></tr>
+          <tr><td><code>type</code></td><td>消息类型:MESSAGE(私信)或 NOTIFICATION(通知)</td></tr>
+          <tr><td><code>content_type</code></td><td>内容类型:TEXT, IMAGE, VIDEO, FILE</td></tr>
+          <tr><td><code>title</code></td><td>消息标题</td></tr>
+          <tr><td><code>content</code></td><td>消息内容(多媒体类型为预签名URL)</td></tr>
+          <tr><td><code>action_url</code></td><td>跳转链接(通知类型通常包含SSO跳转)</td></tr>
+          <tr><td><code>action_text</code></td><td>跳转按钮文案</td></tr>
+          <tr><td><code>receiver_id</code></td><td>接收者ID(前端用于判断消息归属)</td></tr>
+        </tbody>
+      </table>
+
+      <h4>7.1 WebSocket 连接建立与用户识别机制</h4>
+      <p><strong>服务端识别用户的过程:</strong></p>
+      <ol>
+        <li><strong>客户端连接时携带 JWT Token</strong>
+          <div class="code-block">
+            <pre>ws://host/api/v1/ws/messages?token=JWT_TOKEN</pre>
+          </div>
+        </li>
+        <li><strong>服务端验证 Token 并解析用户ID</strong>
+          <ul>
+            <li>服务端从 Token 中解析出 <code>user_id</code>(当前登录用户的ID)</li>
+            <li>验证用户是否存在且有效</li>
+          </ul>
+        </li>
+        <li><strong>将 WebSocket 连接与用户ID关联存储</strong>
+          <ul>
+            <li>服务端使用 <code>ConnectionManager</code> 管理连接</li>
+            <li>存储格式:<code>{user_id: [WebSocket1, WebSocket2, ...]}</code></li>
+            <li>一个用户可以有多个设备同时在线(手机、电脑、平板等)</li>
+          </ul>
+        </li>
+      </ol>
+
+      <h4>7.2 消息推送机制</h4>
+      <p><strong>服务端如何知道推送给哪个用户:</strong></p>
+      <ol>
+        <li><strong>消息创建时确定接收者</strong>:消息保存到数据库时,<code>receiver_id</code> 字段记录了接收者的用户ID</li>
+        <li><strong>根据 receiver_id 查找连接</strong>:服务端使用 <code>receiver_id</code> 作为 key,从 <code>active_connections</code> 中查找该用户的所有在线连接</li>
+        <li><strong>向所有在线设备推送</strong>:如果用户有多个设备在线,所有设备都会收到消息</li>
+      </ol>
+
+      <p><strong>关键点:</strong></p>
+      <ul>
+        <li><strong>连接时</strong>:通过 Token 解析出当前用户的 <code>user_id</code>,将连接存储到 <code>active_connections[user_id]</code></li>
+        <li><strong>推送时</strong>:使用消息的 <code>receiver_id</code> 作为 key,从 <code>active_connections[receiver_id]</code> 中查找连接并推送</li>
+        <li><strong>多设备支持</strong>:一个用户多个设备在线时,所有设备都会收到消息</li>
+      </ul>
+
+      <h4>7.3 前端接收消息与更新聊天窗口</h4>
+      <p><strong>前端如何判断消息是否属于当前用户:</strong></p>
+      <p>前端通过 WebSocket 接收到的消息中,服务端已经根据 <code>receiver_id</code> 进行了路由,所以<strong>收到的消息都是发给当前用户的</strong>。前端需要判断的是:</p>
+      <ol>
+        <li>消息是否属于当前打开的聊天窗口</li>
+        <li>消息是别人发给我的,还是我自己从其他设备发送的</li>
+      </ol>
+
+      <p><strong>消息处理逻辑:</strong></p>
+      <div class="code-block">
+        <pre>
+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
+    }
+  }
+}
+        </pre>
+      </div>
+
+      <p><strong>更新会话列表:</strong></p>
+      <div class="code-block">
+        <pre>
+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()
+  }
+}
+        </pre>
+      </div>
+
+      <h4>7.4 完整流程图</h4>
+      <div class="code-block">
+        <pre>
+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
+6. 消息保存到数据库 → receiver_id = A
+7. 后台任务推送 → manager.send_personal_message(payload, receiver_id=A)
+8. 查找连接 → active_connections[A] 找到用户A的所有连接
+9. 推送消息 → 用户A的所有设备都收到消息
+
+10. 前端接收消息
+11. 判断消息类型和当前窗口
+12. 更新消息列表(如果当前窗口)
+13. 更新会话列表(最后一条消息、未读数)
+14. 滚动到底部(如果当前窗口)
+        </pre>
+      </div>
+
+      <h4>7.5 关键设计点总结</h4>
+      <ul>
+        <li><strong>用户识别</strong>:连接时通过 JWT Token 解析 <code>user_id</code>,推送时使用 <code>receiver_id</code> 查找连接</li>
+        <li><strong>多设备支持</strong>:一个 <code>user_id</code> 可以对应多个 WebSocket 连接,所有设备都会收到消息</li>
+        <li><strong>消息路由</strong>:服务端根据 <code>receiver_id</code> 自动路由到正确的用户,前端只需判断是否属于当前窗口</li>
+        <li><strong>实时更新</strong>:收到消息后自动更新消息列表、会话列表、未读数,无需手动刷新</li>
+      </ul>
     </div>
 
     <div class="section">
-      <h3>5. 调用示例 (Python)</h3>
+      <h3>8. 前端完整调用示例</h3>
+      <p>以下示例展示前端如何完整地使用消息中心功能。</p>
+      
+      <h4>8.1 初始化消息中心</h4>
+      <div class="code-block">
+        <pre>
+// 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)
+  }
+}
+        </pre>
+      </div>
+
+      <h4>8.2 选择会话并加载历史消息</h4>
+      <div class="code-block">
+        <pre>
+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)
+  }
+}
+        </pre>
+      </div>
+
+      <h4>8.3 发送文本消息</h4>
+      <div class="code-block">
+        <pre>
+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('发送失败')
+  }
+}
+        </pre>
+      </div>
+
+      <h4>8.4 上传文件并发送</h4>
+      <div class="code-block">
+        <pre>
+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,  // 使用返回的 key
+      type: 'MESSAGE',
+      content_type: 'IMAGE',  // 根据文件类型设置:IMAGE, VIDEO, FILE
+      title: '图片'
+    }
+    
+    const res = await api.post('/messages/', payload)
+    messages.value.push(res.data)
+  } catch (e) {
+    ElMessage.error('上传失败')
+  }
+}
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>9. 调用示例 (Python)</h3>
       <p>以下示例展示如何使用 Python 发送通知。</p>
       <div class="code-block">
         <pre>
@@ -228,7 +1069,10 @@ print(resp.json())
 </template>
 
 <script setup lang="ts">
-// No specific logic needed for now
+import { Download } from '@element-plus/icons-vue'
+import { useHelpDocs } from '../../composables/useHelpDocs'
+
+const { downloadDoc } = useHelpDocs()
 </script>
 
 <style scoped>

+ 347 - 0
frontend/src/views/help/MinIOFilePermissions.vue

@@ -0,0 +1,347 @@
+<template>
+  <div class="help-content">
+    <div class="content-header">
+      <h2>消息文件存储与权限控制</h2>
+      <el-button type="primary" size="small" plain @click="downloadDoc('/docs/minio_file_permissions_guide.md', 'MinIO_File_Permissions_Guide.md')">
+        <el-icon style="margin-right: 5px"><Download /></el-icon>
+        下载 开发文档
+      </el-button>
+    </div>
+    <p class="intro">统一消息平台使用 MinIO 对象存储服务来管理聊天记录中的文件附件(图片、视频、文档等)。为了确保文件安全,系统采用双重权限控制机制:数据库层权限控制和存储层权限控制(预签名 URL)。</p>
+
+    <div class="section">
+      <h3>1. 概述</h3>
+      <p>统一消息平台使用 MinIO 对象存储服务来管理聊天记录中的文件附件(图片、视频、文档等)。为了确保文件安全,系统采用<strong>双重权限控制机制</strong>:</p>
+      <ul>
+        <li><strong>数据库层权限控制</strong>:用户只能查询自己相关的消息</li>
+        <li><strong>存储层权限控制</strong>:通过预签名 URL(Presigned URL)实现临时访问授权</li>
+      </ul>
+
+      <h4>核心特性</h4>
+      <ul>
+        <li>✅ <strong>私有存储</strong>:所有文件存储在私有 Bucket 中,无法直接访问</li>
+        <li>✅ <strong>权限隔离</strong>:只有有权限查看消息的用户才能获取文件访问链接</li>
+        <li>✅ <strong>时效控制</strong>:预签名 URL 默认 1 小时有效,过期后需重新获取</li>
+        <li>✅ <strong>路径隔离</strong>:文件路径包含用户 ID,便于后续扩展细粒度权限</li>
+      </ul>
+    </div>
+
+    <div class="section">
+      <h3>2. 权限控制架构</h3>
+      
+      <h4>2.1 存储层设计</h4>
+      <p>文件路径格式:<code>messages/{user_id}/{year}/{month}/{uuid}.{ext}</code></p>
+      <p><strong>示例</strong>:<code>messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg</code></p>
+
+      <h4>2.2 权限控制流程</h4>
+      <div class="code-block">
+        <pre>
+1. 用户上传文件 (需认证)
+   ↓
+2. 存储到私有Bucket
+   ↓
+3. 返回object_key
+   ↓
+4. 保存消息记录(content=object_key)
+   ↓
+5. 返回预签名URL(1小时有效)
+
+6. 用户查询消息列表 (需认证)
+   ↓
+7. 权限过滤(仅查询自己的消息)
+   ↓
+8. 返回消息列表
+   ↓
+9. 为文件类型生成预签名URL
+   ↓
+10. 返回消息(含签名URL)
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>3. 开发者使用指南</h3>
+
+      <h4>3.1 文件上传</h4>
+      <p><strong>接口说明</strong>:</p>
+      <ul>
+        <li><strong>Endpoint</strong>: <code>POST /api/v1/messages/upload</code></li>
+        <li><strong>认证方式</strong>: Bearer Token (用户认证) 或 应用签名认证</li>
+        <li><strong>Content-Type</strong>: <code>multipart/form-data</code></li>
+      </ul>
+
+      <p><strong>请求示例</strong>:</p>
+      <div class="code-block">
+        <pre>
+curl -X POST "{{API_BASE_URL}}/messages/upload" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -F "file=@/path/to/image.jpg"
+        </pre>
+      </div>
+
+      <p><strong>响应示例</strong>:</p>
+      <div class="code-block">
+        <pre>
+{
+  "url": "https://minio.example.com/bucket/messages/123/2026/01/uuid.jpg?X-Amz-Algorithm=...&X-Amz-Expires=3600",
+  "key": "messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg",
+  "filename": "image.jpg",
+  "content_type": "image/jpeg",
+  "size": 102400
+}
+        </pre>
+      </div>
+
+      <p><strong>字段说明</strong>:</p>
+      <table class="param-table">
+        <thead>
+          <tr><th>字段</th><th>类型</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr><td><code>url</code></td><td>string</td><td>预签名访问 URL(1小时有效)</td></tr>
+          <tr><td><code>key</code></td><td>string</td><td>MinIO 对象键(object_key),用于存储到消息表</td></tr>
+          <tr><td><code>filename</code></td><td>string</td><td>原始文件名</td></tr>
+          <tr><td><code>content_type</code></td><td>string</td><td>MIME 类型</td></tr>
+          <tr><td><code>size</code></td><td>int</td><td>文件大小(字节)</td></tr>
+        </tbody>
+      </table>
+
+      <p><strong>前端示例 (JavaScript)</strong>:</p>
+      <div class="code-block">
+        <pre>
+async function uploadFile(file) {
+  const formData = new FormData();
+  formData.append('file', file);
+
+  const response = await fetch('/api/v1/messages/upload', {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${token}`
+    },
+    body: formData
+  });
+
+  const result = await response.json();
+  
+  // 使用 result.key 发送消息
+  return result;
+}
+        </pre>
+      </div>
+
+      <p><strong>文件类型限制</strong>:</p>
+      <ul>
+        <li><strong>图片</strong>: <code>image/jpeg</code>, <code>image/png</code>, <code>image/gif</code>, <code>image/webp</code></li>
+        <li><strong>视频</strong>: <code>video/mp4</code>, <code>video/quicktime</code>, <code>video/x-msvideo</code></li>
+        <li><strong>文档</strong>: <code>application/pdf</code>, <code>application/msword</code>, <code>application/vnd.openxmlformats-officedocument.*</code>, <code>text/plain</code></li>
+      </ul>
+      <p><strong>文件大小限制</strong>: 最大 50MB</p>
+
+      <h4>3.2 发送带文件的消息</h4>
+      <p>上传文件后,使用返回的 <code>key</code> 作为消息内容发送。</p>
+      <div class="code-block">
+        <pre>
+curl -X POST "{{API_BASE_URL}}/messages/" \
+  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "receiver_id": 456,
+    "type": "MESSAGE",
+    "content_type": "IMAGE",
+    "title": "分享图片",
+    "content": "messages/123/2026/01/a1b2c3d4-e5f6-7890-abcd-ef1234567890.jpg"
+  }'
+        </pre>
+      </div>
+      <p><strong>重要提示</strong>:</p>
+      <ul>
+        <li><code>content</code> 字段应存储 <code>key</code>(object_key),而不是完整的 URL</li>
+        <li>系统会在查询消息时自动为文件类型生成预签名 URL</li>
+      </ul>
+
+      <h4>3.3 查询消息(自动生成文件访问链接)</h4>
+      <p><strong>接口说明</strong>:</p>
+      <ul>
+        <li><strong>Endpoint</strong>: <code>GET /api/v1/messages/</code></li>
+        <li><strong>认证方式</strong>: Bearer Token(仅用户认证)</li>
+        <li><strong>权限</strong>: 用户只能查询自己作为接收者的消息</li>
+      </ul>
+
+      <p><strong>权限控制说明</strong>:</p>
+      <ol>
+        <li><strong>数据库层过滤</strong>:API 自动过滤,只返回 <code>receiver_id == current_user.id</code> 的消息</li>
+        <li><strong>自动签名</strong>:对于 <code>content_type</code> 为 <code>IMAGE</code>、<code>VIDEO</code>、<code>FILE</code> 的消息,系统自动将 <code>content</code> 字段中的 <code>key</code> 转换为预签名 URL</li>
+        <li><strong>URL 时效</strong>:预签名 URL 默认 1 小时有效</li>
+      </ol>
+
+      <p><strong>前端处理示例</strong>:</p>
+      <div class="code-block">
+        <pre>
+async function fetchMessages() {
+  const response = await fetch('/api/v1/messages/', {
+    headers: {
+      'Authorization': `Bearer ${token}`
+    }
+  });
+  
+  const messages = await response.json();
+  
+  messages.forEach(msg => {
+    if (['IMAGE', 'VIDEO', 'FILE'].includes(msg.content_type)) {
+      // content 已经是预签名 URL,直接使用
+      console.log('文件URL:', msg.content);
+    }
+  });
+  
+  return messages;
+}
+        </pre>
+      </div>
+    </div>
+
+    <div class="section">
+      <h3>4. 预签名 URL 机制详解</h3>
+
+      <h4>4.1 什么是预签名 URL?</h4>
+      <p>预签名 URL(Presigned URL)是 MinIO/S3 提供的一种临时访问授权机制。它允许在不需要公开 Bucket 的情况下,为特定对象生成一个有时效性的访问链接。</p>
+
+      <h4>4.2 生成时机</h4>
+      <p>预签名 URL 在以下场景自动生成:</p>
+      <ol>
+        <li><strong>文件上传后</strong>:上传接口返回的 <code>url</code> 字段</li>
+        <li><strong>查询消息时</strong>:<code>_process_message_content()</code> 函数自动处理</li>
+        <li><strong>WebSocket 推送时</strong>:实时消息推送中的文件内容</li>
+      </ol>
+
+      <h4>4.3 URL 有效期</h4>
+      <ul>
+        <li><strong>默认有效期</strong>: 1 小时(3600 秒)</li>
+        <li><strong>过期处理</strong>: URL 过期后,前端需要重新调用消息查询接口获取新的预签名 URL</li>
+      </ul>
+    </div>
+
+    <div class="section">
+      <h3>5. 安全最佳实践</h3>
+
+      <h4>5.1 前端开发建议</h4>
+      <ol>
+        <li><strong>不要缓存预签名 URL</strong>
+          <ul>
+            <li>URL 有时效性,建议每次显示消息时重新获取</li>
+            <li>如果 URL 过期,重新调用消息查询接口</li>
+          </ul>
+        </li>
+        <li><strong>错误处理</strong>
+          <div class="code-block">
+            <pre>
+async function loadImage(url) {
+  try {
+    const response = await fetch(url);
+    if (!response.ok) {
+      // URL 可能已过期,重新获取消息
+      await refreshMessages();
+    }
+    return response.blob();
+  } catch (error) {
+    console.error('加载图片失败:', error);
+  }
+}
+            </pre>
+          </div>
+        </li>
+      </ol>
+
+      <h4>5.2 后端开发建议</h4>
+      <ol>
+        <li><strong>不要直接暴露 object_key</strong>
+          <ul>
+            <li>消息表中的 <code>content</code> 字段存储的是 <code>key</code>,不是完整 URL</li>
+            <li>只有在有权限查看消息时才生成预签名 URL</li>
+          </ul>
+        </li>
+        <li><strong>权限验证</strong>
+          <ul>
+            <li>所有消息查询接口都已内置权限验证</li>
+            <li>不要绕过权限检查直接生成预签名 URL</li>
+          </ul>
+        </li>
+      </ol>
+    </div>
+
+    <div class="section">
+      <h3>6. 常见问题 (FAQ)</h3>
+
+      <h4>Q1: 为什么上传文件后返回的 URL 有时效性?</h4>
+      <p><strong>A</strong>: 为了安全考虑,所有文件访问都通过预签名 URL。即使 URL 过期,用户仍可以通过查询消息接口重新获取有效的 URL。</p>
+
+      <h4>Q2: 如何延长文件访问 URL 的有效期?</h4>
+      <p><strong>A</strong>: 目前默认 1 小时有效期。如需调整,可以修改 <code>backend/app/core/minio.py</code> 中的 <code>get_presigned_url()</code> 方法的 <code>expires</code> 参数。</p>
+      <div class="code-block">
+        <pre>
+# 修改为 24 小时
+presigned_url = minio_storage.get_presigned_url(object_name, expires=timedelta(hours=24))
+        </pre>
+      </div>
+
+      <h4>Q3: 用户能否直接访问 MinIO 中的文件?</h4>
+      <p><strong>A</strong>: 不能。Bucket 设置为私有模式,所有文件必须通过预签名 URL 访问。即使知道文件路径,没有有效的预签名 URL 也无法访问。</p>
+
+      <h4>Q4: 如何实现文件下载功能?</h4>
+      <p><strong>A</strong>: 预签名 URL 支持直接下载。可以在前端添加下载按钮:</p>
+      <div class="code-block">
+        <pre>
+function downloadFile(url, filename) {
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  a.click();
+}
+        </pre>
+      </div>
+
+      <h4>Q5: 预签名 URL 过期后如何处理?</h4>
+      <p><strong>A</strong>: 前端检测到 URL 过期(HTTP 403/404)时,重新调用消息查询接口获取新的预签名 URL。</p>
+
+      <h4>Q6: 能否为其他用户生成文件访问链接?</h4>
+      <p><strong>A</strong>: 不能。系统会验证消息权限,只有有权限查看消息的用户才能获取文件访问链接。这确保了文件访问的安全性。</p>
+
+      <h4>Q7: 文件路径中的 user_id 是上传者还是接收者?</h4>
+      <p><strong>A</strong>: 是上传者的 <code>user_id</code>。文件路径格式为 <code>messages/{uploader_user_id}/{year}/{month}/{uuid}.{ext}</code>。</p>
+    </div>
+
+    <div class="section">
+      <h3>7. 技术实现细节</h3>
+
+      <h4>7.1 核心代码位置</h4>
+      <ul>
+        <li><strong>MinIO 存储类</strong>: <code>backend/app/core/minio.py</code></li>
+        <li><strong>文件上传接口</strong>: <code>backend/app/api/v1/endpoints/messages_upload.py</code></li>
+        <li><strong>消息查询接口</strong>: <code>backend/app/api/v1/endpoints/messages.py</code></li>
+        <li><strong>URL 生成逻辑</strong>: <code>_process_message_content()</code> 函数</li>
+      </ul>
+
+      <h4>7.2 配置说明</h4>
+      <p>MinIO 相关配置在 <code>backend/app/core/config.py</code> 中:</p>
+      <div class="code-block">
+        <pre>
+MINIO_ENDPOINT: str = "https://api.hnyunzhu.com:9004"
+MINIO_ACCESS_KEY: str = "your_access_key"
+MINIO_SECRET_KEY: str = "your_secret_key"
+MINIO_BUCKET_NAME: str = "unified-message-files"
+MINIO_SECURE: bool = False
+        </pre>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Download } from '@element-plus/icons-vue'
+import { useHelpDocs } from '../../composables/useHelpDocs'
+
+const { downloadDoc } = useHelpDocs()
+</script>
+
+<style scoped>
+@import './help.css';
+</style>