Parcourir la source

广播接口开发

liuq il y a 1 mois
Parent
commit
7025d06b1e

+ 108 - 43
backend/app/api/v1/endpoints/messages.py

@@ -89,8 +89,12 @@ async def create_message(
 
     # 2. 确定接收者 (Receiver Resolution)
     final_receiver_id = None
+    is_broadcast = getattr(message_in, "is_broadcast", False)
 
-    if message_in.receiver_id:
+    if is_broadcast:
+        if message_in.type != MessageType.NOTIFICATION:
+            raise HTTPException(status_code=403, detail="广播模式仅支持系统通知 (NOTIFICATION)")
+    elif message_in.receiver_id:
         # 方式 A: 直接指定 User ID
         final_receiver_id = message_in.receiver_id
         user = db.query(User).filter(User.id == final_receiver_id).first()
@@ -122,7 +126,7 @@ async def create_message(
             )
         final_receiver_id = mapping.user_id
     else:
-        raise HTTPException(status_code=400, detail="必须指定 receiver_id 或 (app_id + app_user_id)")
+        raise HTTPException(status_code=400, detail="必须指定 receiver_id 或 (app_id + app_user_id),或者设置 is_broadcast=True")
 
     # 3. 处理 SSO 跳转链接 (Link Generation)
     final_action_url = message_in.action_url
@@ -152,49 +156,110 @@ async def create_message(
                 pass # 提取失败则保持原样
 
     # 4. 创建消息
-    message = Message(
-        sender_id=sender_id,
-        receiver_id=final_receiver_id,
-        app_id=app_id_int,  # 使用 Integer ID 存储到数据库
-        type=message_in.type,
-        content_type=message_in.content_type,
-        title=message_in.title,
-        content=content_val,
-        action_url=final_action_url,
-        action_text=message_in.action_text
-    )
-    db.add(message)
-    db.commit()
-    db.refresh(message)
-    
-    # 5. 触发实时推送 (WebSocket)
-    # 处理用于推送的消息内容 (签名)
-    processed_msg = _process_message_content(message)
-    
-    push_payload = {
-        "type": "NEW_MESSAGE",
-        "data": {
-            "id": processed_msg.id,
-            "type": processed_msg.type,
-            "content_type": processed_msg.content_type,
-            "title": processed_msg.title,
-            "content": processed_msg.content, # 使用签名后的 URL
-            "action_url": processed_msg.action_url,
-            "action_text": processed_msg.action_text,
-            "sender_name": "系统通知" if not sender_id else "用户私信", # 简化处理
-            "sender_id": sender_id, # Add sender_id for frontend to decide left/right
-            "created_at": str(processed_msg.created_at),
-            # 附加应用信息,便于前端按应用拆分系统通知会话
-            "app_id": message.app_id,
-            "app_name": message.app.app_name if message.app else None,
+    if is_broadcast:
+        # 查找所有活跃用户 (is_deleted=0)
+        active_users = db.query(User.id).filter(
+            User.status == "ACTIVE", 
+            User.is_deleted == 0
+        ).all()
+        
+        if not active_users:
+            raise HTTPException(status_code=400, detail="没有可发送的活跃用户")
+            
+        messages_to_insert = []
+        for u in active_users:
+            messages_to_insert.append(
+                Message(
+                    sender_id=sender_id,
+                    receiver_id=u.id,
+                    app_id=app_id_int,
+                    type=message_in.type,
+                    content_type=message_in.content_type,
+                    title=message_in.title,
+                    content=content_val,
+                    action_url=final_action_url,
+                    action_text=message_in.action_text
+                )
+            )
+            
+        db.add_all(messages_to_insert)
+        db.commit()
+        
+        # 使用第一条消息进行签名作为接口的返回值
+        db.refresh(messages_to_insert[0])
+        processed_msg = _process_message_content(messages_to_insert[0])
+        
+        # 提取推送所需数据,避免在异步任务中触发延迟加载
+        push_tasks = [{"id": msg.id, "receiver_id": msg.receiver_id} for msg in messages_to_insert]
+        
+        async def send_broadcast_ws():
+            for t in push_tasks:
+                push_payload = {
+                    "type": "NEW_MESSAGE",
+                    "data": {
+                        "id": t["id"],
+                        "type": processed_msg.type,
+                        "content_type": processed_msg.content_type,
+                        "title": processed_msg.title,
+                        "content": processed_msg.content,
+                        "action_url": processed_msg.action_url,
+                        "action_text": processed_msg.action_text,
+                        "sender_name": "系统通知" if not sender_id else "用户私信",
+                        "sender_id": sender_id,
+                        "created_at": str(processed_msg.created_at),
+                        "app_id": processed_msg.app_id,
+                        "app_name": processed_msg.app_name,
+                    }
+                }
+                await manager.send_personal_message(push_payload, t["receiver_id"])
+                
+        background_tasks.add_task(send_broadcast_ws)
+        return processed_msg
+        
+    else:
+        message = Message(
+            sender_id=sender_id,
+            receiver_id=final_receiver_id,
+            app_id=app_id_int,  # 使用 Integer ID 存储到数据库
+            type=message_in.type,
+            content_type=message_in.content_type,
+            title=message_in.title,
+            content=content_val,
+            action_url=final_action_url,
+            action_text=message_in.action_text
+        )
+        db.add(message)
+        db.commit()
+        db.refresh(message)
+        
+        # 5. 触发实时推送 (WebSocket)
+        # 处理用于推送的消息内容 (签名)
+        processed_msg = _process_message_content(message)
+        
+        push_payload = {
+            "type": "NEW_MESSAGE",
+            "data": {
+                "id": processed_msg.id,
+                "type": processed_msg.type,
+                "content_type": processed_msg.content_type,
+                "title": processed_msg.title,
+                "content": processed_msg.content, # 使用签名后的 URL
+                "action_url": processed_msg.action_url,
+                "action_text": processed_msg.action_text,
+                "sender_name": "系统通知" if not sender_id else "用户私信", # 简化处理
+                "sender_id": sender_id, # Add sender_id for frontend to decide left/right
+                "created_at": str(processed_msg.created_at),
+                # 附加应用信息,便于前端按应用拆分系统通知会话
+                "app_id": message.app_id,
+                "app_name": message.app.app_name if message.app else None,
+            }
         }
-    }
+        
+        # 使用后台任务发送 WS 消息,避免阻塞 HTTP 响应
+        # 如果是发给自己,receiver_id == sender_id,ws 会收到一次
+        background_tasks.add_task(manager.send_personal_message, push_payload, final_receiver_id)
     
-    # 使用后台任务发送 WS 消息,避免阻塞 HTTP 响应
-    # 如果是发给自己,receiver_id == sender_id,ws 会收到一次
-    background_tasks.add_task(manager.send_personal_message, push_payload, final_receiver_id)
-
-    return processed_msg
+        return processed_msg
 
 @router.get("/", response_model=List[MessageResponse])
 def read_messages(

+ 4 - 1
backend/app/schemas/message.py

@@ -31,11 +31,14 @@ class MessageBase(BaseModel):
 class MessageCreate(MessageBase):
     receiver_id: Optional[int] = None
     app_user_id: Optional[str] = None
+    is_broadcast: bool = False
     
     @model_validator(mode='after')
     def check_receiver(self):
+        if self.is_broadcast:
+            return self
         if not self.receiver_id and not (self.app_user_id and self.app_id):
-            raise ValueError("必须提供 receiver_id,或者同时提供 app_user_id 和 app_id")
+            raise ValueError("非广播消息必须提供 receiver_id,或者同时提供 app_user_id 和 app_id")
         return self
 
 class MessageUpdate(BaseModel):

+ 152 - 2
frontend/public/docs/message_integration.md

@@ -14,7 +14,7 @@
 
 | 接口 | 用户权限 | 应用权限 | 说明 |
 |------|---------|---------|------|
-| `POST /messages/` | ✅ 仅 MESSAGE | ✅ MESSAGE + NOTIFICATION | 用户只能发私信,应用可发通知 |
+| `POST /messages/` | ✅ 仅 MESSAGE | ✅ MESSAGE + NOTIFICATION + BROADCAST | 用户只能发私信,应用可发通知和广播 |
 | `GET /messages/conversations` | ✅ | ❌ | 仅用户可查询 |
 | `GET /messages/history/{id}` | ✅ | ❌ | 仅用户可查询 |
 | `GET /messages/unread-count` | ✅ | ❌ | 仅用户可查询 |
@@ -134,7 +134,157 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
 }
 ```
 
-### 3.3 如何获取接收者ID (receiver_id) 和应用ID (app_id)
+### 3.3 广播消息接口 (Broadcast)
+
+广播消息接口允许应用向所有活跃用户发送系统通知。适用于系统公告、重要通知等需要全员推送的场景。
+
+**重要说明:**
+- **仅支持系统通知**:广播模式仅支持 `type: "NOTIFICATION"`,不支持私信类型
+- **仅应用可调用**:广播功能仅限应用通过签名认证调用,普通用户无权使用
+- **发送给所有活跃用户**:系统会自动查找所有状态为 `ACTIVE` 且未删除的用户,为每个用户创建一条消息记录
+- **实时推送**:所有在线用户会通过 WebSocket 实时收到推送
+
+**接口地址**: `POST {{API_BASE_URL}}/messages/`
+
+**认证方式**: 应用签名认证(必须使用应用签名头信息)
+
+**请求参数:**
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `is_broadcast` | boolean | 是 | 设置为 `true` 启用广播模式 |
+| `type` | string | 是 | 必须为 `"NOTIFICATION"` |
+| `title` | string | 是 | 消息标题 |
+| `content` | string | 是 | 消息内容 |
+| `content_type` | string | 否 | 内容类型,默认为 `"TEXT"` |
+| `auto_sso` | boolean | 否 | 是否启用 SSO 自动跳转,默认为 `false` |
+| `target_url` | string | 否 | 目标业务页面 URL(当 `auto_sso=true` 时使用) |
+| `action_url` | string | 否 | 自定义跳转链接(不使用 SSO 时使用) |
+| `action_text` | string | 否 | 操作按钮文案 |
+
+**注意:** 广播模式下,**不需要**提供 `receiver_id` 或 `app_user_id`,系统会自动向所有活跃用户发送。
+
+**完整 HTTP 请求示例 (应用签名认证):**
+
+```
+POST {{API_BASE_URL}}/messages/ HTTP/1.1
+Host: api.yourdomain.com
+Content-Type: application/json
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
+
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护通知",
+  "content": "系统将于今晚 22:00-24:00 进行维护,期间可能无法访问,请提前做好准备。",
+  "auto_sso": false,
+  "action_text": "查看详情"
+}
+```
+
+**带 SSO 跳转的广播示例:**
+
+```
+POST {{API_BASE_URL}}/messages/ HTTP/1.1
+Host: api.yourdomain.com
+Content-Type: application/json
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
+
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "重要公告",
+  "content": "请查看最新的系统更新说明",
+  "auto_sso": true,
+  "target_url": "http://your-app.com/announcements/2024-02",
+  "action_text": "立即查看"
+}
+```
+
+**响应示例:**
+
+接口返回的是第一条创建的消息记录(用于签名验证),实际所有用户都会收到消息:
+
+```json
+{
+  "id": 1001,
+  "sender_id": null,
+  "receiver_id": 1,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护通知",
+  "content": "系统将于今晚 22:00-24:00 进行维护...",
+  "action_url": null,
+  "action_text": "查看详情",
+  "is_read": false,
+  "created_at": "2026-02-25T10:00:00",
+  "app_id": "your_app_id_string",
+  "app_name": "您的应用名称"
+}
+```
+
+**Python 调用示例:**
+
+```python
+import requests
+import time
+import hmac
+import hashlib
+
+# 配置
+API_URL = "{{API_BASE_URL}}/messages/"
+APP_ID = "your_app_id_string"
+APP_SECRET = "your_app_secret"
+
+def generate_signature(app_id, app_secret):
+    """生成签名"""
+    timestamp = str(int(time.time()))
+    params = {"app_id": app_id, "timestamp": timestamp}
+    query_string = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
+    sign = hmac.new(app_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256).hexdigest()
+    return timestamp, sign
+
+timestamp, sign = generate_signature(APP_ID, APP_SECRET)
+
+headers = {
+    "X-App-Id": APP_ID,
+    "X-Timestamp": timestamp,
+    "X-Sign": sign,
+    "Content-Type": "application/json"
+}
+
+# 广播消息
+payload = {
+    "is_broadcast": True,  # 启用广播模式
+    "type": "NOTIFICATION",  # 必须是 NOTIFICATION
+    "content_type": "TEXT",
+    "title": "系统维护通知",
+    "content": "系统将于今晚 22:00-24:00 进行维护,期间可能无法访问。",
+    "auto_sso": False,
+    "action_text": "查看详情"
+}
+
+response = requests.post(API_URL, json=payload, headers=headers)
+print(f"Status: {response.status_code}")
+print(f"Response: {response.json()}")
+```
+
+**注意事项:**
+
+1. **权限限制**:只有通过应用签名认证的请求才能使用广播功能,普通用户 Token 认证无法使用
+2. **消息类型限制**:广播仅支持 `NOTIFICATION` 类型,不支持 `MESSAGE` 类型
+3. **用户范围**:广播会发送给所有状态为 `ACTIVE` 且 `is_deleted=0` 的用户
+4. **性能考虑**:如果用户数量很大,广播操作可能需要一些时间,建议在后台异步处理
+5. **实时推送**:所有在线用户会通过 WebSocket 实时收到推送,离线用户可以在下次登录时查看
+6. **消息记录**:每个用户都会收到一条独立的消息记录,可以单独标记已读或删除
+
+### 3.4 如何获取接收者ID (receiver_id) 和应用ID (app_id)
 
 在实际开发中,通常不会直接记住用户ID和应用ID,而是通过查询接口先找到对应对象,再取出其 ID。
 

+ 163 - 3
frontend/src/views/help/MessageIntegration.vue

@@ -29,8 +29,8 @@
           <tr>
             <td><code>POST /messages/</code></td>
             <td>✅ 仅 MESSAGE</td>
-            <td>✅ MESSAGE + NOTIFICATION</td>
-            <td>用户只能发私信,应用可发通知</td>
+            <td>✅ MESSAGE + NOTIFICATION + BROADCAST</td>
+            <td>用户只能发私信,应用可发通知和广播</td>
           </tr>
           <tr>
             <td><code>GET /messages/conversations</code></td>
@@ -204,7 +204,167 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
         </pre>
       </div>
 
-      <h4>3.3 如何获取接收者ID (receiver_id) 和应用ID (app_id)</h4>
+      <h4>3.3 广播消息接口 (Broadcast)</h4>
+      <p>广播消息接口允许应用向所有活跃用户发送系统通知。适用于系统公告、重要通知等需要全员推送的场景。</p>
+      
+      <p><strong>重要说明:</strong></p>
+      <ul>
+        <li><strong>仅支持系统通知</strong>:广播模式仅支持 <code>type: "NOTIFICATION"</code>,不支持私信类型</li>
+        <li><strong>仅应用可调用</strong>:广播功能仅限应用通过签名认证调用,普通用户无权使用</li>
+        <li><strong>发送给所有活跃用户</strong>:系统会自动查找所有状态为 <code>ACTIVE</code> 且未删除的用户,为每个用户创建一条消息记录</li>
+        <li><strong>实时推送</strong>:所有在线用户会通过 WebSocket 实时收到推送</li>
+      </ul>
+      
+      <ul>
+        <li><strong>接口地址</strong>: <code>POST /api/v1/messages/</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>is_broadcast</code></td><td>boolean</td><td>是</td><td>设置为 <code>true</code> 启用广播模式</td></tr>
+          <tr><td><code>type</code></td><td>string</td><td>是</td><td>必须为 <code>"NOTIFICATION"</code></td></tr>
+          <tr><td><code>title</code></td><td>string</td><td>是</td><td>消息标题</td></tr>
+          <tr><td><code>content</code></td><td>string</td><td>是</td><td>消息内容</td></tr>
+          <tr><td><code>content_type</code></td><td>string</td><td>否</td><td>内容类型,默认为 <code>"TEXT"</code></td></tr>
+          <tr><td><code>auto_sso</code></td><td>boolean</td><td>否</td><td>是否启用 SSO 自动跳转,默认为 <code>false</code></td></tr>
+          <tr><td><code>target_url</code></td><td>string</td><td>否</td><td>目标业务页面 URL(当 <code>auto_sso=true</code> 时使用)</td></tr>
+          <tr><td><code>action_url</code></td><td>string</td><td>否</td><td>自定义跳转链接(不使用 SSO 时使用)</td></tr>
+          <tr><td><code>action_text</code></td><td>string</td><td>否</td><td>操作按钮文案</td></tr>
+        </tbody>
+      </table>
+
+      <p><strong>注意:</strong> 广播模式下,<strong>不需要</strong>提供 <code>receiver_id</code> 或 <code>app_user_id</code>,系统会自动向所有活跃用户发送。</p>
+
+      <p><strong>完整 HTTP 请求示例 (应用签名认证):</strong></p>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+Host: api.yourdomain.com
+Content-Type: application/json
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
+
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护通知",
+  "content": "系统将于今晚 22:00-24:00 进行维护,期间可能无法访问,请提前做好准备。",
+  "auto_sso": false,
+  "action_text": "查看详情"
+}
+        </pre>
+      </div>
+
+      <p><strong>带 SSO 跳转的广播示例:</strong></p>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+Host: api.yourdomain.com
+Content-Type: application/json
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
+
+{
+  "is_broadcast": true,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "重要公告",
+  "content": "请查看最新的系统更新说明",
+  "auto_sso": true,
+  "target_url": "http://your-app.com/announcements/2024-02",
+  "action_text": "立即查看"
+}
+        </pre>
+      </div>
+
+      <p><strong>响应示例:</strong></p>
+      <p>接口返回的是第一条创建的消息记录(用于签名验证),实际所有用户都会收到消息:</p>
+      <div class="code-block">
+        <pre>
+{
+  "id": 1001,
+  "sender_id": null,
+  "receiver_id": 1,
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "系统维护通知",
+  "content": "系统将于今晚 22:00-24:00 进行维护...",
+  "action_url": null,
+  "action_text": "查看详情",
+  "is_read": false,
+  "created_at": "2026-02-25T10:00:00",
+  "app_id": "your_app_id_string",
+  "app_name": "您的应用名称"
+}
+        </pre>
+      </div>
+
+      <p><strong>Python 调用示例:</strong></p>
+      <div class="code-block">
+        <pre>
+import requests
+import time
+import hmac
+import hashlib
+
+# 配置
+API_URL = "http://your-api.com/api/v1/messages/"
+APP_ID = "your_app_id_string"
+APP_SECRET = "your_app_secret"
+
+def generate_signature(app_id, app_secret):
+    """生成签名"""
+    timestamp = str(int(time.time()))
+    params = {"app_id": app_id, "timestamp": timestamp}
+    query_string = "&".join([f"{k}={params[k]}" for k in sorted(params.keys())])
+    sign = hmac.new(app_secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256).hexdigest()
+    return timestamp, sign
+
+timestamp, sign = generate_signature(APP_ID, APP_SECRET)
+
+headers = {
+    "X-App-Id": APP_ID,
+    "X-Timestamp": timestamp,
+    "X-Sign": sign,
+    "Content-Type": "application/json"
+}
+
+# 广播消息
+payload = {
+    "is_broadcast": True,  # 启用广播模式
+    "type": "NOTIFICATION",  # 必须是 NOTIFICATION
+    "content_type": "TEXT",
+    "title": "系统维护通知",
+    "content": "系统将于今晚 22:00-24:00 进行维护,期间可能无法访问。",
+    "auto_sso": False,
+    "action_text": "查看详情"
+}
+
+response = requests.post(API_URL, json=payload, headers=headers)
+print(f"Status: {response.status_code}")
+print(f"Response: {response.json()}")
+        </pre>
+      </div>
+
+      <p><strong>注意事项:</strong></p>
+      <ul>
+        <li><strong>权限限制</strong>:只有通过应用签名认证的请求才能使用广播功能,普通用户 Token 认证无法使用</li>
+        <li><strong>消息类型限制</strong>:广播仅支持 <code>NOTIFICATION</code> 类型,不支持 <code>MESSAGE</code> 类型</li>
+        <li><strong>用户范围</strong>:广播会发送给所有状态为 <code>ACTIVE</code> 且 <code>is_deleted=0</code> 的用户</li>
+        <li><strong>性能考虑</strong>:如果用户数量很大,广播操作可能需要一些时间,建议在后台异步处理</li>
+        <li><strong>实时推送</strong>:所有在线用户会通过 WebSocket 实时收到推送,离线用户可以在下次登录时查看</li>
+        <li><strong>消息记录</strong>:每个用户都会收到一条独立的消息记录,可以单独标记已读或删除</li>
+      </ul>
+
+      <h4>3.4 如何获取接收者ID (receiver_id) 和应用ID (app_id)</h4>
       <p>在实际开发中,通常不会直接记住用户ID和应用ID,而是通过查询接口先找到对应对象,再取出其 ID。</p>
 
       <h5>3.3.1 通过用户查询接口获取 receiver_id</h5>