Ver Fonte

更新通知部分

liuq há 1 mês atrás
pai
commit
6b4fd1d0dd

+ 120 - 19
backend/app/api/v1/endpoints/messages.py

@@ -13,8 +13,10 @@ from app.schemas.message import MessageCreate, MessageResponse, MessageUpdate, C
 from app.core.websocket_manager import manager
 from app.core.config import settings
 from app.core.minio import minio_storage
+from app.services.ticket_service import TicketService
 from datetime import datetime
-from urllib.parse import quote, urlparse
+from urllib.parse import quote, urlparse, parse_qs, urlencode, urlunparse
+import json
 
 router = APIRouter()
 
@@ -52,7 +54,8 @@ async def create_message(
     - 应用:可以发送 NOTIFICATION 或 MESSAGE
     """
     sender_id = None
-    app_id = None
+    app_id_int = None  # 数据库中的 Integer ID(用于存储到 Message 表)
+    app_id_str = None  # 字符串类型的 app_id(用于 SSO 跳转链接)
 
     # 1. 鉴权与身份识别
     if isinstance(current_subject, User):
@@ -61,14 +64,28 @@ async def create_message(
             raise HTTPException(status_code=403, detail="普通用户无权发送系统通知")
         sender_id = current_subject.id
         
+        # 如果用户发送时提供了 app_id(字符串),需要查找对应的应用
+        if message_in.app_id:
+            app = db.query(Application).filter(Application.app_id == message_in.app_id).first()
+            if not app:
+                raise HTTPException(status_code=404, detail=f"应用未找到: {message_in.app_id}")
+            app_id_int = app.id
+            app_id_str = app.app_id
+        
     elif isinstance(current_subject, Application):
         # 应用发送
-        app_id = current_subject.id
-        # 安全校验: 确保传入的 app_id (如果有) 与签名身份一致
-        if message_in.app_id and message_in.app_id != current_subject.id:
-             # 这里我们选择忽略传入的 app_id,强制使用当前认证的应用 ID
-             pass 
-        message_in.app_id = app_id
+        app_id_int = current_subject.id
+        app_id_str = current_subject.app_id
+        
+        # 安全校验: 如果传入了 app_id(字符串),确保与签名身份一致
+        if message_in.app_id:
+            if message_in.app_id != current_subject.app_id:
+                raise HTTPException(
+                    status_code=403, 
+                    detail=f"传入的 app_id ({message_in.app_id}) 与认证应用不匹配"
+                )
+        # 使用当前认证应用的 app_id(字符串)
+        message_in.app_id = app_id_str
 
     # 2. 确定接收者 (Receiver Resolution)
     final_receiver_id = None
@@ -83,10 +100,18 @@ async def create_message(
     elif message_in.app_user_id and message_in.app_id:
         # 方式 B: 通过 App User ID 查找映射
         # 注意:如果是用户发送,必须要提供 app_id 才能查映射
-        # 如果是应用发送,message_in.app_id 已经被赋值为 current_subject.id
+        # 如果是应用发送,message_in.app_id 已经是字符串类型
+        
+        # 如果 app_id_int 还没有设置(用户发送且提供了字符串 app_id),需要查找
+        if app_id_int is None:
+            app = db.query(Application).filter(Application.app_id == message_in.app_id).first()
+            if not app:
+                raise HTTPException(status_code=404, detail=f"应用未找到: {message_in.app_id}")
+            app_id_int = app.id
+            app_id_str = app.app_id
         
         mapping = db.query(AppUserMapping).filter(
-            AppUserMapping.app_id == message_in.app_id,
+            AppUserMapping.app_id == app_id_int,  # 使用 Integer ID 查询映射表
             AppUserMapping.mapped_key == message_in.app_user_id
         ).first()
         
@@ -102,16 +127,14 @@ async def create_message(
     # 3. 处理 SSO 跳转链接 (Link Generation)
     final_action_url = message_in.action_url
     
-    if message_in.type == MessageType.NOTIFICATION and message_in.auto_sso and message_in.app_id and message_in.target_url:
-        # 构造 SSO 中转链接
-        # 格式: {PLATFORM_URL}/api/v1/auth/sso/jump?app_id={APP_ID}&redirect_to={TARGET_URL}
+    if message_in.type == MessageType.NOTIFICATION and message_in.auto_sso and app_id_str and message_in.target_url:
+        # 生成 jump 接口 URL,用户点击时调用后端接口生成 callback URL
+        # 格式: {PLATFORM_URL}/api/v1/simple/sso/jump?app_id={APP_ID}&redirect_to={TARGET_URL}
         
-        # 假设 settings.SERVER_HOST 配置了当前服务地址,如果没有则使用相对路径或默认值
-        # 这里为了演示,假设前端或API base url
-        base_url = settings.API_V1_STR # /api/v1
+        base_url = settings.API_V1_STR  # /api/v1
         
         encoded_target = quote(message_in.target_url)
-        final_action_url = f"{base_url}/auth/sso/jump?app_id={message_in.app_id}&redirect_to={encoded_target}"
+        final_action_url = f"{base_url}/simple/sso/jump?app_id={app_id_str}&redirect_to={encoded_target}"
 
     # 处理内容 (如果是文件类型且传入的是 URL,尝试提取 Key)
     content_val = message_in.content if isinstance(message_in.content, str) else str(message_in.content)
@@ -132,7 +155,7 @@ async def create_message(
     message = Message(
         sender_id=sender_id,
         receiver_id=final_receiver_id,
-        app_id=message_in.app_id,
+        app_id=app_id_int,  # 使用 Integer ID 存储到数据库
         type=message_in.type,
         content_type=message_in.content_type,
         title=message_in.title,
@@ -360,4 +383,82 @@ def delete_message(
     
     db.delete(message)
     db.commit()
-    return processed_msg
+    return processed_msg
+
+@router.get("/{message_id}/callback-url", response_model=dict)
+def get_message_callback_url(
+    message_id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    获取消息的 callback URL(用于通知按钮点击)
+    内部执行 jump 接口的逻辑,实时生成 ticket 和 callback URL
+    """
+    # 1. 获取消息
+    message = db.query(Message).filter(Message.id == message_id).first()
+    if not message:
+        raise HTTPException(status_code=404, detail="消息未找到")
+    
+    # 2. 验证权限:只有接收者可以获取
+    if message.receiver_id != current_user.id:
+        raise HTTPException(status_code=403, detail="无权访问此消息")
+    
+    # 3. 检查是否有 action_url(jump 接口 URL)
+    if not message.action_url:
+        raise HTTPException(status_code=400, detail="此消息没有配置跳转链接")
+    
+    # 4. 解析 action_url,提取 app_id 和 redirect_to
+    # action_url 格式: /api/v1/simple/sso/jump?app_id=xxx&redirect_to=xxx
+    parsed = urlparse(message.action_url)
+    if not parsed.path.endswith("/sso/jump"):
+        raise HTTPException(status_code=400, detail="无效的跳转链接格式")
+    
+    query_params = parse_qs(parsed.query)
+    app_id = query_params.get("app_id", [None])[0]
+    redirect_to = query_params.get("redirect_to", [None])[0]
+    
+    if not app_id or not redirect_to:
+        raise HTTPException(status_code=400, detail="跳转链接参数不完整")
+    
+    # 5. 执行 jump 接口的逻辑(但不返回 RedirectResponse,而是返回 JSON)
+    app = db.query(Application).filter(Application.app_id == app_id).first()
+    if not app:
+        raise HTTPException(status_code=404, detail="应用未找到")
+    
+    # 6. 生成 Ticket(使用当前登录用户)
+    ticket = TicketService.generate_ticket(current_user.id, app_id)
+    
+    # 7. 获取应用回调地址
+    redirect_base = ""
+    if app.redirect_uris:
+        try:
+            uris = json.loads(app.redirect_uris)
+            if isinstance(uris, list) and len(uris) > 0:
+                redirect_base = uris[0]
+            elif isinstance(uris, str):
+                redirect_base = uris
+        except (json.JSONDecodeError, TypeError):
+            redirect_base = app.redirect_uris.strip()
+    
+    if not redirect_base:
+        raise HTTPException(status_code=400, detail="应用未配置回调地址")
+    
+    # 8. 构造最终 callback URL
+    parsed_uri = urlparse(redirect_base)
+    callback_query_params = parse_qs(parsed_uri.query)
+    
+    callback_query_params['ticket'] = [ticket]
+    callback_query_params['next'] = [redirect_to]
+    
+    new_query = urlencode(callback_query_params, doseq=True)
+    callback_url = urlunparse((
+        parsed_uri.scheme,
+        parsed_uri.netloc,
+        parsed_uri.path,
+        parsed_uri.params,
+        new_query,
+        parsed_uri.fragment
+    ))
+    
+    return {"callback_url": callback_url}

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

@@ -19,7 +19,7 @@ class MessageBase(BaseModel):
     content_type: ContentType = ContentType.TEXT
     
     type: MessageType = MessageType.MESSAGE
-    app_id: Optional[int] = None
+    app_id: Optional[str] = None  # 改为字符串类型,支持开发者保存的字符串 app_id
     
     action_url: Optional[str] = None
     action_text: Optional[str] = None

+ 128 - 43
frontend/public/docs/message_integration.md

@@ -18,6 +18,7 @@
 | `GET /messages/conversations` | ✅ | ❌ | 仅用户可查询 |
 | `GET /messages/history/{id}` | ✅ | ❌ | 仅用户可查询 |
 | `GET /messages/unread-count` | ✅ | ❌ | 仅用户可查询 |
+| `GET /messages/{id}/callback-url` | ✅ | ❌ | 仅用户可调用,获取通知回调 URL |
 | `PUT /messages/{id}/read` | ✅ | ❌ | 仅用户可操作 |
 | `PUT /messages/read-all` | ✅ | ❌ | 仅用户可操作 |
 | `DELETE /messages/{id}` | ✅ | ❌ | 仅用户可操作 |
@@ -87,7 +88,7 @@
 
 ### 3.1 应用调用示例 (签名认证)
 
-适用于业务系统后端向用户推送通知。签名生成规则请参考 API 安全规范 (简单来说:`sign = HMAC-SHA256(secret, app_id=101&timestamp=1700000000)`)。
+适用于业务系统后端向用户推送通知。签名生成规则请参考 API 安全规范 (简单来说:`sign = HMAC-SHA256(secret, app_id=your_app_id_string&timestamp=1700000000)`)。
 
 **完整 HTTP 请求示例:**
 
@@ -95,12 +96,12 @@
 POST {{API_BASE_URL}}/messages/ HTTP/1.1
 Host: api.yourdomain.com
 Content-Type: application/json
-X-App-Id: 101
+X-App-Id: your_app_id_string
 X-Timestamp: 1708848000
 X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
 
 {
-  "app_id": 101,
+  "app_id": "your_app_id_string",
   "app_user_id": "zhangsan_oa",
   "type": "NOTIFICATION",
   "content_type": "TEXT",
@@ -282,7 +283,7 @@ const fetchContactsPaginated = async (page = 1, pageSize = 20, keyword = '') =>
 如果是“用户调用 + 使用 `app_user_id`”的方式发送消息,需要在 Body 中同时提供 `app_id`,可以通过应用列表接口查询:
 
 - **接口地址**: `GET {{API_BASE_URL}}/apps/?search=关键字`
-- **说明**: 支持按应用名称 / `app_id` 模糊搜索,返回结构中既包含内部自增主键 `id`,也包含对外使用的 `app_id` 字段。
+- **说明**: 支持按应用名称 / `app_id` 模糊搜索,返回结构中既包含内部自增主键 `id`(整数),也包含对外使用的 `app_id` 字段(字符串类型,消息接口使用此字段)
 
 **示例:**
 
@@ -295,8 +296,8 @@ GET {{API_BASE_URL}}/apps/?search=OA
   "total": 1,
   "items": [
     {
-      "id": 101,              // 数据库主键 (消息表中的 app_id 对应此字段)
-      "app_id": "oa_system",  // 对外展示的应用ID (如开放接口使用)
+      "id": 101,              // 数据库主键 (内部使用)
+      "app_id": "oa_system",  // 字符串类型的应用ID (消息接口使用此字段)
       "app_name": "OA系统"
     }
   ]
@@ -308,7 +309,7 @@ Content-Type: application/json
 Authorization: Bearer xxx
 
 {
-  "app_id": 101,              // 使用 items[0].id
+  "app_id": "oa_system",       // 使用 items[0].app_id (字符串类型)
   "app_user_id": "zhangsan_oa",
   "type": "NOTIFICATION",
   "content_type": "TEXT",
@@ -319,8 +320,8 @@ Authorization: Bearer xxx
 
 #### 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` 表中解析真实用户。
+- **应用通过签名调用接口时**:系统会自动根据 `X-App-Id` 解析出当前应用,并将 `message_in.app_id` 强制设置为该应用的字符串类型 app_id,Body 中传入的 `app_id` 会被忽略。
+- **用户调用并使用 `app_user_id` 时**:`app_id` 必须在 Body 中显式给出(字符串类型),用于从 `app_user_mapping` 表中解析真实用户。
 
 ## 4. 消息查询接口
 
@@ -494,6 +495,79 @@ DELETE {{API_BASE_URL}}/messages/501
 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
 ```
 
+### 5.4 获取消息回调 URL
+
+用于获取通知消息的 SSO 跳转回调 URL。当用户点击通知消息的操作按钮时,前端应调用此接口获取实时的 callback URL(包含 ticket),然后打开该 URL。
+
+**重要说明:**
+- 此接口会实时生成 ticket,确保 ticket 不会过期
+- 只有消息的接收者可以调用此接口
+- 仅适用于带有 `action_url` 的通知消息
+
+- **接口地址**: `GET {{API_BASE_URL}}/messages/{message_id}/callback-url`
+- **路径参数**: `message_id` - 消息ID
+- **认证方式**: `Authorization: Bearer <JWT_TOKEN>`
+- **权限**: 仅用户可调用,且只能获取自己接收的消息
+
+**请求示例:**
+
+```
+GET {{API_BASE_URL}}/messages/501/callback-url
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+```
+
+**响应示例:**
+
+```json
+{
+  "callback_url": "http://your-app.com/callback?ticket=abc123&next=http://your-business.com/detail/123"
+}
+```
+
+**前端调用示例 (JavaScript):**
+
+```javascript
+// 处理通知操作按钮点击
+const handleNotificationAction = async (message) => {
+  if (message.action_url) {
+    try {
+      // 调用接口获取 callback URL(实时生成 ticket)
+      const res = await api.get(`/messages/${message.id}/callback-url`)
+      
+      if (res.data && res.data.callback_url) {
+        // 打开回调 URL
+        window.open(res.data.callback_url, '_blank')
+      } else {
+        console.error('获取跳转链接失败')
+      }
+    } catch (error) {
+      console.error('获取跳转链接失败:', error)
+    }
+  }
+}
+```
+
+**工作流程:**
+
+1. **创建消息时**:消息的 `action_url` 存储的是 jump 接口 URL(格式:`/api/v1/simple/sso/jump?app_id=xxx&redirect_to=xxx`),不包含 ticket
+2. **用户点击按钮时**:
+   - 前端调用 `GET /messages/{id}/callback-url` 接口
+   - 后端实时生成 ticket,并构造最终的 callback URL
+   - 返回 JSON 格式的 `callback_url`
+   - 前端打开返回的 callback URL
+
+**错误响应:**
+
+- `404`: 消息未找到
+- `403`: 无权访问此消息(不是消息接收者)
+- `400`: 消息没有配置跳转链接或跳转链接格式无效
+
+**注意事项:**
+
+- Ticket 有效期为 60 秒,因此必须在用户点击时实时生成
+- 如果消息的 `action_url` 不是 jump 接口格式,将返回 400 错误
+- 此接口会验证用户身份,确保只有消息接收者可以获取 callback URL
+
 ## 6. 文件上传接口
 
 用于上传图片、视频、文档等附件,上传成功后可用于发送多媒体消息。
@@ -629,7 +703,7 @@ ws.onerror = (error) => {
     "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=your_app_id_string&redirect_to=...",
     "action_text": "立即处理",
     "created_at": "2026-02-25T10:00:00"
   }
@@ -1130,43 +1204,27 @@ import requests
 import time
 import hmac
 import hashlib
-import json
 
 # 配置
 API_URL = "{{API_BASE_URL}}/messages/"
-APP_ID = "101"
+APP_ID = "your_app_id_string"  # 字符串类型的 app_id
 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()))
+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
 
-# 签名参数 (注意:消息接口签名仅包含 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])
+timestamp, sign = generate_signature(APP_ID, APP_SECRET)
 
-# HMAC-SHA256
-sign = hmac.new(
-    APP_SECRET.encode('utf-8'),
-    query_string.encode('utf-8'),
-    hashlib.sha256
-).hexdigest()
+# Debug: 打印签名信息
+query_string = "&".join([f"{k}={v}" for k, v in sorted({"app_id": APP_ID, "timestamp": timestamp}.items())])
+print(f"Debug - Query string for signature: {query_string}")
+print(f"Debug - Generated signature: {sign}")
+print(f"Debug - Timestamp: {timestamp}")
 
 headers = {
     "X-App-Id": APP_ID,
@@ -1175,8 +1233,35 @@ headers = {
     "Content-Type": "application/json"
 }
 
-# 3. 发送
-print(f"Signing string: {query_string}")
+payload = {
+    "app_id": APP_ID,  
+    "app_user_id": "admin",
+    "type": "NOTIFICATION",
+    "content_type": "TEXT",
+    "title": "测试通知",
+    "content": "这是一条测试通知消息2",
+    "auto_sso": True,  # 开启自动 SSO
+    "target_url": "http://your-business-system.com/detail/123",  # 最终要跳转的业务页面
+    "action_text": "查看详情"
+}
+
+print(f"Debug - Request headers: {headers}")
+print(f"Debug - Request payload: {payload}")
+
 resp = requests.post(API_URL, json=payload, headers=headers)
-print(resp.json())
+print(f"Status: {resp.status_code}")
+print(f"Response Headers: {dict(resp.headers)}")
+
+# Safely handle response - check if it's JSON
+try:
+    if resp.text:
+        print(f"Response Text: {resp.text}")
+        try:
+            print(f"Response JSON: {resp.json()}")
+        except requests.exceptions.JSONDecodeError:
+            print("Response is not valid JSON")
+    else:
+        print("Response body is empty")
+except Exception as e:
+    print(f"Error reading response: {e}")
 ```

+ 138 - 45
frontend/src/views/help/MessageIntegration.vue

@@ -50,6 +50,12 @@
             <td>❌</td>
             <td>仅用户可查询</td>
           </tr>
+          <tr>
+            <td><code>GET /messages/{id}/callback-url</code></td>
+            <td>✅</td>
+            <td>❌</td>
+            <td>仅用户可调用,获取通知回调 URL</td>
+          </tr>
           <tr>
             <td><code>PUT /messages/{id}/read</code></td>
             <td>✅</td>
@@ -152,7 +158,7 @@
       </ul>
 
       <h4>3.1 应用调用示例 (签名认证)</h4>
-      <p>适用于业务系统后端向用户推送通知。签名生成规则请参考 <a href="#">API 安全规范</a> (简单来说:<code>sign = HMAC-SHA256(secret, app_id=101&timestamp=1700000000)</code>)。</p>
+      <p>适用于业务系统后端向用户推送通知。签名生成规则请参考 <a href="#">API 安全规范</a> (简单来说:<code>sign = HMAC-SHA256(secret, app_id=your_app_id_string&timestamp=1700000000)</code>)。</p>
       
       <p><strong>完整 HTTP 请求示例:</strong></p>
       <div class="code-block">
@@ -160,12 +166,12 @@
 POST /api/v1/messages/ HTTP/1.1
 Host: api.yourdomain.com
 Content-Type: application/json
-X-App-Id: 101
+X-App-Id: your_app_id_string
 X-Timestamp: 1708848000
 X-Sign: a1b2c3d4e5f6... (HMAC-SHA256签名)
 
 {
-  "app_id": 101,
+  "app_id": "your_app_id_string",
   "app_user_id": "zhangsan_oa",    // 第三方系统账号
   "type": "NOTIFICATION",
   "content_type": "TEXT",
@@ -190,7 +196,6 @@ Content-Type: application/json
 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
 
 {
-  "app_id": 101,
   "receiver_id": 2048,             // 接收用户 ID
   "type": "MESSAGE",               // 私信
   "content_type": "TEXT",
@@ -342,7 +347,7 @@ const fetchContactsPaginated = async (page = 1, pageSize = 20, keyword = '') =>
       <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>
+        <li><strong>说明</strong>: 支持按应用名称 / <code>app_id</code> 模糊搜索,返回结构中既包含内部自增主键 <code>id</code>(整数),也包含对外使用的 <code>app_id</code> 字段(字符串类型,消息接口使用此字段)。</li>
       </ul>
       <div class="code-block">
         <pre>
@@ -354,8 +359,8 @@ GET /api/v1/apps/?search=OA
   "total": 1,
   "items": [
     {
-      "id": 101,              // 数据库主键 (消息表中的 app_id 对应此字段)
-      "app_id": "oa_system",  // 对外展示的应用ID (如开放接口使用)
+      "id": 101,              // 数据库主键 (内部使用)
+      "app_id": "oa_system",  // 字符串类型的应用ID (消息接口使用此字段)
       "app_name": "OA系统"
     }
   ]
@@ -363,7 +368,7 @@ GET /api/v1/apps/?search=OA
 
 // 用户以 app_user_id 方式发送消息时示例
 {
-  "app_id": 101,              // 使用 items[0].id
+  "app_id": "oa_system",       // 使用 items[0].app_id (字符串类型)
   "app_user_id": "zhangsan_oa",
   "type": "NOTIFICATION",
   "content_type": "TEXT",
@@ -375,8 +380,8 @@ GET /api/v1/apps/?search=OA
 
       <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>
+        <li><strong>应用通过签名调用接口时</strong>:系统会自动根据 <code>X-App-Id</code> 解析出当前应用,并将 <code>message_in.app_id</code> 强制设置为该应用的字符串类型 app_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>
 
@@ -558,6 +563,83 @@ DELETE /api/v1/messages/501
 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
         </pre>
       </div>
+
+      <h4>5.4 获取消息回调 URL</h4>
+      <p>用于获取通知消息的 SSO 跳转回调 URL。当用户点击通知消息的操作按钮时,前端应调用此接口获取实时的 callback URL(包含 ticket),然后打开该 URL。</p>
+      <p><strong>重要说明:</strong></p>
+      <ul>
+        <li>此接口会实时生成 ticket,确保 ticket 不会过期</li>
+        <li>只有消息的接收者可以调用此接口</li>
+        <li>仅适用于带有 <code>action_url</code> 的通知消息</li>
+      </ul>
+      <ul>
+        <li><strong>接口地址</strong>: <code>GET /api/v1/messages/{message_id}/callback-url</code></li>
+        <li><strong>路径参数</strong>: <code>message_id</code> - 消息ID</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>
+GET /api/v1/messages/501/callback-url
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
+        </pre>
+      </div>
+      <p><strong>响应示例:</strong></p>
+      <div class="code-block">
+        <pre>
+{
+  "callback_url": "http://your-app.com/callback?ticket=abc123&next=http://your-business.com/detail/123"
+}
+        </pre>
+      </div>
+      <p><strong>前端调用示例 (JavaScript):</strong></p>
+      <div class="code-block">
+        <pre>
+// 处理通知操作按钮点击
+const handleNotificationAction = async (message) => {
+  if (message.action_url) {
+    try {
+      // 调用接口获取 callback URL(实时生成 ticket)
+      const res = await api.get(`/messages/${message.id}/callback-url`)
+      
+      if (res.data && res.data.callback_url) {
+        // 打开回调 URL
+        window.open(res.data.callback_url, '_blank')
+      } else {
+        console.error('获取跳转链接失败')
+      }
+    } catch (error) {
+      console.error('获取跳转链接失败:', error)
+    }
+  }
+}
+        </pre>
+      </div>
+      <p><strong>工作流程:</strong></p>
+      <ol>
+        <li><strong>创建消息时</strong>:消息的 <code>action_url</code> 存储的是 jump 接口 URL(格式:<code>/api/v1/simple/sso/jump?app_id=xxx&redirect_to=xxx</code>),不包含 ticket</li>
+        <li><strong>用户点击按钮时</strong>:
+          <ul>
+            <li>前端调用 <code>GET /messages/{id}/callback-url</code> 接口</li>
+            <li>后端实时生成 ticket,并构造最终的 callback URL</li>
+            <li>返回 JSON 格式的 <code>callback_url</code></li>
+            <li>前端打开返回的 callback URL</li>
+          </ul>
+        </li>
+      </ol>
+      <p><strong>错误响应:</strong></p>
+      <ul>
+        <li><code>404</code>: 消息未找到</li>
+        <li><code>403</code>: 无权访问此消息(不是消息接收者)</li>
+        <li><code>400</code>: 消息没有配置跳转链接或跳转链接格式无效</li>
+      </ul>
+      <p><strong>注意事项:</strong></p>
+      <ul>
+        <li>Ticket 有效期为 60 秒,因此必须在用户点击时实时生成</li>
+        <li>如果消息的 <code>action_url</code> 不是 jump 接口格式,将返回 400 错误</li>
+        <li>此接口会验证用户身份,确保只有消息接收者可以获取 callback URL</li>
+      </ul>
     </div>
 
     <div class="section">
@@ -708,7 +790,7 @@ ws.onerror = (error) => {
     "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=your_app_id_string&redirect_to=...",
     "action_text": "立即处理",
     "created_at": "2026-02-25T10:00:00"
   }
@@ -1013,43 +1095,27 @@ import requests
 import time
 import hmac
 import hashlib
-import json
 
 # 配置
-API_URL = "http://localhost:8000/api/v1/messages/"
-APP_ID = "101"
+API_URL = "http://your-api.com/api/v1/messages/"  # 替换为实际的 API 地址
+APP_ID = "your_app_id_string"  # 字符串类型的 app_id
 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
-}
+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
 
-# 排序并拼接: app_id=101&timestamp=1700000000
-sorted_keys = sorted(params.keys())
-query_string = "&".join([f"{k}={params[k]}" for k in sorted_keys])
+timestamp, sign = generate_signature(APP_ID, APP_SECRET)
 
-# HMAC-SHA256
-sign = hmac.new(
-    APP_SECRET.encode('utf-8'),
-    query_string.encode('utf-8'),
-    hashlib.sha256
-).hexdigest()
+# Debug: 打印签名信息
+query_string = "&".join([f"{k}={v}" for k, v in sorted({"app_id": APP_ID, "timestamp": timestamp}.items())])
+print(f"Debug - Query string for signature: {query_string}")
+print(f"Debug - Generated signature: {sign}")
+print(f"Debug - Timestamp: {timestamp}")
 
 headers = {
     "X-App-Id": APP_ID,
@@ -1058,10 +1124,37 @@ headers = {
     "Content-Type": "application/json"
 }
 
-# 3. 发送
-print(f"Signing string: {query_string}")
+payload = {
+    "app_id": APP_ID,  
+    "app_user_id": "admin",
+    "type": "NOTIFICATION",
+    "content_type": "TEXT",
+    "title": "测试通知",
+    "content": "这是一条测试通知消息2",
+    "auto_sso": True,  # 开启自动 SSO
+    "target_url": "http://your-business-system.com/detail/123",  # 最终要跳转的业务页面
+    "action_text": "查看详情"
+}
+
+print(f"Debug - Request headers: {headers}")
+print(f"Debug - Request payload: {payload}")
+
 resp = requests.post(API_URL, json=payload, headers=headers)
-print(resp.json())
+print(f"Status: {resp.status_code}")
+print(f"Response Headers: {dict(resp.headers)}")
+
+# Safely handle response - check if it's JSON
+try:
+    if resp.text:
+        print(f"Response Text: {resp.text}")
+        try:
+            print(f"Response JSON: {resp.json()}")
+        except requests.exceptions.JSONDecodeError:
+            print("Response is not valid JSON")
+    else:
+        print("Response body is empty")
+except Exception as e:
+    print(f"Error reading response: {e}")
         </pre>
       </div>
     </div>

+ 13 - 3
frontend/src/views/message/index.vue

@@ -553,10 +553,20 @@ const parseJsonContent = (content: string) => {
 }
 
 // 处理通知操作点击
-const handleNotificationAction = (msg: any) => {
+const handleNotificationAction = async (msg: any) => {
   if (msg.action_url) {
-    // 如果是SSO跳转链接,直接打开
-    window.open(msg.action_url, '_blank')
+    try {
+      // 调用后端接口获取 callback URL(实时生成 ticket)
+      const res = await api.get(`/messages/${msg.id}/callback-url`)
+      if (res.data && res.data.callback_url) {
+        window.open(res.data.callback_url, '_blank')
+      } else {
+        ElMessage.error('获取跳转链接失败')
+      }
+    } catch (error: any) {
+      console.error('获取跳转链接失败:', error)
+      ElMessage.error(error.response?.data?.detail || '获取跳转链接失败')
+    }
   }
 }