瀏覽代碼

用户通知

liuq 4 周之前
父節點
當前提交
17c04e344a

+ 46 - 5
backend/app/api/v1/endpoints/messages.py

@@ -20,6 +20,19 @@ import json
 
 router = APIRouter()
 
+
+def _conversation_last_preview(msg: Message) -> str:
+    """会话列表「最后一条」预览文案:用户通知类型展示 title,便于列表识别。"""
+    ct = msg.content_type
+    cv = ct.value if hasattr(ct, "value") else str(ct)
+    if cv == ContentType.USER_NOTIFICATION.value:
+        t = (msg.title or "").strip()
+        return t if t else "申请通知"
+    if cv == ContentType.TEXT.value:
+        return msg.content or ""
+    return f"[{cv}]"
+
+
 def _process_message_content(message: Message) -> MessageResponse:
     """处理消息内容,为文件类型生成预签名 URL"""
     # Pydantic v2 use model_validate
@@ -87,6 +100,20 @@ async def create_message(
         # 使用当前认证应用的 app_id(字符串)
         message_in.app_id = app_id_str
 
+        # 应用发信时可选:解析 sender_app_user_id → sender_id(不落库,只写 sender_id)
+        sender_app_user_id = getattr(message_in, "sender_app_user_id", None)
+        if sender_app_user_id:
+            sender_mapping = db.query(AppUserMapping).filter(
+                AppUserMapping.app_id == app_id_int,
+                AppUserMapping.mapped_key == sender_app_user_id
+            ).first()
+            if not sender_mapping:
+                raise HTTPException(
+                    status_code=404,
+                    detail=f"发起人映射未找到: App {message_in.app_id}, sender_app_user_id={sender_app_user_id}"
+                )
+            sender_id = sender_mapping.user_id
+
     # 2. 确定接收者 (Receiver Resolution)
     final_receiver_id = None
     is_broadcast = getattr(message_in, "is_broadcast", False)
@@ -131,17 +158,31 @@ 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 app_id_str and message_in.target_url:
+    if (
+        message_in.auto_sso
+        and app_id_str
+        and message_in.target_url
+        and (
+            message_in.type == MessageType.NOTIFICATION
+            or message_in.content_type == ContentType.USER_NOTIFICATION
+        )
+    ):
         # 生成 jump 接口 URL,用户点击时调用后端接口生成 callback URL
         # 格式: {PLATFORM_URL}/api/v1/simple/sso/jump?app_id={APP_ID}&redirect_to={TARGET_URL}
-        
         base_url = settings.API_V1_STR  # /api/v1
-        
         encoded_target = quote(message_in.target_url)
         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)
+    # 注意:前端可能会对 content 做 JSON.parse(如 USER_NOTIFICATION)。
+    # 如果请求体传的是对象/dict,这里应当序列化为合法 JSON 字符串,而不是 str(dict)。
+    if isinstance(message_in.content, str):
+        content_val = message_in.content
+    else:
+        try:
+            content_val = json.dumps(message_in.content, ensure_ascii=False, default=str)
+        except Exception:
+            content_val = str(message_in.content)
     if message_in.content_type in [ContentType.IMAGE, ContentType.VIDEO, ContentType.FILE]:
         # 简单判断: 如果包含 bucket name,可能是 URL
         if settings.MINIO_BUCKET_NAME in content_val and "http" in content_val:
@@ -365,7 +406,7 @@ def get_conversations(
                 "username": username,
                 "full_name": full_name,
                 "unread_count": 0,
-                "last_message": msg.content if msg.content_type == ContentType.TEXT else f"[{msg.content_type}]",
+                "last_message": _conversation_last_preview(msg),
                 "last_message_type": msg.content_type,
                 "updated_at": msg.created_at,
                 "is_system": is_system,

+ 2 - 0
backend/app/models/message.py

@@ -13,6 +13,8 @@ class ContentType(str, enum.Enum):
     IMAGE = "IMAGE"
     VIDEO = "VIDEO"
     FILE = "FILE"
+    # 用户在应用内发起的“申请通知”,用于在私信界面展示通知样式(携带 action_url/action_text)
+    USER_NOTIFICATION = "USER_NOTIFICATION"
 
 class Message(Base):
     __tablename__ = "messages"

+ 3 - 0
backend/app/schemas/message.py

@@ -12,6 +12,8 @@ class ContentType(str, Enum):
     IMAGE = "IMAGE"
     VIDEO = "VIDEO"
     FILE = "FILE"
+    # 用户在应用内发起的“申请通知”,用于在私信界面展示通知样式(携带 action_url/action_text)
+    USER_NOTIFICATION = "USER_NOTIFICATION"
 
 class MessageBase(BaseModel):
     title: str = Field(..., max_length=255)
@@ -31,6 +33,7 @@ class MessageBase(BaseModel):
 class MessageCreate(MessageBase):
     receiver_id: Optional[int] = None
     app_user_id: Optional[str] = None
+    sender_app_user_id: Optional[str] = None  # 应用发信时可选:帐内发起人,解析后写入 sender_id,用于审计和会话聚合
     is_broadcast: bool = False
     
     @model_validator(mode='after')

+ 367 - 7
frontend/public/docs/client_api_guide.md

@@ -36,6 +36,155 @@
 - **说明**:
   - 用户调用:仅允许 `type = MESSAGE`
   - 应用调用:支持 `MESSAGE` / `NOTIFICATION` / 广播
+  - **用户申请通知**:`type = MESSAGE` 且 `content_type = USER_NOTIFICATION`,在私信会话中以通知卡片样式展示(标题 + 结构化内容 + 操作按钮);需跳转业务页时配合 `app_id`、`auto_sso`、`target_url`(后端生成 `action_url`)
+
+#### 调用约定
+
+| 项目 | 说明 |
+|------|------|
+| **方法 / 路径** | `POST {{API_BASE_URL}}/messages/` |
+| **Content-Type** | `application/json` |
+| **用户调用** | `Authorization: Bearer <JWT_TOKEN>`;**仅允许** `type = MESSAGE`,禁止 `NOTIFICATION` |
+| **应用调用** | `X-App-Id`(字符串 app_id)、`X-Timestamp`(Unix 秒)、`X-Sign`;签名:`HMAC-SHA256(app_secret, 待签名字符串)`,待签名字符串为按键名字母序拼接的 `app_id=...&timestamp=...`(不含 `sign` 本身) |
+
+#### 请求体字段(MessageCreate)
+
+| 字段 | 必填 | 说明 |
+|------|------|------|
+| `type` | 是 | `MESSAGE` / `NOTIFICATION` |
+| `title` | 是 | 标题,最长 255 |
+| `content` | 是 | 字符串或 JSON 对象(对象会序列化为 JSON 字符串落库) |
+| `content_type` | 否 | 默认 `TEXT`;`IMAGE` / `VIDEO` / `FILE` / `USER_NOTIFICATION` |
+| `receiver_id` | 条件 | 统一平台用户 ID;与 `app_id`+`app_user_id` 二选一(**广播时不要填**) |
+| `app_id` | 条件 | 应用字符串 ID;与 `app_user_id` 联用解析接收者;用户发 `USER_NOTIFICATION` 且 `auto_sso` 时需填 |
+| `app_user_id` | 条件 | 业务账号,须在映射表存在 |
+| `sender_app_user_id` | 否 | 应用发信时可选:发起人业务账号,解析后写入 `sender_id` 用于审计和会话聚合;用户 JWT 发信忽略 |
+| `is_broadcast` | 否 | `true` 时向全员发通知;**仅** `type = NOTIFICATION` + **应用签名**;勿填接收者 |
+| `auto_sso` | 否 | 与 `target_url` 配合;`NOTIFICATION` 或 `USER_NOTIFICATION` 时可自动生成 `action_url`(jump) |
+| `target_url` | 否 | 业务落地页 URL(`auto_sso = true` 时使用) |
+| `action_url` / `action_text` | 否 | 自定义按钮;接收端点击若走 `callback-url`,需为后端识别的 jump 格式 |
+
+**接收者规则(非广播)**:必须提供 `receiver_id`,**或**同时提供 `app_id` + `app_user_id`。  
+**应用 + `app_user_id`**:Body 中的 `app_id` 必须与 `X-App-Id` 一致,否则 403。
+
+**常见错误**:`403`(无权限 / 类型不允许 / app_id 不匹配)、`404`(用户或映射不存在)、`422`(校验失败,如缺接收者)。
+
+#### 签名生成示例(Python)
+
+```python
+import hmac, hashlib, time
+
+app_id = "your_app_id_string"
+app_secret = "your_app_secret"
+timestamp = str(int(time.time()))
+query_string = f"app_id={app_id}&timestamp={timestamp}"
+sign = hmac.new(
+    app_secret.encode("utf-8"),
+    query_string.encode("utf-8"),
+    hashlib.sha256,
+).hexdigest()
+# 请求头: X-App-Id, X-Timestamp, X-Sign=sign
+```
+
+#### Python 完整调用(requests)
+
+依赖:`pip install requests`。将 `API_BASE`、`APP_ID`、`APP_SECRET`、用户示例中的 `ACCESS_TOKEN` 换成实际值。
+
+```python
+import hmac
+import hashlib
+import time
+import requests
+
+API_BASE = "https://your-host.example.com/api/v1"  # 无尾斜杠
+APP_ID = "your_app_id_string"
+APP_SECRET = "your_app_secret"
+
+
+def app_sign_headers() -> dict:
+    ts = str(int(time.time()))
+    query_string = f"app_id={APP_ID}&timestamp={ts}"
+    sign = hmac.new(
+        APP_SECRET.encode("utf-8"),
+        query_string.encode("utf-8"),
+        hashlib.sha256,
+    ).hexdigest()
+    return {
+        "X-App-Id": APP_ID,
+        "X-Timestamp": ts,
+        "X-Sign": sign,
+        "Content-Type": "application/json",
+    }
+
+
+def send_notification_by_app_user() -> dict:
+    """应用签名:按 app_user_id 发送通知"""
+    url = f"{API_BASE}/messages/"
+    payload = {
+        "app_id": APP_ID,
+        "app_user_id": "zhangsan_oa",
+        "type": "NOTIFICATION",
+        "content_type": "TEXT",
+        "title": "审批通知",
+        "content": "您有一条待审批任务",
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/todo/123",
+        "action_text": "立即处理",
+    }
+    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+def send_user_notification_by_app(app_user_id: str, sender_app_user_id: str = None) -> dict:
+    """应用发 USER_NOTIFICATION:代发起人通知接收者(如 OA 代张三通知李四)"""
+    url = f"{API_BASE}/messages/"
+    payload = {
+        "app_id": APP_ID,
+        "app_user_id": app_user_id,
+        "type": "MESSAGE",
+        "content_type": "USER_NOTIFICATION",
+        "title": "请假申请",
+        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/leave/123",
+        "action_text": "去审批",
+    }
+    if sender_app_user_id:
+        payload["sender_app_user_id"] = sender_app_user_id
+    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+def send_user_notification(access_token: str) -> dict:
+    """用户 Bearer:发送 USER_NOTIFICATION(用户本人发,如重新提醒)"""
+    url = f"{API_BASE}/messages/"
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Content-Type": "application/json",
+    }
+    payload = {
+        "receiver_id": 2048,
+        "type": "MESSAGE",
+        "content_type": "USER_NOTIFICATION",
+        "app_id": "oa_system",
+        "title": "请假申请",
+        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/leave/123",
+        "action_text": "去审批",
+    }
+    r = requests.post(url, headers=headers, json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+if __name__ == "__main__":
+    print(send_notification_by_app_user())
+    # print(send_user_notification_by_app("lisi_manager", "zhangsan_oa"))
+    # print(send_user_notification("eyJhbGciOiJIUzI1NiIsInR5cCI6..."))
+```
 
 用户私信示例:
 
@@ -53,16 +202,127 @@ Content-Type: application/json
 }
 ```
 
-通知示例(应用签名场景):
+响应示例(MessageResponse):
+
+```json
+{
+  "id": 501,
+  "sender_id": 10001,
+  "receiver_id": 2048,
+  "app_id": null,
+  "app_name": null,
+  "type": "MESSAGE",
+  "content_type": "TEXT",
+  "title": "私信",
+  "content": "你好,这是一条私信",
+  "action_url": null,
+  "action_text": null,
+  "is_read": false,
+  "created_at": "2026-03-18T09:59:00",
+  "read_at": null
+}
+```
+
+应用发 USER_NOTIFICATION 示例(应用签名,按 app_user_id 接收,可选 sender_app_user_id):
 
 ```http
 POST {{API_BASE_URL}}/messages/
 X-App-Id: your_app_id
-X-Timestamp: 1700000000
-X-Sign: your_hmac_sign
+X-Timestamp: 1708848000
+X-Sign: <HMAC-SHA256>
+Content-Type: application/json
+
+{
+  "app_id": "oa_system",
+  "app_user_id": "lisi_manager",
+  "sender_app_user_id": "zhangsan_oa",
+  "type": "MESSAGE",
+  "content_type": "USER_NOTIFICATION",
+  "title": "请假申请",
+  "content": {"applyType":"LEAVE","days":2,"businessId":123},
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/leave/123",
+  "action_text": "去审批"
+}
+```
+
+用户发 USER_NOTIFICATION 示例(用户 Token,如重新提醒):
+
+```http
+POST {{API_BASE_URL}}/messages/
+Authorization: Bearer xxx
+Content-Type: application/json
+
+{
+  "receiver_id": 2048,
+  "type": "MESSAGE",
+  "content_type": "USER_NOTIFICATION",
+  "app_id": "oa_system",
+  "title": "请假申请",
+  "content": {"applyType":"LEAVE","days":2,"businessId":123},
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/leave/123",
+  "action_text": "去审批"
+}
+```
+
+- **`content_type`**:`USER_NOTIFICATION`,用于在私信界面渲染通知样式(与 `type=NOTIFICATION` 的系统通知会话不同,仍归属与对方的私信会话)。
+- **`content`**:可为 JSON 对象;服务端序列化为 JSON 字符串;客户端可解析后逐项展示。
+- **`auto_sso` + `target_url` + `app_id`**:后端生成 `action_url`(jump 链接);接收者点击按钮时应调用 `GET {{API_BASE_URL}}/messages/{message_id}/callback-url` 获取带 ticket 的 `callback_url` 再打开。
+
+响应字段与普通私信一致,`content_type` 为 `USER_NOTIFICATION`,`action_url` / `action_text` 按请求生成。
+
+通知示例(应用签名 + 按 `app_user_id` 投递):
+
+```http
+POST {{API_BASE_URL}}/messages/
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: <HMAC-SHA256 十六进制>
+Content-Type: application/json
+
+{
+  "app_id": "your_app_id_string",
+  "app_user_id": "zhangsan_oa",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批通知",
+  "content": "您有一条待审批任务",
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/todo/123",
+  "action_text": "立即处理"
+}
+```
+
+广播示例(应用签名,全员 `NOTIFICATION`):
+
+```http
+POST {{API_BASE_URL}}/messages/
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: <HMAC-SHA256 十六进制>
+Content-Type: application/json
+
+{
+  "type": "NOTIFICATION",
+  "is_broadcast": true,
+  "content_type": "TEXT",
+  "title": "系统公告",
+  "content": "这是一条全员通知"
+}
+```
+
+通知示例(应用签名 + 已知平台 `receiver_id`):
+
+```http
+POST {{API_BASE_URL}}/messages/
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: <HMAC-SHA256 十六进制>
 Content-Type: application/json
 
 {
+  "receiver_id": 2048,
   "type": "NOTIFICATION",
   "content_type": "TEXT",
   "title": "审批通知",
@@ -73,6 +333,27 @@ Content-Type: application/json
 }
 ```
 
+响应示例(MessageResponse,与上例类似):
+
+```json
+{
+  "id": 10001,
+  "sender_id": null,
+  "receiver_id": 2048,
+  "app_id": 101,
+  "app_name": "OA系统",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批通知",
+  "content": "您有一条待审批任务",
+  "action_url": "/api/v1/simple/sso/jump?app_id=oa_system&redirect_to=https%3A%2F%2Fbiz.example.com%2Ftodo%2F123",
+  "action_text": "立即处理",
+  "is_read": false,
+  "created_at": "2026-03-18T10:02:00",
+  "read_at": null
+}
+```
+
 ### 3.2 通知回调 URL
 
 - **接口**:`GET {{API_BASE_URL}}/messages/{message_id}/callback-url`
@@ -164,6 +445,36 @@ Authorization: Bearer <JWT_TOKEN>
   - `other_user_id < 0`:按应用拆分的系统通知会话
   - `other_user_id = 0`:兼容历史统一系统会话
 
+请求示例(私信):
+
+```http
+GET {{API_BASE_URL}}/messages/history/2048?skip=0&limit=50 HTTP/1.1
+Authorization: Bearer <JWT_TOKEN>
+```
+
+响应示例(MessageResponse 列表):
+
+```json
+[
+  {
+    "id": 501,
+    "sender_id": 10001,
+    "receiver_id": 2048,
+    "app_id": null,
+    "app_name": null,
+    "type": "MESSAGE",
+    "content_type": "TEXT",
+    "title": "私信",
+    "content": "你好",
+    "action_url": null,
+    "action_text": null,
+    "is_read": true,
+    "created_at": "2026-03-18T09:59:00",
+    "read_at": "2026-03-18T10:00:00"
+  }
+]
+```
+
 ---
 
 ## 5. WebSocket 通信接口详情
@@ -206,7 +517,7 @@ Authorization: Bearer <JWT_TOKEN>
 |------|------|------|
 | `id` | number | 消息 ID |
 | `type` | string | **消息业务类型**:`MESSAGE`(私信)/ `NOTIFICATION`(通知) |
-| `content_type` | string | 内容类型:`TEXT` / `IMAGE` / `VIDEO` / `FILE` |
+| `content_type` | string | 内容类型:`TEXT` / `IMAGE` / `VIDEO` / `FILE` / `USER_NOTIFICATION`(用户私信中的申请通知样式) |
 | `title` | string | 标题(私信/通知均可能有) |
 | `content` | string | 文本内容;若为多媒体类型,服务端会下发**预签名 URL**(或兼容历史的完整 URL) |
 | `sender_id` | number \| null | 发送者用户 ID;通知通常为 `null`(系统/应用发出) |
@@ -222,7 +533,7 @@ Authorization: Bearer <JWT_TOKEN>
 只看 `data` 即可,不需要额外接口:
 
 1. **优先看 `data.type`**
-   - `data.type === "MESSAGE"`:这是 **私信**
+   - `data.type === "MESSAGE"`:这是 **私信**(若 `data.content_type === "USER_NOTIFICATION"`,为私信中的「申请通知」样式,仍按对方用户会话归类)
    - `data.type === "NOTIFICATION"`:这是 **通知**(应用或系统)
 
 2. **通知里区分“哪个应用”的通知**
@@ -253,7 +564,7 @@ type WsEvent = {
   data: {
     id: number
     type: 'MESSAGE' | 'NOTIFICATION'
-    content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'
+    content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
     title: string
     content: string
     sender_id: number | null
@@ -312,7 +623,7 @@ type WsPush =
 type WsMessageData = {
   id: number
   type: 'MESSAGE' | 'NOTIFICATION'
-  content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE'
+  content_type: 'TEXT' | 'IMAGE' | 'VIDEO' | 'FILE' | 'USER_NOTIFICATION'
   title: string
   content: string
   sender_id: number | null
@@ -526,6 +837,32 @@ export function startMessageWs(token: string) {
 - **接口**:`GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三`
 - **用途**:管理端完整分页检索
 
+请求示例:
+
+```http
+GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三 HTTP/1.1
+Authorization: Bearer <JWT_TOKEN>
+```
+
+响应示例:
+
+```json
+{
+  "total": 100,
+  "items": [
+    {
+      "id": 2048,
+      "mobile": "13800138000",
+      "name": "张三",
+      "english_name": "zhangsan",
+      "status": "ACTIVE",
+      "role": "ORDINARY_USER",
+      "is_deleted": 0
+    }
+  ]
+}
+```
+
 ---
 
 ## 7. 应用中心(快捷导航)接口
@@ -534,6 +871,7 @@ export function startMessageWs(token: string) {
 
 - **接口**:`GET {{API_BASE_URL}}/simple/me/launchpad-apps`
 - **说明**:返回当前用户可见且已激活的快捷导航应用(包含分类和描述)
+- **说明补充**:该接口是为“快捷导航页”服务的“可见应用列表”接口,不支持按关键字检索应用。若需要应用检索,请使用 `GET {{API_BASE_URL}}/apps/?search=关键字`(受权限约束)。
 
 响应示例:
 
@@ -560,6 +898,28 @@ export function startMessageWs(token: string) {
 
 客户端在“快捷导航”点击应用后,调用统一 SSO 登录接口获取 `redirect_url` 并跳转。
 
+SSO 登录请求示例:
+
+```http
+POST {{API_BASE_URL}}/simple/sso-login
+Authorization: Bearer <JWT_TOKEN>
+Content-Type: application/json
+
+{
+  "app_id": "oa_system",
+  "username": "",
+  "password": ""
+}
+```
+
+响应示例:
+
+```json
+{
+  "redirect_url": "https://oa.example.com/callback?ticket=abc123"
+}
+```
+
 ---
 
 ## 8. 实战接入建议

+ 206 - 0
frontend/src/views/help/ClientApi.vue

@@ -65,6 +65,41 @@ Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
         <li><strong>私信</strong>:使用 <code>type: "MESSAGE"</code> + <code>receiver_id</code></li>
         <li><strong>通知</strong>:使用 <code>type: "NOTIFICATION"</code>(用户侧无权限发送系统通知)</li>
         <li><strong>按外部账号发送</strong>:<code>app_id + app_user_id</code> 组合解析接收者</li>
+        <li><strong>用户申请通知</strong>:<code>type: "MESSAGE"</code> + <code>content_type: "USER_NOTIFICATION"</code>,在私信界面以通知卡片样式展示。可应用发(<code>app_id</code>+<code>app_user_id</code> 接收,可选 <code>sender_app_user_id</code>)或用户 Token 发(<code>receiver_id</code>),支持 <code>action_url</code> 按钮</li>
+      </ul>
+
+      <h4>发送接口调用说明(POST /api/v1/messages/)</h4>
+      <table class="param-table">
+        <thead>
+          <tr><th>项目</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr><td><strong>URL</strong></td><td><code>POST /api/v1/messages/</code>,<code>Content-Type: application/json</code></td></tr>
+          <tr><td><strong>用户认证</strong></td><td><code>Authorization: Bearer &lt;JWT_TOKEN&gt;</code>;仅可发 <code>type: "MESSAGE"</code>,不可发 <code>NOTIFICATION</code></td></tr>
+          <tr><td><strong>应用认证</strong></td><td><code>X-App-Id</code>(字符串 app_id)、<code>X-Timestamp</code>(Unix 秒)、<code>X-Sign</code>;签名:<code>HMAC-SHA256(app_secret, "app_id=...&amp;timestamp=...")</code>,键名按字母序拼接</td></tr>
+        </tbody>
+      </table>
+      <table class="param-table" style="margin-top: 12px;">
+        <thead>
+          <tr><th>请求体字段</th><th>必填</th><th>说明</th></tr>
+        </thead>
+        <tbody>
+          <tr><td><code>type</code></td><td>是</td><td><code>MESSAGE</code> / <code>NOTIFICATION</code></td></tr>
+          <tr><td><code>title</code></td><td>是</td><td>标题,最长 255</td></tr>
+          <tr><td><code>content</code></td><td>是</td><td>文本字符串或 JSON 对象(对象会序列化为合法 JSON 字符串存储)</td></tr>
+          <tr><td><code>content_type</code></td><td>否</td><td>默认 <code>TEXT</code>;可选 <code>IMAGE</code> / <code>VIDEO</code> / <code>FILE</code> / <code>USER_NOTIFICATION</code></td></tr>
+          <tr><td><code>receiver_id</code></td><td>条件</td><td>统一平台用户 ID;与 <code>app_id</code>+<code>app_user_id</code> 二选一(广播时不要填)</td></tr>
+          <tr><td><code>app_id</code></td><td>条件</td><td>应用字符串 ID;与 <code>app_user_id</code> 一起解析接收者;用户发 <code>USER_NOTIFICATION</code> 且 <code>auto_sso</code> 时需填以生成 jump</td></tr>
+          <tr><td><code>app_user_id</code></td><td>条件</td><td>业务侧账号,须在 <code>app_user_mappings</code> 有映射</td></tr>
+          <tr><td><code>sender_app_user_id</code></td><td>否</td><td>应用发信时:发起人业务账号,解析后写入 <code>sender_id</code> 用于审计和会话聚合;用户 JWT 发信忽略</td></tr>
+          <tr><td><code>is_broadcast</code></td><td>否</td><td><code>true</code> 时全员通知;仅 <code>type: NOTIFICATION</code> + 应用签名;勿填接收者</td></tr>
+          <tr><td><code>auto_sso</code> / <code>target_url</code></td><td>否</td><td>为 <code>true</code> 且提供 <code>target_url</code> 时,<code>NOTIFICATION</code> 或 <code>USER_NOTIFICATION</code> 可自动生成 <code>action_url</code>(jump)</td></tr>
+          <tr><td><code>action_url</code> / <code>action_text</code></td><td>否</td><td>自定义按钮链接与文案;若用 <code>callback-url</code> 接口,jump 格式需与后端约定一致</td></tr>
+        </tbody>
+      </table>
+      <ul style="margin-top: 12px;">
+        <li><strong>接收者</strong>:非广播时须满足其一——<code>receiver_id</code>,或同时 <code>app_id</code> + <code>app_user_id</code>。</li>
+        <li><strong>响应</strong>:<code>201</code> 返回消息体(含 <code>id</code>、<code>action_url</code> 等);失败常见 <code>403</code>(权限)、<code>404</code>(用户/映射不存在)、<code>422</code>(参数校验)。</li>
       </ul>
 
       <h4>示例:发送私信(用户 Token)</h4>
@@ -102,6 +137,177 @@ Content-Type: application/json
   "action_text": "立即处理"
 }</pre>
       </div>
+
+      <h4>示例:应用通知指定业务账号(app_user_id)</h4>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+X-App-Id: your_app_id_string
+X-Timestamp: 1708848000
+X-Sign: &lt;HMAC-SHA256 十六进制&gt;
+Content-Type: application/json
+
+{
+  "app_id": "your_app_id_string",
+  "app_user_id": "zhangsan_oa",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "审批通知",
+  "content": "您有一条待审批任务",
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/todo/123",
+  "action_text": "立即处理"
+}</pre>
+      </div>
+      <p class="note">应用签名且用 <code>app_user_id</code> 定位接收者时,Body 须同时带 <code>app_id</code>,且与 <code>X-App-Id</code> 一致,否则返回 403。</p>
+
+      <h4>示例:应用发 USER_NOTIFICATION(应用签名,按 app_user_id 接收)</h4>
+      <p class="intro" style="margin-top: 8px;">
+        应用代发用户申请通知:如 OA 系统代「申请人张三」通知「经理李四」。接收者用 <code>app_id</code>+<code>app_user_id</code> 定位;可选 <code>sender_app_user_id</code> 标识发起人,便于审计和会话聚合。
+      </p>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+X-App-Id: your_app_id
+X-Timestamp: 1708848000
+X-Sign: &lt;HMAC-SHA256&gt;
+Content-Type: application/json
+
+{
+  "app_id": "oa_system",
+  "app_user_id": "lisi_manager",
+  "sender_app_user_id": "zhangsan_oa",
+  "type": "MESSAGE",
+  "content_type": "USER_NOTIFICATION",
+  "title": "请假申请",
+  "content": {"applyType":"LEAVE","days":2,"businessId":123},
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/leave/123",
+  "action_text": "去审批"
+}</pre>
+      </div>
+
+      <h4>示例:用户发 USER_NOTIFICATION(用户 Token,重新提醒等)</h4>
+      <p class="intro" style="margin-top: 8px;">
+        用户本人发起:如申请人向经理发送提醒;<code>sender_id</code> 为当前用户,<code>receiver_id</code> 指定接收者。
+      </p>
+      <div class="code-block">
+        <pre>
+POST /api/v1/messages/ HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;
+Content-Type: application/json
+
+{
+  "receiver_id": 2048,
+  "type": "MESSAGE",
+  "content_type": "USER_NOTIFICATION",
+  "app_id": "oa_system",
+  "title": "请假申请",
+  "content": {"applyType":"LEAVE","days":2,"businessId":123},
+  "auto_sso": true,
+  "target_url": "https://biz.example.com/leave/123",
+  "action_text": "去审批"
+}</pre>
+      </div>
+
+      <h4>Python 调用示例(requests)</h4>
+      <p class="intro">需安装:<code>pip install requests</code>。将 <code>API_BASE</code>、<code>APP_ID</code>、<code>APP_SECRET</code>、<code>ACCESS_TOKEN</code> 替换为实际值。</p>
+      <div class="code-block">
+        <pre>
+import hmac
+import hashlib
+import time
+import requests
+
+API_BASE = "https://your-host.example.com/api/v1"  # 无尾斜杠
+APP_ID = "your_app_id_string"
+APP_SECRET = "your_app_secret"
+
+
+def app_sign_headers() -&gt; dict:
+    ts = str(int(time.time()))
+    query_string = f"app_id={APP_ID}&amp;timestamp={ts}"
+    sign = hmac.new(
+        APP_SECRET.encode("utf-8"),
+        query_string.encode("utf-8"),
+        hashlib.sha256,
+    ).hexdigest()
+    return {
+        "X-App-Id": APP_ID,
+        "X-Timestamp": ts,
+        "X-Sign": sign,
+        "Content-Type": "application/json",
+    }
+
+
+# 1) 应用签名:按 app_user_id 发送通知
+def send_notification_by_app_user():
+    url = f"{API_BASE}/messages/"
+    payload = {
+        "app_id": APP_ID,
+        "app_user_id": "zhangsan_oa",
+        "type": "NOTIFICATION",
+        "content_type": "TEXT",
+        "title": "审批通知",
+        "content": "您有一条待审批任务",
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/todo/123",
+        "action_text": "立即处理",
+    }
+    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+# 2) 应用发 USER_NOTIFICATION(代发起人通知接收者)
+def send_user_notification_by_app(app_user_id: str, sender_app_user_id: str = None):
+    url = f"{API_BASE}/messages/"
+    payload = {
+        "app_id": APP_ID,
+        "app_user_id": app_user_id,
+        "type": "MESSAGE",
+        "content_type": "USER_NOTIFICATION",
+        "title": "请假申请",
+        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/leave/123",
+        "action_text": "去审批",
+    }
+    if sender_app_user_id:
+        payload["sender_app_user_id"] = sender_app_user_id
+    r = requests.post(url, headers=app_sign_headers(), json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+# 3) 用户 Token:发送 USER_NOTIFICATION(用户本人发,如重新提醒)
+def send_user_notification(access_token: str):
+    url = f"{API_BASE}/messages/"
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Content-Type": "application/json",
+    }
+    payload = {
+        "receiver_id": 2048,
+        "type": "MESSAGE",
+        "content_type": "USER_NOTIFICATION",
+        "app_id": "oa_system",
+        "title": "请假申请",
+        "content": {"applyType": "LEAVE", "days": 2, "businessId": 123},
+        "auto_sso": True,
+        "target_url": "https://biz.example.com/leave/123",
+        "action_text": "去审批",
+    }
+    r = requests.post(url, headers=headers, json=payload, timeout=30)
+    r.raise_for_status()
+    return r.json()
+
+
+if __name__ == "__main__":
+    print(send_notification_by_app_user())
+    # print(send_user_notification_by_app("lisi_manager", "zhangsan_oa"))
+    # print(send_user_notification("eyJhbGciOiJIUzI1NiIsInR5cCI6..."))</pre>
+      </div>
     </div>
 
     <div class="section">

+ 80 - 23
frontend/src/views/message/index.vue

@@ -138,25 +138,60 @@
               
               <div class="message-content-wrapper">
                  <div class="message-bubble">
-                   <!-- TEXT -->
-                   <span v-if="msg.content_type === 'TEXT'">{{ msg.content }}</span>
-                   
-                   <!-- IMAGE -->
-                   <el-image 
-                     v-else-if="msg.content_type === 'IMAGE'" 
-                     :src="msg.content" 
-                     :preview-src-list="[msg.content]"
-                     class="msg-image"
-                   />
-                   
-                   <!-- FILE -->
-                   <div v-else-if="msg.content_type === 'FILE'" class="msg-file">
-                      <el-icon><Document /></el-icon>
-                      <a :href="msg.content" target="_blank">下载文件</a>
-                   </div>
-                   
-                   <!-- VIDEO -->
-                   <video v-else-if="msg.content_type === 'VIDEO'" :src="msg.content" controls class="msg-video"></video>
+                  <!-- 用户应用内“申请通知”:在私信界面展示通知样式(title + action_url) -->
+                  <template v-if="msg.content_type === 'USER_NOTIFICATION'">
+                    <div class="user-notification-title">{{ msg.title || '申请通知' }}</div>
+
+                    <div class="notification-content">
+                      <!-- 如果内容是 JSON 格式,解析为键值对 -->
+                      <template v-if="isJsonContent(msg.content)">
+                        <div
+                          v-for="(value, key) in parseJsonContent(msg.content)"
+                          :key="key"
+                          class="notification-detail-item"
+                        >
+                          <span class="detail-label">{{ key }}:</span>
+                          <span class="detail-value">{{ value }}</span>
+                        </div>
+                      </template>
+                      <!-- 普通文本内容 -->
+                      <template v-else>
+                        <div class="notification-text">{{ msg.content }}</div>
+                      </template>
+                    </div>
+
+                    <!-- 操作按钮(复用通知点击回调逻辑) -->
+                    <div v-if="msg.action_url" class="notification-action">
+                      <a
+                        :href="msg.action_url"
+                        class="action-link"
+                        @click.prevent="handleNotificationAction(msg)"
+                      >
+                        <span>{{ msg.action_text || '查看' }}</span>
+                        <el-icon class="action-icon"><ArrowRight /></el-icon>
+                      </a>
+                    </div>
+                  </template>
+
+                  <!-- TEXT -->
+                  <span v-else-if="msg.content_type === 'TEXT'">{{ msg.content }}</span>
+
+                  <!-- IMAGE -->
+                  <el-image
+                    v-else-if="msg.content_type === 'IMAGE'"
+                    :src="msg.content"
+                    :preview-src-list="[msg.content]"
+                    class="msg-image"
+                  />
+
+                  <!-- FILE -->
+                  <div v-else-if="msg.content_type === 'FILE'" class="msg-file">
+                    <el-icon><Document /></el-icon>
+                    <a :href="msg.content" target="_blank">下载文件</a>
+                  </div>
+
+                  <!-- VIDEO -->
+                  <video v-else-if="msg.content_type === 'VIDEO'" :src="msg.content" controls class="msg-video"></video>
                  </div>
                  <div class="message-time">{{ formatTime(msg.created_at) }}</div>
               </div>
@@ -292,13 +327,15 @@ const handleWsMessage = (newMessage: any) => {
     updateConversationPreview(systemConvId, newMessage.content, newMessage.content_type, {
       is_system: true,
       app_id: newMessage.app_id,
-      app_name: newMessage.app_name
+      app_name: newMessage.app_name,
+      title: newMessage.title
     })
   } else {
     updateConversationPreview(
       newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
       newMessage.content,
-      newMessage.content_type
+      newMessage.content_type,
+      newMessage.content_type === 'USER_NOTIFICATION' ? { title: newMessage.title } : undefined
     )
   }
 }
@@ -410,11 +447,18 @@ const updateConversationPreview = (
   userId: number,
   content: string,
   type: string,
-  extra?: { is_system?: boolean; app_id?: number; app_name?: string }
+  extra?: { is_system?: boolean; app_id?: number; app_name?: string; title?: string }
 ) => {
   const conv = conversations.value.find(c => c.user_id === userId)
   if (conv) {
-    conv.last_message = type === 'TEXT' ? content : `[${type}]`
+    if (type === 'USER_NOTIFICATION') {
+      const t = (extra?.title && String(extra.title).trim()) || ''
+      conv.last_message = t || '申请通知'
+    } else if (type === 'TEXT') {
+      conv.last_message = content
+    } else {
+      conv.last_message = `[${type}]`
+    }
     conv.last_message_type = type
     conv.updated_at = new Date().toISOString()
 
@@ -716,6 +760,19 @@ const handleNotificationAction = async (msg: any) => {
   background: #95ec69; /* WeChat green */
 }
 
+/* 私信气泡内的用户通知:标题需醒目(与普通文本区分) */
+.user-notification-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1a1a1a;
+  margin-bottom: 10px;
+  line-height: 1.4;
+}
+
+.message-row.is-me .user-notification-title {
+  color: #0f3d0f;
+}
+
 .message-time {
   font-size: 12px;
   color: #b2b2b2;