liuq 1 месяц назад
Родитель
Сommit
ea1b646b9b

+ 7 - 1
backend/app/api/v1/api.py

@@ -3,7 +3,8 @@ from fastapi import APIRouter
 from app.api.v1.endpoints import (
 from app.api.v1.endpoints import (
     auth, users, apps, utils, simple_auth, oidc, 
     auth, users, apps, utils, simple_auth, oidc, 
     open_api, logs, system_logs, backup, login_logs, 
     open_api, logs, system_logs, backup, login_logs, 
-    user_import, system, system_config, sms_auth
+    user_import, system, system_config, sms_auth,
+    messages, messages_upload, ws
 )
 )
 
 
 api_router = APIRouter()
 api_router = APIRouter()
@@ -22,3 +23,8 @@ api_router.include_router(simple_auth.router, prefix="/simple", tags=["简易认
 api_router.include_router(sms_auth.router, prefix="/auth/sms", tags=["短信认证 (SMS Auth)"])
 api_router.include_router(sms_auth.router, prefix="/auth/sms", tags=["短信认证 (SMS Auth)"])
 api_router.include_router(oidc.router, prefix="/oidc", tags=["OIDC (OpenID Connect)"])
 api_router.include_router(oidc.router, prefix="/oidc", tags=["OIDC (OpenID Connect)"])
 api_router.include_router(open_api.router, prefix="/open", tags=["开放接口 (OpenAPI)"])
 api_router.include_router(open_api.router, prefix="/open", tags=["开放接口 (OpenAPI)"])
+
+# 消息通知模块
+api_router.include_router(messages.router, prefix="/messages", tags=["消息通知 (Messages)"])
+api_router.include_router(messages_upload.router, prefix="/messages", tags=["消息附件 (Upload)"])
+api_router.include_router(ws.router, prefix="/ws", tags=["WebSocket (Realtime)"])

+ 52 - 4
backend/app/api/v1/deps.py

@@ -1,5 +1,5 @@
-from typing import Generator, Optional
-from fastapi import Depends, HTTPException, status, Response
+from typing import Generator, Optional, Union
+from fastapi import Depends, HTTPException, status, Response, Header
 from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from jose import jwt, JWTError
 from jose import jwt, JWTError
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
@@ -10,6 +10,7 @@ from app.core.database import SessionLocal
 from app.models.user import User
 from app.models.user import User
 from app.models.application import Application
 from app.models.application import Application
 from app.schemas.token import TokenPayload
 from app.schemas.token import TokenPayload
+from app.services.signature_service import SignatureService
 
 
 reusable_oauth2 = OAuth2PasswordBearer(
 reusable_oauth2 = OAuth2PasswordBearer(
     tokenUrl=f"{settings.API_V1_STR}/auth/login",
     tokenUrl=f"{settings.API_V1_STR}/auth/login",
@@ -58,7 +59,7 @@ def get_current_user(
             if remaining_seconds < threshold:
             if remaining_seconds < threshold:
                 expires_delta = None
                 expires_delta = None
                 if is_long_term:
                 if is_long_term:
-                     expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+                    expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
                 
                 
                 # Issue new token
                 # Issue new token
                 new_token = security.create_access_token(
                 new_token = security.create_access_token(
@@ -126,7 +127,7 @@ def get_current_user_optional(
             if remaining_seconds < threshold:
             if remaining_seconds < threshold:
                 expires_delta = None
                 expires_delta = None
                 if is_long_term:
                 if is_long_term:
-                     expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+                    expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
                      
                      
                 new_token = security.create_access_token(
                 new_token = security.create_access_token(
                     subject=token_data.sub, 
                     subject=token_data.sub, 
@@ -201,3 +202,50 @@ def get_current_app(
     if not app:
     if not app:
         raise HTTPException(status_code=404, detail="App not found")
         raise HTTPException(status_code=404, detail="App not found")
     return app
     return app
+
+# 定义一个联合类型,表示调用者可能是用户,也可能是应用
+AuthSubject = Union[User, Application]
+
+def get_current_user_or_app(
+    # --- 用户认证参数 ---
+    token: Optional[str] = Depends(reusable_oauth2),
+    
+    # --- 应用认证参数 (Header 方式) ---
+    x_app_id: Optional[str] = Header(None, alias="X-App-Id"),
+    x_timestamp: Optional[str] = Header(None, alias="X-Timestamp"),
+    x_sign: Optional[str] = Header(None, alias="X-Sign"),
+    
+    # --- 数据库会话 ---
+    db: Session = Depends(get_db)
+) -> AuthSubject:
+    
+    # 1. 尝试用户认证 (JWT)
+    if token:
+        try:
+            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+            token_data = TokenPayload(**payload)
+            if token_data.sub and token_data.sub.isdigit():
+                user = db.query(User).filter(User.id == int(token_data.sub)).first()
+                if user and user.status == "ACTIVE":
+                    return user
+        except:
+            pass # Token 无效,继续尝试应用认证
+
+    # 2. 尝试应用认证 (签名)
+    if x_app_id and x_timestamp and x_sign:
+        app = db.query(Application).filter(Application.app_id == x_app_id).first()
+        if app:
+            # 验证签名
+            params = {
+                "app_id": x_app_id,
+                "timestamp": x_timestamp,
+                "sign": x_sign
+            }
+            if SignatureService.verify_signature(app.app_secret, params, x_sign):
+                return app
+    
+    # 3. 均未通过
+    raise HTTPException(
+        status_code=401, 
+        detail="Authentication failed: Invalid Token or Signature"
+    )

+ 359 - 0
backend/app/api/v1/endpoints/messages.py

@@ -0,0 +1,359 @@
+from typing import Any, List, Optional
+from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
+from sqlalchemy.orm import Session
+from sqlalchemy import or_, and_, desc
+from app.core.database import get_db
+from app.api.v1 import deps
+from app.api.v1.deps import AuthSubject, get_current_user_or_app
+from app.models.message import Message, MessageType
+from app.models.user import User
+from app.models.application import Application
+from app.models.mapping import AppUserMapping
+from app.schemas.message import MessageCreate, MessageResponse, MessageUpdate, ContentType, ConversationResponse
+from app.core.websocket_manager import manager
+from app.core.config import settings
+from app.core.minio import minio_storage
+from datetime import datetime
+from urllib.parse import quote, urlparse
+
+router = APIRouter()
+
+def _process_message_content(message: Message) -> MessageResponse:
+    """处理消息内容,为文件类型生成预签名 URL"""
+    # Pydantic v2 use model_validate
+    response = MessageResponse.model_validate(message)
+    
+    if message.content_type in [ContentType.IMAGE, ContentType.VIDEO, ContentType.FILE]:
+        # 如果内容是对象 Key (不以 http 开头),则生成预签名 URL
+        # 如果是旧数据的完整 URL,则保持不变 (或视需求处理)
+        if message.content and not message.content.startswith("http"):
+            signed_url = minio_storage.get_presigned_url(message.content)
+            if signed_url:
+                response.content = signed_url
+    
+    return response
+
+@router.post("/", response_model=MessageResponse)
+async def create_message(
+    *,
+    db: Session = Depends(get_db),
+    message_in: MessageCreate,
+    current_subject: AuthSubject = Depends(get_current_user_or_app),
+    background_tasks: BackgroundTasks
+) -> Any:
+    """
+    发送消息 (支持用户私信和应用通知)
+    权限:
+    - 用户:只能发送 MESSAGE
+    - 应用:可以发送 NOTIFICATION 或 MESSAGE
+    """
+    sender_id = None
+    app_id = None
+
+    # 1. 鉴权与身份识别
+    if isinstance(current_subject, User):
+        # 用户发送
+        if message_in.type == MessageType.NOTIFICATION:
+            raise HTTPException(status_code=403, detail="普通用户无权发送系统通知")
+        sender_id = current_subject.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
+
+    # 2. 确定接收者 (Receiver Resolution)
+    final_receiver_id = None
+
+    if 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()
+        if not user:
+            raise HTTPException(status_code=404, detail="接收用户未找到")
+            
+    elif message_in.app_user_id and message_in.app_id:
+        # 方式 B: 通过 App User ID 查找映射
+        # 注意:如果是用户发送,必须要提供 app_id 才能查映射
+        # 如果是应用发送,message_in.app_id 已经被赋值为 current_subject.id
+        
+        mapping = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == message_in.app_id,
+            AppUserMapping.app_user_id == message_in.app_user_id
+        ).first()
+        
+        if not mapping:
+            raise HTTPException(
+                status_code=404, 
+                detail=f"用户映射未找到: App {message_in.app_id}, User {message_in.app_user_id}"
+            )
+        final_receiver_id = mapping.user_id
+    else:
+        raise HTTPException(status_code=400, detail="必须指定 receiver_id 或 (app_id + app_user_id)")
+
+    # 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}
+        
+        # 假设 settings.SERVER_HOST 配置了当前服务地址,如果没有则使用相对路径或默认值
+        # 这里为了演示,假设前端或API base url
+        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}"
+
+    # 处理内容 (如果是文件类型且传入的是 URL,尝试提取 Key)
+    content_val = message_in.content if isinstance(message_in.content, str) else 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:
+            try:
+                # 尝试从 URL 中提取 path
+                parsed = urlparse(content_val)
+                path = parsed.path.lstrip('/')
+                # path 可能是 "bucket_name/object_key"
+                if path.startswith(settings.MINIO_BUCKET_NAME + "/"):
+                    content_val = path[len(settings.MINIO_BUCKET_NAME)+1:]
+            except:
+                pass # 提取失败则保持原样
+
+    # 4. 创建消息
+    message = Message(
+        sender_id=sender_id,
+        receiver_id=final_receiver_id,
+        app_id=message_in.app_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)
+        }
+    }
+    
+    # 使用后台任务发送 WS 消息,避免阻塞 HTTP 响应
+    # 如果是发给自己,receiver_id == sender_id,ws 会收到一次
+    background_tasks.add_task(manager.send_personal_message, push_payload, final_receiver_id)
+
+    return processed_msg
+
+@router.get("/", response_model=List[MessageResponse])
+def read_messages(
+    db: Session = Depends(get_db),
+    skip: int = 0,
+    limit: int = 100,
+    unread_only: bool = False,
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    获取当前用户的消息列表 (所有历史记录)
+    """
+    query = db.query(Message).filter(Message.receiver_id == current_user.id)
+    if unread_only:
+        query = query.filter(Message.is_read == False)
+    
+    messages = query.order_by(Message.created_at.desc()).offset(skip).limit(limit).all()
+    
+    # 处理文件 URL 签名
+    return [_process_message_content(msg) for msg in messages]
+
+@router.get("/conversations", response_model=List[ConversationResponse])
+def get_conversations(
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user)
+) -> Any:
+    """
+    获取当前用户的会话列表 (聚合)
+    """
+    # 查找所有与我相关的消息
+    messages = db.query(Message).filter(
+        or_(
+            Message.sender_id == current_user.id,
+            Message.receiver_id == current_user.id
+        )
+    ).order_by(Message.created_at.desc()).limit(1000).all()
+
+    conversations_map = {}
+    
+    for msg in messages:
+        # 确定对话方 (Counterpart)
+        other_id = None
+        other_user = None
+
+        if msg.type == MessageType.NOTIFICATION:
+             # 系统通知,归类为一个特殊的 ID 0
+             other_id = 0
+             # No user object needed for system
+        elif msg.sender_id == current_user.id and msg.receiver_id == current_user.id:
+            # 文件传输助手
+            other_id = current_user.id
+            other_user = current_user
+        elif msg.sender_id == current_user.id:
+            other_id = msg.receiver_id
+            other_user = msg.receiver
+        else:
+            other_id = msg.sender_id
+            other_user = msg.sender
+            
+        # 如果是私信但没找到用户,跳过
+        if other_id != 0 and not other_user:
+            continue
+
+        # 如果这个对话方还没处理过
+        if other_id not in conversations_map:
+            if other_id == 0:
+                username = "System"
+                full_name = "系统通知"
+            else:
+                username = other_user.mobile  # User has mobile, not username
+                full_name = other_user.name or other_user.english_name or other_user.mobile
+
+            conversations_map[other_id] = {
+                "user_id": other_id,
+                "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_type": msg.content_type,
+                "updated_at": msg.created_at
+            }
+        
+        # 累加未读数 (只计算接收方是自己的未读消息)
+        # 注意: 这里的 is_read 是针对接收者的状态
+        # 即使是自己发送的消息,msg.receiver_id 也不会是自己(除非发给自己)
+        # 所以这里的判断逻辑是: 如果我是接收者,且未读,则计入未读数
+        if not msg.is_read and msg.receiver_id == current_user.id:
+             conversations_map[other_id]["unread_count"] += 1
+
+    return list(conversations_map.values())
+
+@router.get("/history/{other_user_id}", response_model=List[MessageResponse])
+def get_chat_history(
+    other_user_id: int,
+    skip: int = 0,
+    limit: int = 50,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user)
+) -> Any:
+    """
+    获取与特定用户的聊天记录
+    """
+    if other_user_id == 0:
+        # System Notifications
+        query = db.query(Message).filter(
+            Message.receiver_id == current_user.id,
+            Message.type == MessageType.NOTIFICATION
+        ).order_by(Message.created_at.desc())
+    else:
+        # User Chat
+        query = db.query(Message).filter(
+            or_(
+                and_(Message.sender_id == current_user.id, Message.receiver_id == other_user_id),
+                and_(Message.sender_id == other_user_id, Message.receiver_id == current_user.id)
+            )
+        ).order_by(Message.created_at.desc()) # 最新在前
+    
+    messages = query.offset(skip).limit(limit).all()
+    
+    return [_process_message_content(msg) for msg in messages]
+
+@router.get("/unread-count", response_model=int)
+def get_unread_count(
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    count = db.query(Message).filter(
+        Message.receiver_id == current_user.id,
+        Message.is_read == False
+    ).count()
+    return count
+
+@router.put("/{message_id}/read", response_model=MessageResponse)
+def mark_as_read(
+    message_id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    message = db.query(Message).filter(
+        Message.id == message_id,
+        Message.receiver_id == current_user.id
+    ).first()
+    if not message:
+        raise HTTPException(status_code=404, detail="Message not found")
+    
+    if not message.is_read:
+        message.is_read = True
+        message.read_at = datetime.now()
+        db.add(message)
+        db.commit()
+        db.refresh(message)
+        
+    return _process_message_content(message)
+
+@router.put("/read-all", response_model=dict)
+def mark_all_read(
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    now = datetime.now()
+    result = db.query(Message).filter(
+        Message.receiver_id == current_user.id,
+        Message.is_read == False
+    ).update(
+        {
+            "is_read": True,
+            "read_at": now
+        },
+        synchronize_session=False
+    )
+    db.commit()
+    return {"updated_count": result}
+
+@router.delete("/{message_id}", response_model=MessageResponse)
+def delete_message(
+    message_id: int,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    message = db.query(Message).filter(
+        Message.id == message_id,
+        Message.receiver_id == current_user.id
+    ).first()
+    if not message:
+        raise HTTPException(status_code=404, detail="Message not found")
+    
+    # 先处理返回数据,避免删除后无法访问
+    processed_msg = _process_message_content(message)
+    
+    db.delete(message)
+    db.commit()
+    return processed_msg

+ 62 - 0
backend/app/api/v1/endpoints/messages_upload.py

@@ -0,0 +1,62 @@
+from fastapi import APIRouter, UploadFile, File, HTTPException, Depends
+from typing import List
+from app.api.v1 import deps
+from app.models.user import User
+from app.core.minio import minio_storage
+
+router = APIRouter()
+
+# 允许的文件类型白名单
+ALLOWED_MIME_TYPES = {
+    "image/jpeg", "image/png", "image/gif", "image/webp",  # 图片
+    "video/mp4", "video/quicktime", "video/x-msvideo",     # 视频
+    "application/pdf", "application/msword",               # 文档
+    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+    "application/vnd.ms-excel",
+    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    "text/plain"
+}
+
+MAX_FILE_SIZE = 50 * 1024 * 1024  # 50MB
+
+@router.post("/upload", summary="上传消息附件")
+async def upload_message_attachment(
+    file: UploadFile = File(...),
+    current_user: User = Depends(deps.get_current_active_user)
+):
+    """
+    上传图片/视频/文件用于发送消息。
+    """
+    # 2. 读取文件内容
+    content = await file.read()
+    
+    if len(content) > MAX_FILE_SIZE:
+         raise HTTPException(status_code=400, detail="文件大小超过 50MB 限制")
+
+    # 3. 上传到 MinIO
+    try:
+        filename = file.filename
+        
+        # 调用 minio_storage.upload_message_file
+        # 注意:这里需要确保 minio_storage.upload_message_file 支持 file_data 参数为 bytes
+        object_name = minio_storage.upload_message_file(
+            file_data=content,
+            filename=filename,
+            content_type=file.content_type,
+            user_id=current_user.id
+        )
+        
+        # 生成预签名 URL (1小时有效)
+        presigned_url = minio_storage.get_presigned_url(object_name)
+        
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+    # 4. 返回结果
+    return {
+        "url": presigned_url,     
+        "key": object_name,       
+        "filename": file.filename,
+        "content_type": file.content_type,
+        "size": len(content)
+    }

+ 64 - 0
backend/app/api/v1/endpoints/simple_auth.py

@@ -3,8 +3,10 @@ import json
 from datetime import timedelta
 from datetime import timedelta
 import logging
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Body, Request
 from fastapi import APIRouter, Depends, HTTPException, Body, Request
+from fastapi.responses import RedirectResponse
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
 from pydantic import BaseModel
+from urllib.parse import urlencode, urlparse, parse_qs, urlunparse
 
 
 from app.api.v1 import deps
 from app.api.v1 import deps
 from app.core import security
 from app.core import security
@@ -767,3 +769,65 @@ def validate_ticket(
         "mapped_key": mapped_key,
         "mapped_key": mapped_key,
         "mapped_email": mapped_email
         "mapped_email": mapped_email
     }
     }
+
+@router.get("/sso/jump", summary="通知跳转 SSO")
+def sso_jump(
+    app_id: str, # 应用 ID
+    redirect_to: str, # 最终目标页面
+    request: Request,
+    db: Session = Depends(deps.get_db),
+    current_user: Optional[User] = Depends(deps.get_current_active_user_optional),
+):
+    """
+    用于消息通知的 SSO 跳转接口。
+    """
+    
+    # 1. 检查应用是否存在
+    app = db.query(Application).filter(Application.app_id == app_id).first()
+    if not app:
+        raise HTTPException(status_code=404, detail="应用未找到")
+
+    # 2. 检查用户是否登录
+    if not current_user:
+        # 未登录 -> 跳转到统一登录页
+        # 假设前端部署在 HTTP_REFERER 或配置的 FRONTEND_HOST (暂用相对路径)
+        login_page = "/login"
+        params = {"redirect": str(request.url)}
+        return RedirectResponse(f"{login_page}?{urlencode(params)}")
+
+    # 3. 用户已登录 -> 生成 Ticket
+    ticket = TicketService.generate_ticket(current_user.id, app_id)
+
+    # 4. 获取应用回调地址
+    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:
+            redirect_base = app.redirect_uris.strip()
+
+    if not redirect_base:
+        raise HTTPException(status_code=400, detail="应用未配置回调地址")
+
+    # 5. 构造最终跳转 URL
+    parsed_uri = urlparse(redirect_base)
+    query_params = parse_qs(parsed_uri.query)
+    
+    query_params['ticket'] = [ticket]
+    query_params['next'] = [redirect_to]
+    
+    new_query = urlencode(query_params, doseq=True)
+    full_redirect_url = urlunparse((
+        parsed_uri.scheme,
+        parsed_uri.netloc,
+        parsed_uri.path,
+        parsed_uri.params,
+        new_query,
+        parsed_uri.fragment
+    ))
+    
+    return RedirectResponse(full_redirect_url)

+ 10 - 9
backend/app/api/v1/endpoints/users.py

@@ -21,26 +21,27 @@ logger = logging.getLogger(__name__)
 
 
 @router.get("/search", response_model=List[UserSchema], summary="搜索用户")
 @router.get("/search", response_model=List[UserSchema], summary="搜索用户")
 def search_users(
 def search_users(
-    keyword: str = Query(None),
+    keyword: str = Query(None, alias="q"),
     limit: int = 20,
     limit: int = 20,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
     current_user: User = Depends(deps.get_current_active_user),
     current_user: User = Depends(deps.get_current_active_user),
 ):
 ):
     """
     """
-    搜索可作为应用转让目标的用户(开发者或超级管理员)。
+    搜索用户(支持姓名、手机号、英文名)。
     """
     """
-    # Only Developers and Super Admins can search
-    if current_user.role not in ["SUPER_ADMIN", "DEVELOPER"]:
-        raise HTTPException(status_code=403, detail="权限不足")
-
     query = db.query(User).filter(
     query = db.query(User).filter(
         User.is_deleted == 0,
         User.is_deleted == 0,
-        User.status == "ACTIVE",
-        User.role.in_(["DEVELOPER", "SUPER_ADMIN"])
+        User.status == "ACTIVE"
     )
     )
     
     
     if keyword:
     if keyword:
-        query = query.filter(User.mobile.ilike(f"%{keyword}%"))
+        query = query.filter(
+            or_(
+                User.mobile.ilike(f"%{keyword}%"),
+                User.name.ilike(f"%{keyword}%"),
+                User.english_name.ilike(f"%{keyword}%")
+            )
+        )
     
     
     # Exclude self
     # Exclude self
     query = query.filter(User.id != current_user.id)
     query = query.filter(User.id != current_user.id)

+ 46 - 0
backend/app/api/v1/endpoints/ws.py

@@ -0,0 +1,46 @@
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, Depends
+from app.core.websocket_manager import manager
+from app.core import security
+from app.core.config import settings
+from jose import jwt, JWTError
+from app.api.v1 import deps
+from sqlalchemy.orm import Session
+from app.core.database import get_db
+from app.models.user import User
+
+router = APIRouter()
+
+async def get_user_from_token(token: str, db: Session):
+    try:
+        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+        user_id = payload.get("sub")
+        if user_id is None:
+            return None
+        return db.query(User).filter(User.id == int(user_id)).first()
+    except JWTError:
+        return None
+
+@router.websocket("/messages")
+async def websocket_endpoint(
+    websocket: WebSocket,
+    token: str = Query(...),
+    db: Session = Depends(get_db)
+):
+    """
+    全平台通用的 WebSocket 连接端点
+    连接 URL: ws://host/api/v1/ws/messages?token=YOUR_JWT_TOKEN
+    """
+    user = await get_user_from_token(token, db)
+    if not user:
+        await websocket.close(code=4001, reason="Authentication failed")
+        return
+
+    await manager.connect(websocket, user.id)
+    try:
+        while True:
+            # 接收客户端心跳
+            data = await websocket.receive_text()
+            if data == "ping":
+                await websocket.send_text("pong")
+    except WebSocketDisconnect:
+        manager.disconnect(websocket, user.id)

+ 7 - 0
backend/app/core/config.py

@@ -54,6 +54,13 @@ class Settings(BaseSettings):
     ALIYUN_SMS_SIGN_NAME: Optional[str] = "速通互联验证服务"
     ALIYUN_SMS_SIGN_NAME: Optional[str] = "速通互联验证服务"
     ALIYUN_SMS_TEMPLATE_CODE: Optional[str] = "100001"
     ALIYUN_SMS_TEMPLATE_CODE: Optional[str] = "100001"
     
     
+    # MinIO
+    MINIO_ENDPOINT: str = "https://api.hnyunzhu.com:9004"
+    MINIO_ACCESS_KEY: str = "1jC3nmjfWAyMGJ3pinbT"
+    MINIO_SECRET_KEY: str = "6FHE9lzeQ6rZ3wIXSA9jGu8pGf49vgwT18NJ9XpO"
+    MINIO_BUCKET_NAME: str = "unified-message-files"
+    MINIO_SECURE: bool = False
+    
     class Config:
     class Config:
         case_sensitive = True
         case_sensitive = True
         env_file = ".env"
         env_file = ".env"

+ 95 - 0
backend/app/core/minio.py

@@ -0,0 +1,95 @@
+from minio import Minio
+from minio.error import S3Error
+from app.core.config import settings
+import io
+import uuid
+from datetime import datetime
+import logging
+
+logger = logging.getLogger(__name__)
+
+class MessageStorage:
+    def __init__(self):
+        try:
+            # Handle endpoint protocol
+            endpoint = settings.MINIO_ENDPOINT
+            secure = settings.MINIO_SECURE
+            
+            if endpoint.startswith("http://"):
+                endpoint = endpoint.replace("http://", "")
+                secure = False
+            elif endpoint.startswith("https://"):
+                endpoint = endpoint.replace("https://", "")
+                secure = True
+                
+            self.client = Minio(
+                endpoint=endpoint,
+                access_key=settings.MINIO_ACCESS_KEY,
+                secret_key=settings.MINIO_SECRET_KEY,
+                secure=secure
+            )
+            self.bucket_name = settings.MINIO_BUCKET_NAME
+            self._ensure_bucket()
+        except Exception as e:
+            logger.error(f"MinIO init failed: {e}")
+            self.client = None
+
+    def _ensure_bucket(self):
+        if not self.client: return
+        try:
+            if not self.client.bucket_exists(self.bucket_name):
+                self.client.make_bucket(self.bucket_name)
+                # 移除公开读策略,默认为私有
+                # self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
+        except S3Error as e:
+            logger.error(f"MinIO bucket error: {e}")
+
+    def get_presigned_url(self, object_name: str, expires=None):
+        """生成预签名访问链接"""
+        if not self.client: return None
+        try:
+            from datetime import timedelta
+            if expires is None:
+                expires = timedelta(hours=1)
+                
+            return self.client.get_presigned_url(
+                "GET",
+                self.bucket_name,
+                object_name,
+                expires=expires
+            )
+        except Exception as e:
+            logger.error(f"Generate presigned url failed: {e}")
+            return None
+
+    def upload_message_file(self, file_data: bytes, filename: str, content_type: str, user_id: int) -> str:
+        """
+        上传消息附件
+        路径格式: messages/{user_id}/{year}/{month}/{uuid}.ext
+        返回: object_name (用于存储和生成签名URL)
+        """
+        if not self.client:
+            raise Exception("Storage service unavailable")
+
+        # 生成结构化路径
+        ext = filename.split('.')[-1] if '.' in filename else 'bin'
+        now = datetime.now()
+        object_name = f"messages/{user_id}/{now.year}/{now.month:02d}/{uuid.uuid4()}.{ext}"
+        
+        try:
+            self.client.put_object(
+                bucket_name=self.bucket_name,
+                object_name=object_name,
+                data=io.BytesIO(file_data),
+                length=len(file_data),
+                content_type=content_type
+            )
+            
+            return object_name
+            
+        except S3Error as e:
+            logger.error(f"Upload failed: {e}")
+            raise Exception("File upload failed")
+
+# 单例实例
+minio_storage = MessageStorage()

+ 47 - 0
backend/app/core/websocket_manager.py

@@ -0,0 +1,47 @@
+from typing import Dict, List
+from fastapi import WebSocket
+import logging
+import json
+
+logger = logging.getLogger(__name__)
+
+class ConnectionManager:
+    def __init__(self):
+        # 存储格式: {user_id: [WebSocket1, WebSocket2, ...]}
+        self.active_connections: Dict[int, List[WebSocket]] = {}
+
+    async def connect(self, websocket: WebSocket, user_id: int):
+        await websocket.accept()
+        if user_id not in self.active_connections:
+            self.active_connections[user_id] = []
+        self.active_connections[user_id].append(websocket)
+        logger.info(f"User {user_id} connected via WebSocket. Total connections: {len(self.active_connections[user_id])}")
+
+    def disconnect(self, websocket: WebSocket, user_id: int):
+        if user_id in self.active_connections:
+            if websocket in self.active_connections[user_id]:
+                self.active_connections[user_id].remove(websocket)
+            if not self.active_connections[user_id]:
+                del self.active_connections[user_id]
+        logger.info(f"User {user_id} disconnected")
+
+    async def broadcast(self, message: str):
+        for connection_list in self.active_connections.values():
+            for connection in connection_list:
+                await connection.send_text(message)
+
+    async def send_personal_message(self, message: dict, user_id: int):
+        """
+        向特定用户的所有在线设备推送消息
+        """
+        if user_id in self.active_connections:
+            # 复制一份列表进行遍历,防止发送过程中连接断开导致列表变化
+            connections = self.active_connections[user_id][:]
+            for connection in connections:
+                try:
+                    await connection.send_json(message)
+                except Exception as e:
+                    logger.error(f"Error sending message to user {user_id}: {e}")
+                    # 可以在此处处理无效连接的清理
+
+manager = ConnectionManager()

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

@@ -6,3 +6,5 @@ from app.models.login_log import LoginLog
 from app.models.import_log import ImportLog
 from app.models.import_log import ImportLog
 from app.models.system_config import SystemConfig
 from app.models.system_config import SystemConfig
 from app.models.backup import BackupRecord, BackupSettings
 from app.models.backup import BackupRecord, BackupSettings
+from app.models.message import Message
+from app.models.device import UserDevice

+ 19 - 0
backend/app/models/device.py

@@ -0,0 +1,19 @@
+from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+class UserDevice(Base):
+    __tablename__ = "user_devices"
+
+    id = Column(Integer, primary_key=True, index=True)
+    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+    
+    device_token = Column(String(255), nullable=False)
+    platform = Column(String(20), nullable=False) # ios, android, harmony
+    device_name = Column(String(100), nullable=True)
+    
+    last_active = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+    __table_args__ = (
+        UniqueConstraint('user_id', 'device_token', name='uq_user_device'),
+    )

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

@@ -0,0 +1,41 @@
+from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum
+from sqlalchemy.sql import func
+from sqlalchemy.orm import relationship
+import enum
+from app.core.database import Base
+
+class MessageType(str, enum.Enum):
+    MESSAGE = "MESSAGE"
+    NOTIFICATION = "NOTIFICATION"
+
+class ContentType(str, enum.Enum):
+    TEXT = "TEXT"
+    IMAGE = "IMAGE"
+    VIDEO = "VIDEO"
+    FILE = "FILE"
+
+class Message(Base):
+    __tablename__ = "messages"
+
+    id = Column(Integer, primary_key=True, index=True)
+    
+    sender_id = Column(Integer, ForeignKey("users.id"), nullable=True)
+    receiver_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
+    app_id = Column(Integer, ForeignKey("applications.id"), nullable=True)
+
+    type = Column(Enum(MessageType), default=MessageType.MESSAGE, nullable=False)
+    content_type = Column(Enum(ContentType), default=ContentType.TEXT, nullable=False)
+    
+    title = Column(String(255), nullable=False)
+    content = Column(Text, nullable=False)
+    
+    action_url = Column(String(1000), nullable=True)
+    action_text = Column(String(50), nullable=True)
+    
+    is_read = Column(Boolean, default=False, nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    read_at = Column(DateTime(timezone=True), nullable=True)
+
+    sender = relationship("User", foreign_keys=[sender_id], backref="sent_messages")
+    receiver = relationship("User", foreign_keys=[receiver_id], backref="received_messages")
+    app = relationship("Application", foreign_keys=[app_id])

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

@@ -0,0 +1,82 @@
+from pydantic import BaseModel, Field, model_validator
+from typing import Optional, Union, Any
+from datetime import datetime
+from enum import Enum
+
+class MessageType(str, Enum):
+    MESSAGE = "MESSAGE"
+    NOTIFICATION = "NOTIFICATION"
+
+class ContentType(str, Enum):
+    TEXT = "TEXT"
+    IMAGE = "IMAGE"
+    VIDEO = "VIDEO"
+    FILE = "FILE"
+
+class MessageBase(BaseModel):
+    title: str = Field(..., max_length=255)
+    content: Union[str, dict, Any] = Field(..., description="内容")
+    content_type: ContentType = ContentType.TEXT
+    
+    type: MessageType = MessageType.MESSAGE
+    app_id: Optional[int] = None
+    
+    action_url: Optional[str] = None
+    action_text: Optional[str] = None
+    
+    # SSO 扩展
+    target_url: Optional[str] = None
+    auto_sso: bool = False
+
+class MessageCreate(MessageBase):
+    receiver_id: Optional[int] = None
+    app_user_id: Optional[str] = None
+    
+    @model_validator(mode='after')
+    def check_receiver(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")
+        return self
+
+class MessageUpdate(BaseModel):
+    is_read: Optional[bool] = None
+
+class MessageResponse(BaseModel):
+    id: int
+    sender_id: Optional[int]
+    receiver_id: int
+    app_id: Optional[int]
+    
+    type: MessageType
+    content_type: ContentType
+    
+    title: str
+    content: str  # DB 中存的是字符串
+    
+    action_url: Optional[str]
+    action_text: Optional[str]
+    
+    is_read: bool
+    created_at: datetime
+    read_at: Optional[datetime]
+
+    class Config:
+        from_attributes = True
+
+class ConversationResponse(BaseModel):
+    user_id: int
+    username: str
+    full_name: Optional[str]
+    unread_count: int
+    last_message: str
+    last_message_type: ContentType
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+# Device Schema
+class DeviceRegister(BaseModel):
+    device_token: str
+    platform: str
+    device_name: Optional[str] = None

+ 1 - 0
backend/requirements.txt

@@ -28,3 +28,4 @@ alibabacloud_tea_openapi>=0.3.0
 alibabacloud_tea_util>=0.3.0
 alibabacloud_tea_util>=0.3.0
 apscheduler>=3.10.0
 apscheduler>=3.10.0
 xpinyin>=0.7.6
 xpinyin>=0.7.6
+minio>=7.1.0

+ 303 - 0
docs/api_message_system_v1.md

@@ -0,0 +1,303 @@
+# 统一消息系统数据库与接口文档
+
+**版本**: V1.1
+**日期**: 2026-02-23
+**状态**: 已发布
+
+本文档详细描述了统一消息平台的数据库设计(Schema)、API 接口规范以及权限控制策略。
+
+---
+
+## 1. 数据库设计 (Database Schema)
+
+### 1.1 ER 图 (Entity-Relationship Diagram)
+
+```mermaid
+erDiagram
+    users ||--o{ messages : "receives"
+    users ||--o{ messages : "sends"
+    users ||--o{ user_devices : "has"
+    applications ||--o{ messages : "sends_notifications"
+    applications ||--o{ app_user_mapping : "maps_users"
+    users ||--o{ app_user_mapping : "mapped_by"
+
+    users {
+        int id PK
+        string username
+        string mobile
+    }
+
+    applications {
+        int id PK
+        string app_name
+        string app_secret
+    }
+    
+    app_user_mapping {
+        int id PK
+        int app_id FK
+        int user_id FK
+        string app_user_id "User ID in 3rd party app"
+    }
+
+    messages {
+        bigint id PK
+        int sender_id FK "Nullable"
+        int receiver_id FK
+        int app_id FK "Nullable"
+        enum type "MESSAGE|NOTIFICATION"
+        enum content_type "TEXT|IMAGE|VIDEO|FILE"
+        string title
+        text content "JSON or String"
+        string action_url "SSO Link"
+        bool is_read
+        datetime created_at
+    }
+
+    user_devices {
+        int id PK
+        int user_id FK
+        string device_token "Unique per User"
+        string platform "ios|android|harmony"
+        datetime last_active
+    }
+```
+
+### 1.2 表结构详解
+
+#### 1.2.1 消息表 (`messages`)
+
+存储所有用户私信和系统通知的核心表。
+
+| 字段名 | 类型 | 必填 | 默认值 | 说明 |
+| :--- | :--- | :--- | :--- | :--- |
+| `id` | BIGINT | 是 | Auto Inc | 主键,消息唯一标识 |
+| `sender_id` | INT | 否 | NULL | 发送者 UserID (私信必填,系统通知为空) |
+| `receiver_id` | INT | 是 | - | **接收者 UserID** (关联 `users.id`) |
+| `app_id` | INT | 否 | NULL | 来源应用 ID (关联 `applications.id`) |
+| `type` | VARCHAR(20) | 是 | MESSAGE | 消息类型: `MESSAGE` (私信), `NOTIFICATION` (通知) |
+| `content_type` | VARCHAR(20) | 是 | TEXT | 内容类型: `TEXT`, `IMAGE`, `VIDEO`, `FILE` |
+| `title` | VARCHAR(255) | 是 | - | 消息标题 (私信可与内容一致或摘要) |
+| `content` | TEXT | 是 | - | 消息内容。多媒体类型存储 JSON 字符串 (如 `{"url":"...", "size":1024}`) |
+| `action_url` | VARCHAR(1000)| 否 | NULL | 点击跳转链接 (通常包含 SSO Ticket) |
+| `action_text` | VARCHAR(50) | 否 | NULL | 跳转按钮文案 (如"去审批") |
+| `is_read` | TINYINT(1) | 是 | 0 | 是否已读 (0:未读, 1:已读) |
+| `created_at` | TIMESTAMP | 是 | NOW() | 创建时间 |
+| `read_at` | TIMESTAMP | 否 | NULL | 阅读时间 |
+
+**索引**:
+- `idx_receiver_id`: `(receiver_id)` - 加速“我的消息”查询
+- `idx_sender_id`: `(sender_id)` - 加速“我发送的消息”查询
+- `idx_app_id`: `(app_id)` - 统计应用发送量
+
+#### 1.2.2 用户设备表 (`user_devices`)
+
+存储移动端设备的推送 Token,用于离线推送。
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `id` | INT | 是 | 主键 |
+| `user_id` | INT | 是 | 关联用户 ID |
+| `device_token` | VARCHAR(255)| 是 | 厂商推送 Token (如 APNs Token) |
+| `platform` | VARCHAR(20) | 是 | 平台: `ios`, `android`, `harmony` |
+| `device_name` | VARCHAR(100)| 否 | 设备名称 (如 "iPhone 13") |
+| `last_active` | TIMESTAMP | 是 | 最后活跃时间 |
+
+**约束**:
+- `uq_user_device`: `UNIQUE(user_id, device_token)` - 防止同一用户同一设备重复注册
+
+---
+
+## 2. 接口权限设计 (Authentication & Authorization)
+
+本系统采用 **混合认证策略 (Hybrid Auth)**,同时支持用户操作和第三方系统调用。
+
+### 2.1 认证方式
+
+#### A. 用户认证 (User Context)
+*   **适用场景**: 移动端/Web端用户发送私信、查询历史消息。
+*   **机制**: HTTP Header `Authorization: Bearer <JWT_TOKEN>`
+*   **身份**: 解析 JWT 获取 `current_user` 对象。
+
+#### B. 应用认证 (Application Context)
+*   **适用场景**: OA/ERP 等后端系统调用接口发送业务通知。
+*   **机制**: HTTP Headers 签名验证。
+    *   `X-App-Id`: 应用 ID
+    *   `X-Timestamp`: 当前时间戳 (用于防重放)
+    *   `X-Sign`: 签名字符串
+*   **签名算法**: `MD5(app_id + timestamp + app_secret)`
+*   **身份**: 验证通过后获取 `current_app` 对象。
+
+### 2.2 权限控制矩阵
+
+| 操作 | 接口路径 | 用户 (User) 权限 | 应用 (App) 权限 | 说明 |
+| :--- | :--- | :--- | :--- | :--- |
+| **发送私信** | `POST /messages/` | ✅ 允许 (type=MESSAGE) | ✅ 允许 | 应用可冒充系统发私信 |
+| **发送通知** | `POST /messages/` | ❌ 禁止 | ✅ 允许 (type=NOTIFICATION) | 只有应用能发通知 |
+| **查询列表** | `GET /messages/` | ✅ 仅限查询自己的 | ❌ 禁止 | 应用只负责发,不负责查 |
+| **会话列表** | `GET /conversations` | ✅ 仅限查询自己的 | ❌ 禁止 | - |
+| **标记已读** | `PUT /read` | ✅ 仅限操作自己的 | ❌ 禁止 | 状态由用户端触发 |
+| **上传文件** | `POST /upload` | ✅ 允许 | ✅ 允许 | - |
+
+---
+
+## 3. API 接口定义 (API Reference)
+
+**Base URL**: `/api/v1`
+
+### 3.1 消息发送 (Send Message)
+
+支持用户私信和应用通知的统一发送接口。
+
+*   **Endpoint**: `POST /messages/`
+*   **Auth**: User Token 或 App Signature
+*   **Request Body**:
+
+| 参数名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `receiver_id` | int | 选填 | 接收者统一用户ID (与 `app_user_id` 二选一) |
+| `app_id` | int | 选填 | 应用ID (若使用 `app_user_id` 则必填) |
+| `app_user_id` | string | 选填 | 第三方业务系统账号 (需已建立映射) |
+| `type` | string | 是 | `MESSAGE` 或 `NOTIFICATION` |
+| `content_type` | string | 是 | `TEXT`, `IMAGE`, `VIDEO`, `FILE` |
+| `title` | string | 是 | 标题 |
+| `content` | string/json | 是 | 文本内容或文件元数据 JSON |
+| `action_url` | string | 否 | 原始跳转链接 |
+| `action_text` | string | 否 | 按钮文案 |
+| `auto_sso` | bool | 否 | 是否自动封装 SSO 跳转 (默认为 False) |
+| `target_url` | string | 否 | 若 `auto_sso=true`,此为最终业务目标 URL |
+
+*   **Example Request (应用发送通知)**:
+```json
+{
+  "app_id": 101,
+  "app_user_id": "zhangsan_oa",
+  "type": "NOTIFICATION",
+  "content_type": "TEXT",
+  "title": "OA审批提醒",
+  "content": "您有一条新的报销单待审批",
+  "auto_sso": true,
+  "target_url": "http://oa.example.com/audit/123",
+  "action_text": "立即处理"
+}
+```
+
+*   **Response (200 OK)**:
+```json
+{
+  "id": 501,
+  "type": "NOTIFICATION",
+  "content": "您有一条新的报销单待审批",
+  "action_url": "http://api.platform.com/api/v1/auth/sso/jump?app_id=101&redirect_to=...",
+  "created_at": "2026-02-23T10:00:00"
+}
+```
+
+### 3.2 获取会话列表 (Get Conversations)
+
+获取当前用户的会话聚合列表(类似微信首页)。
+
+*   **Endpoint**: `GET /messages/conversations`
+*   **Auth**: User Token Only
+*   **Response**: List of Conversations
+
+```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"
+  }
+]
+```
+
+### 3.3 获取聊天记录 (Get History)
+
+*   **Endpoint**: `GET /messages/history/{other_user_id}`
+*   **Params**:
+    *   `other_user_id`: 对方用户ID (0 代表系统通知)
+    *   `skip`: 分页偏移 (默认0)
+    *   `limit`: 条数 (默认50)
+*   **Response**: List of Messages
+
+### 3.4 消息状态管理
+
+*   **获取未读数**: `GET /messages/unread-count`
+*   **标记单条已读**: `PUT /messages/{message_id}/read`
+*   **标记全部已读**: `PUT /messages/read-all`
+*   **删除消息**: `DELETE /messages/{message_id}`
+
+### 3.5 文件上传 (Upload)
+
+*   **Endpoint**: `POST /messages/upload`
+*   **Content-Type**: `multipart/form-data`
+*   **Form Field**: `file`
+*   **Response**:
+```json
+{
+  "url": "messages/1/2026/02/uuid.jpg", 
+  "filename": "image.jpg",
+  "content_type": "image/jpeg",
+  "size": 50200
+}
+```
+> **注意**: 返回的 `url` 是 MinIO 对象 Key,发送消息时应将此 JSON 作为 `content` 发送。
+
+### 3.6 SSO 跳转 (SSO Jump)
+
+*   **Endpoint**: `GET /auth/sso/jump`
+*   **Params**:
+    *   `app_id`: 目标应用 ID
+    *   `redirect_to`: 最终跳转地址
+*   **逻辑**: 验证登录态 -> 生成 Ticket -> 302 跳转到 `AppCallbackUrl?ticket=...&next=redirect_to`。
+
+---
+
+## 4. WebSocket 实时接口
+
+*   **URL**: `ws://{host}/api/v1/ws/messages`
+*   **Query Param**: `token={JWT_TOKEN}`
+*   **Events**:
+    *   **Server -> Client**:
+        ```json
+        {
+          "type": "NEW_MESSAGE",
+          "data": {
+            "id": 502,
+            "sender_id": 102,
+            "title": "新消息",
+            "content": "...",
+            "content_type": "TEXT",
+            "created_at": "..."
+          }
+        }
+        ```
+    *   **Client -> Server**:
+        *   `ping`: 心跳包,服务器回复 `pong`。
+
+---
+
+## 5. 枚举定义
+
+### MessageType
+*   `MESSAGE`: 普通用户私信
+*   `NOTIFICATION`: 系统/应用通知
+
+### ContentType
+*   `TEXT`: 纯文本
+*   `IMAGE`: 图片
+*   `VIDEO`: 视频
+*   `FILE`: 普通文件

+ 345 - 0
docs/api_message_system_v1.pdf

@@ -0,0 +1,345 @@
+统一消息系统数据库与接口文档
+
+版本: V1.1
+日期: 2026-02-23
+状态: 已发布
+本文档详细描述了统一消息平台的数据库设计(Schema)、API 接口规范以及权限控制策略。
+
+数据库设计 1.                                  (Database Schema)
+
+图 1.1 ER (Entity-Relationship Diagram)
+
+                                                users                                                                                            applications
+
+                                          int   id         PK                                                                              int   id              PK
+
+                                          string username                                                                                  string app_name
+
+                                          string mobile                                                                                    string app_secret
+
+                      receives                                                                                    mapped_by
+                        sends                                              has
+
+                     messages                                                                                                                        maps_users
+
+bigint  id            PK                                                             sends_notifications
+
+int     sender_id     FK Nullable
+
+int     receiver_id FK                                                           user_devices
+
+                                                                                                                                           app_user_mapping
+
+int     app_id        FK Nullable                              int     id            PK
+
+                                                                                                                             int  id             PK
+
+enum    type                    MESSAGE|NOTIFICATION           int     user_id       FK
+
+                                                                                                                             int  app_id         FK
+
+enum    content_type            TEXT|IMAGE|VIDEO|FILE          string  device_token            Unique per User
+
+                                                                                                                             int  user_id        FK
+
+string  title                                                  string  platform                ios|android|harmony
+
+                                                                                                                             string app_user_id      User ID in 3rd party app
+
+text    content                 JSON or String                 datetime last_active
+
+string  action_url              SSO Link
+
+bool    is_read
+
+datetime created_at
+
+1.2 表结构详解
+
+消息表 1.2.1             ( messages )
+
+存储所有用户私信和系统通知的核心表。
+
+字段名                             类型                             必填 默认值                          说明
+                                                                                               主键,消息唯一标识
+ id                             BIGINT                         是       Auto Inc
+字段名            类型              必填 默认值 说明
+
+ sender_id     INT             否      NULL         发送者 UserID (私信必填,系统通知为空)
+ receiver_id
+ app_id        INT             是-                  接收者 关联 UserID (  users.id )
+
+ type          INT             否      NULL         来源应用 关联 ID (  applications.id )
+
+ content_type  VARCHAR(20)     是      MESSAGE      消息类型 私信 : MESSAGE ( ), NOTIFICATION
+ title                                             (通知)
+
+ content       VARCHAR(20)     是      TEXT         内容类型: TEXT , IMAGE , VIDEO , FILE
+
+ action_url    是 VARCHAR(255)         -            消息标题 (私信可与内容一致或摘要)
+ action_text
+ is_read       TEXT            是-                  消息内容。多媒体类型存储 JSON 字符串
+ created_at                                        如( {"url":"...", "size":1024} )
+ read_at
+               否 VARCHAR(1000)        NULL         点击跳转链接 (通常包含 SSO Ticket)
+
+               VARCHAR(50)     否      NULL         跳转按钮文案 (如"去审批")
+
+               TINYINT(1)      是0                  是否已读 未读 (0: , 1:已读)
+
+               TIMESTAMP       是      NOW()        创建时间
+
+               TIMESTAMP       否      NULL         阅读时间
+
+索引:
+
+     加速 我的消息 查询 idx_receiver_id : (receiver_id) -“”
+     加速 我发送的消息 查询 idx_sender_id : (sender_id) -
+                            “               ”
+     统计应用发送量 idx_app_id : (app_id) -
+
+用户设备表 1.2.2    ( user_devices )
+
+存储移动端设备的推送 Token,用于离线推送。
+
+字段名                  类型                  必填 说明
+
+ id                  INT                 是 主键
+ user_id             INT
+ device_token        VARCHAR(255)        是 关联用户 ID
+ platform            VARCHAR(20)
+ device_name         VARCHAR(100)        是         厂商推送 如 Token ( APNs Token)
+ last_active         TIMESTAMP
+                                         是         平台: ios , android , harmony
+
+                                         否         设备名称 如( "iPhone 13")
+
+                                         是 最后活跃时间
+约束:
+   防止同一用户同一设备重复注册 uq_user_device : UNIQUE(user_id, device_token) -
+
+接口权限设计 2.          (Authentication & Authorization)
+
+本系统采用 混合认证策略 (Hybrid Auth),同时支持用户操作和第三方系统调用。
+
+2.1 认证方式
+
+用户认证 A.  (User Context)
+
+适用场景: 移动端/Web端用户发送私信、查询历史消息。
+机制: HTTP Header Authorization: Bearer <JWT_TOKEN>
+身份 解析 获取 对象。 :
+         JWT   current_user
+
+应用认证 B.  (Application Context)
+
+适用场景: OA/ERP 等后端系统调用接口发送业务通知。
+机制: HTTP Headers 签名验证。
+应用 X-App-Id :
+               ID
+X-Timestamp : 当前时间戳 (用于防重放)
+X-Sign : 签名字符串
+签名算法: MD5(app_id + timestamp + app_secret)
+身份: 验证通过后获取 current_app 对象。
+
+2.2 权限控制矩阵
+
+操作 接口路径                  用户 (User) 权限              应用 (App) 权限          说明
+发送私信 POST /messages/     ✅ 允许                      ✅ 允许                 应用可冒充系统发私信
+发送通知 POST /messages/                               ✅ 允许
+查询列表 GET /messages/      (type=MESSAGE)                                 只有应用能发通知
+                                                   (type=NOTIFICATION)  应用只负责发,
+                         ❌ 禁止                                           不负责查
+                                                   ❌ 禁止
+                         ✅
+
+                         仅限查询自己的
+
+会话列表 仅限查询自己的 ✅                                     ❌ 禁止                 -
+                     GET /conversations
+操作 接口路径                         用户 (User) 权限  应用 (App) 权限            说明
+标记已读 PUT /read                                ❌ 禁止                   状态由用户端触发
+上传文件 POST /upload               ✅             ✅ 允许
+                                                                     -
+                                仅限操作自己的
+                                ✅ 允许
+
+接口定义 3. API              (API Reference)
+
+Base URL: /api/v1
+
+消息发送 3.1           (Send Message)
+
+支持用户私信和应用通知的统一发送接口。
+
+Endpoint: POST /messages/
+
+或 Auth: User Token App Signature
+
+Request Body:
+
+参数名                类型               必填  说明
+
+ receiver_id       int              选填  接收者统一用户ID (与 app_user_id 二选一)
+ app_id
+ app_user_id       int              选填  应用 若使用 则必填 ID (              )
+ type                                           app_user_id
+ content_type
+ title             string           选填  第三方业务系统账号 (需已建立映射)
+ content
+ action_url        string           是   或 MESSAGE NOTIFICATION
+ action_text
+ auto_sso          string           是   TEXT , IMAGE , VIDEO , FILE
+ target_url
+                   string           是   标题
+
+                   string/json      是   文本内容或文件元数据 JSON
+
+                   string           否   原始跳转链接
+
+                   string           否   按钮文案
+
+                   bool             否   是否自动封装 SSO 跳转 (默认为 False)
+
+                   string           否   若 ,此为最终业务目标 auto_sso=true       URL
+
+应用发送通知 Example Request (        ):
+{
+   "app_id": 101,
+   "app_user_id": "zhangsan_oa",
+   "type": "NOTIFICATION",
+   "content_type": "TEXT",
+   "title": "OA审批提醒",
+   "content": "您有一条新的报销单待审批",
+   "auto_sso": true,
+   "target_url": "http://oa.example.com/audit/123",
+   "action_text": "立即处理"
+
+}
+
+Response (200 OK):
+
+{
+   "id": 501,
+   "type": "NOTIFICATION",
+   "content": "您有一条新的报销单待审批",
+   "action_url": "http://api.platform.com/api/v1/auth/sso/jump?app_id=101&redirect_to=...",
+   "created_at": "2026-02-23T10:00:00"
+
+}
+
+获取会话列表 3.2          (Get Conversations)
+
+获取当前用户的会话聚合列表(类似微信首页)。
+
+Endpoint: GET /messages/conversations
+Auth: User Token Only
+Response: List of Conversations
+[
+   {
+      "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"
+   }
+
+]
+
+获取聊天记录 3.3  (Get History)
+
+Endpoint: GET /messages/history/{other_user_id}
+
+Params:
+对方用户 代表系统通知 other_user_id :
+skip : 分页偏移 (默认0)           ID (0          )
+
+条数 默认 limit :
+          ( 50)
+
+Response: List of Messages
+
+3.4 消息状态管理
+
+获取未读数: GET /messages/unread-count
+标记单条已读: PUT /messages/{message_id}/read
+标记全部已读: PUT /messages/read-all
+删除消息: DELETE /messages/{message_id}
+
+文件上传 3.5  (Upload)
+
+Endpoint: POST /messages/upload
+Content-Type: multipart/form-data
+Form Field: file
+Response:
+{
+   "url": "messages/1/2026/02/uuid.jpg",
+   "filename": "image.jpg",
+   "content_type": "image/jpeg",
+   "size": 50200
+
+}
+
+注意: 返回的 url 是 MinIO 对象 Key,发送消息时应将此 JSON 作为 content 发送。
+
+跳转 3.6 SSO  (SSO Jump)
+
+Endpoint: GET /auth/sso/jump
+
+Params:
+目标应用 app_id :
+                ID
+最终跳转地址 redirect_to :
+逻辑 验证登录态 生成 跳转到 。 :
+            ->      Ticket -> 302               AppCallbackUrl?ticket=...&next=redirect_to
+
+实时接口 4. WebSocket
+
+      URL: ws://{host}/api/v1/ws/messages
+      Query Param: token={JWT_TOKEN}
+      Events:
+
+            Server -> Client:
+
+                 {
+                    "type": "NEW_MESSAGE",
+                    "data": {
+                       "id": 502,
+                       "sender_id": 102,
+                       "title": "新消息",
+                       "content": "...",
+                       "content_type": "TEXT",
+                       "created_at": "..."
+                    }
+
+                 }
+
+            Client -> Server:
+
+         ping : 心跳包,服务器回复 。 pong
+5. 枚举定义
+
+MessageType
+
+MESSAGE : 普通用户私信
+系统 应用通知 NOTIFICATION :
+                /
+
+ContentType
+
+   纯文本 TEXT :
+   图片 IMAGE :
+   视频 VIDEO :
+   FILE : 普通文件
+

+ 227 - 0
docs/design_message_system.md

@@ -0,0 +1,227 @@
+# 统一消息与通知中心详细设计文档
+
+**版本**: V4.0  
+**日期**: 2026-02-22  
+**状态**: 最终定稿  
+**适用范围**: Android, iOS, Windows, macOS, Web, 第三方业务系统 (OA/ERP等)
+
+---
+
+## 1. 系统概述 (System Overview)
+
+### 1.1 背景与目标
+为了增强“统一认证平台”的交互能力,构建一个中心化的消息通知系统。该系统不仅支持用户间的私信,更重要的是连接第三方业务应用(如 OA、CRM)与用户终端,实现业务通知的实时触达和**一键免登跳转处理**。
+
+### 1.2 核心特性
+1.  **全平台覆盖**: 一套后端架构同时支持移动端(Android/iOS)和桌面端(Win/Mac/Web)。
+2.  **自建实时推送**: 摒弃第三方推送服务,采用 **WebSocket 长连接** 实现应用内实时通知,配合 HTTP 离线拉取策略。
+3.  **多媒体支持**: 提供专用的文件上传接口 (`/messages/upload`),支持图片、视频、文档发送。
+4.  **SSO 一键跳转**: 通知的跳转链接 (`action_url`) 自动封装 SSO 逻辑,用户点击通知后,经过统一认证平台鉴权,携带 Ticket 自动登录目标应用并跳转至具体业务详情页。
+5.  **账号映射**: 第三方应用无需知道平台的 `User ID`,只需提供其自身的 `App User ID` 即可精准投递消息。
+
+---
+
+## 2. 总体架构 (Architecture)
+
+### 2.1 逻辑架构图
+```mermaid
+graph TD
+    %% 发送端 (聪明的端点)
+    Sender[第三方应用 OA/ERP] -->|1. 上传附件| MinIO[(MinIO OSS)]
+    Sender -->|2. 携带 User_ID & 完整URL| MsgAPI[统一消息 API]
+
+    %% 处理层:纯粹的哑管道 (解耦)
+    subgraph "统一消息中心 (Message Gateway)"
+        MsgAPI -->|3. 存入信箱| DB[(MySQL msg_inbox)]
+        MsgAPI -->|4. 广播事件| Redis[(Redis Pub/Sub)]
+        Redis -->|5. 集群订阅| WS[WebSocket 服务集群]
+    end
+
+    %% 接收端
+    subgraph "客户端 (多端漫游)"
+        Mobile[手机 App]
+        Desktop[桌面客户端]
+    end
+
+    WS -->|6. 实时推送 JSON 信封| Mobile
+    WS -->|6. 实时推送 JSON 信封| Desktop
+
+    %% 客户端触发 SSO 免登 (逻辑在端侧和目标应用侧)
+    Mobile -.->|7. 拦截URL, 注入本地 Token| LocalRouter[客户端路由引擎]
+    LocalRouter -->|8. 带 Token 访问| TargetApp[目标应用页面/API]
+    TargetApp -.->|9. 校验 Token| SSO[统一认证平台]
+    TargetApp -->|10. 渲染展示| DetailPage[业务详情页]
+```
+
+---
+
+## 3. 数据库设计 (Database Schema)
+
+### 3.1 消息表 (`messages`)
+
+| 字段名 | 类型 | 约束 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `id` | BigInt | PK | 消息唯一标识 |
+| `receiver_id` | Int | FK, NotNull | **接收者 UserID** (统一平台 ID) |
+| `app_id` | Int | FK, Nullable | 来源应用 ID (标识消息来源) |
+| `type` | Enum | NotNull | `MESSAGE` (私信), `NOTIFICATION` (平台通知) |
+| `content_type` | Enum | NotNull | `TEXT`, `IMAGE`, `VIDEO`, `FILE` |
+| `content` | Text | NotNull | 文本内容 或 **JSON 结构体** (见下文) |
+| `title` | Varchar(255) | NotNull | 标题 (如"工单待审批") |
+| `action_url` | Varchar(1000) | Nullable | **SSO 跳转链接** (平台自动生成) |
+| `action_text` | Varchar(50) | Nullable | 按钮文案 (如"立即处理") |
+| `is_read` | Bool | Default 0 | 是否已读 |
+| `created_at` | DateTime | Default Now | 创建时间 |
+
+---
+
+## 4. API 接口规范 (API Specification)
+
+**Base URL**: `/api/v1/messages`
+
+### 4.1 专用文件上传 (Upload Attachment)
+
+专门用于消息附件的上传,支持 MinIO 存储。
+
+*   **URL**: `POST /upload`
+*   **权限**: Login User / Service Token
+*   **Content-Type**: `multipart/form-data`
+*   **Response**:
+    ```json
+    {
+      "url": "http://minio.com/messages/user_1/2026/02/uuid.jpg",
+      "filename": "screenshot.jpg",
+      "content_type": "image/jpeg",
+      "size": 102400
+    }
+    ```
+
+### 4.2 发送消息 (Send Message)
+
+支持多媒体内容和账号映射。**支持混合权限认证**:既接受用户 Token,也接受应用签名。
+
+*   **URL**: `POST /`
+*   **Authentication**:
+    *   **User**: Header `Authorization: Bearer <token>`
+    *   **App**: Headers `X-App-Id`, `X-Timestamp`, `X-Sign`
+*   **Request Body**:
+    ```json
+    {
+      // --- 接收者定位 (二选一) ---
+      // 方式 A: 知道统一平台 ID
+      // "receiver_id": 10086,
+      
+      // 方式 B: 只知道业务系统账号 (推荐)
+      "app_id": 101,              // 来源应用 ID
+      "app_user_id": "zhangsan",  // 该应用内的账号
+      
+      // --- 消息内容 ---
+      "type": "MESSAGE",          // 类型:MESSAGE (私信) 或 NOTIFICATION (通知)
+      "content_type": "IMAGE",    // 内容类型:TEXT, IMAGE, VIDEO, FILE
+      
+      //如果是 TEXT,直接传字符串
+      //"content": "你好", 
+      
+      //如果是 IMAGE/VIDEO,传 JSON 字符串 (通常是 /upload 接口的返回值)
+      "content": "{\"url\":\"http://minio...\",\"width\":800,\"height\":600}",
+      
+      "title": "新图片消息",
+      
+      // --- 跳转行为 (仅 NOTIFICATION 有效) ---
+      "target_url": "http://oa.com/audit/999", // 最终业务页面
+      "action_text": "去审批",
+      "auto_sso": true // 开启自动 SSO 封装
+    }
+    ```
+
+### 4.3 获取消息列表 (Get List)
+
+*   **URL**: `GET /?unread_only=true`
+*   **Response**:
+    ```json
+    [
+      {
+        "id": 501,
+        "type": "MESSAGE",
+        "content_type": "IMAGE",
+        "content": "{\"url\":\"...\"}", // 前端需解析 JSON
+        "is_read": false,
+        "created_at": "..."
+      }
+    ]
+    ```
+
+### 4.4 SSO 跳转接口 (SSO Jump)
+
+*   **URL**: `GET /api/v1/auth/sso/jump`
+*   **Params**: `app_id=101`, `redirect_to=http://oa.com/audit/999`
+*   **Logic**:
+    1.  检查当前用户登录状态。
+    2.  若未登录 -> 跳转登录页 -> 回调。
+    3.  若已登录 -> 生成 Ticket -> 跳转 `AppCallback?ticket=ST&next={redirect_to}`。
+
+---
+
+## 5. 权限认证策略 (Authentication Strategy)
+
+为了同时支持用户操作和系统调用,消息接口采用**混合认证策略 (Hybrid Auth)**。
+
+### 5.1 认证优先级
+后端 API 将按以下顺序尝试识别调用者身份:
+
+1.  **用户认证 (Bearer Token)**:
+    *   **场景**: 用户通过 App/Web 发送私信、查询历史消息。
+    *   **机制**: 检查 Header `Authorization: Bearer <JWT>`.
+    *   **身份**: 识别为 `User` 对象。
+
+2.  **应用认证 (API Signature)**:
+    *   **场景**: OA/ERP 等后端系统调用接口发送业务通知。
+    *   **机制**: 检查 Headers `X-App-Id`, `X-Timestamp`, `X-Sign`.
+    *   **签名算法**: `MD5(app_id + timestamp + app_secret)`。
+    *   **身份**: 识别为 `Application` 对象。
+
+### 5.2 权限控制
+*   **User**: 只能发送 `type=MESSAGE` (私信);只能查询 `receiver_id` 为自己的消息。
+*   **Application**: 只能发送 `type=NOTIFICATION` (通知) 或 `MESSAGE`;无法查询消息列表(仅负责投递)。
+
+---
+
+## 6. 客户端集成指南 (Client Guide)
+
+### 6.1 消息内容渲染
+前端收到消息后,根据 `content_type` 决定渲染逻辑:
+*   `TEXT`: 直接显示 `content` 字符串。
+*   `IMAGE`: `JSON.parse(content)` -> `<img src={obj.url} />`。
+*   `VIDEO`: `JSON.parse(content)` -> `<video src={obj.url} controls />`。
+*   `FILE`: `JSON.parse(content)` -> 显示文件名和下载图标 -> 点击下载。
+
+### 6.2 全平台推送策略
+*   **Web 端**: 使用 `Notification API` 弹窗;WebSocket 保持连接。
+*   **Android/iOS**:
+    *   **在线**: WebSocket 收到消息 -> 弹出 App 内置 Notification Banner。
+    *   **离线**: App 启动/回到前台 -> 调用 HTTP 接口拉取未读消息 -> 更新角标。
+    *   **跳转**: 点击通知 -> 唤起 App -> WebView 打开 `action_url` (自动完成 SSO 登录)。
+
+### 6.3 文件上传流程
+1.  用户选择图片/视频。
+2.  调用 `POST /api/v1/messages/upload` 上传文件到 MinIO。
+3.  获取返回的 `url` 和元数据。
+4.  调用 `POST /api/v1/messages` 发送消息,将元数据填入 `content`。
+
+---
+
+## 7. 实施步骤 (Action Items)
+
+1.  **基础设施**:
+    *   部署 MinIO 服务。
+    *   在 `config.py` 配置 MinIO 连接信息。
+2.  **后端开发**:
+    *   实现 `MinIO Client` 模块。
+    *   实现 `/messages/upload` 接口。
+    *   修改 `Message` 模型支持 `content_type`。
+    *   实现 `/auth/sso/jump` 接口。
+    *   **实现混合认证依赖 (`get_current_user_or_app`)**。
+3.  **前端/客户端开发**:
+    *   集成 WebSocket。
+    *   实现多媒体消息的气泡组件 (Text/Image/Video)。
+    *   实现 SSO 跳转拦截逻辑。

+ 58 - 0
docs/tasks_message_system.md

@@ -0,0 +1,58 @@
+# 统一消息系统开发任务清单
+
+## 1. 基础设施与数据库准备
+- [ ] **MinIO 服务部署**
+    - [ ] 编写 docker-compose.yml 增加 MinIO 服务配置
+    - [ ] 配置 MinIO 的 Access Key 和 Secret Key
+    - [ ] 创建默认 Bucket (`messages`) 并设置访问策略
+- [ ] **数据库变更**
+    - [ ] 执行 `V3__add_message_system.sql` 脚本
+    - [ ] 验证 `messages` 和 `user_devices` 表结构是否正确
+    - [ ] 验证 `app_user_mapping` 表是否存在(用于第三方账号映射)
+
+## 2. 后端核心模块开发 (Backend)
+- [ ] **MinIO 客户端封装**
+    - [ ] 实现 `backend/app/core/minio.py` 工具类
+    - [ ] 实现文件上传方法 (`put_object`)
+    - [ ] 实现预签名 URL 生成方法 (`get_presigned_url`)
+- [ ] **WebSocket 服务实现**
+    - [ ] 实现 `ConnectionManager` 类 (管理连接/断开/广播)
+    - [ ] 实现 `/api/v1/ws/messages` 端点
+    - [ ] 实现 JWT Token 鉴权逻辑 (`get_user_from_token`)
+- [ ] **消息模型与 Schema 定义**
+    - [ ] 更新 `backend/app/models/message.py`
+    - [ ] 定义 Pydantic Schema (`MessageCreate`, `MessageResponse`)
+    - [ ] 处理多媒体内容 (`content_type`) 的 JSON 序列化/反序列化
+
+## 3. API 接口开发 (API Endpoints)
+- [ ] **消息发送接口 (`POST /messages`)**
+    - [ ] 实现用户发送私信逻辑 (校验接收者)
+    - [ ] 实现应用发送通知逻辑 (校验 App 签名)
+    - [ ] 实现 `app_user_id` 到 `user_id` 的自动映射查找
+    - [ ] 集成 WebSocket 实时推送 (异步任务)
+- [ ] **消息查询接口**
+    - [ ] 实现会话列表聚合接口 (`GET /conversations`)
+    - [ ] 实现历史消息分页接口 (`GET /history/{id}`)
+    - [ ] 实现未读数统计与标记已读接口
+- [ ] **文件上传与 SSO**
+    - [ ] 实现附件上传接口 (`POST /upload`)
+    - [ ] 实现 SSO 跳转中转接口 (`GET /auth/sso/jump`)
+
+## 4. 客户端 SDK/前端集成 (Frontend)
+- [ ] **WebSocket 客户端封装**
+    - [ ] 实现断线重连机制
+    - [ ] 实现心跳保活 (Ping/Pong)
+- [ ] **UI 组件开发**
+    - [ ] 开发会话列表组件 (展示未读红点、最后一条消息)
+    - [ ] 开发聊天窗口组件 (支持 Text/Image/File 渲染)
+- [ ] **业务逻辑对接**
+    - [ ] 对接文件上传流程 (上传 -> 拿 Key -> 发消息)
+    - [ ] 处理通知点击跳转 (拦截 URL -> 自动登录)
+
+## 5. 测试与文档
+- [ ] **单元测试**
+    - [ ] 编写 API 接口测试用例 (Pytest)
+    - [ ] 模拟 WebSocket 连接测试
+- [ ] **集成测试**
+    - [ ] 验证第三方应用通过 API 发送通知的全流程
+    - [ ] 验证 MinIO 文件上传下载流程

+ 7 - 1
frontend/nginx.conf

@@ -1,6 +1,9 @@
 server {
 server {
     listen 80;
     listen 80;
     server_name localhost;
     server_name localhost;
+    
+    # Increase client body size limit for file uploads
+    client_max_body_size 50M;
 
 
     # Serve Static Files
     # Serve Static Files
     location / {
     location / {
@@ -37,6 +40,9 @@ server {
     ssl_protocols TLSv1.2 TLSv1.3;
     ssl_protocols TLSv1.2 TLSv1.3;
     ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
     ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
     ssl_prefer_server_ciphers off;
     ssl_prefer_server_ciphers off;
+    
+    # Increase client body size limit for file uploads
+    client_max_body_size 50M;
 
 
     # Serve Static Files
     # Serve Static Files
     location / {
     location / {
@@ -57,4 +63,4 @@ server {
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "upgrade";
         proxy_set_header Connection "upgrade";
     }
     }
-}
+}

+ 80 - 0
frontend/src/components/UserAvatar.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="user-avatar" :style="avatarStyle" :title="name">
+    {{ avatarText }}
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+const props = defineProps({
+  name: {
+    type: String,
+    required: false,
+    default: '未知'
+  },
+  size: {
+    type: Number,
+    default: 40
+  },
+  userId: {
+    type: [Number, String],
+    default: 0
+  }
+})
+
+// 预设的莫兰迪色系背景,柔和护眼
+const backgroundColors = [
+  '#7265e6', '#ffbf00', '#00a2ae', '#f56a00', '#1890ff', 
+  '#69c0ff', '#87e8de', '#ff85c0', '#5cdbd3', '#ffc069'
+]
+
+// 根据 UserID 或 名字 计算固定的背景色
+const backgroundColor = computed(() => {
+  let hash = 0
+  const seed = props.userId ? props.userId.toString() : props.name
+  for (let i = 0; i < seed.length; i++) {
+    hash = seed.charCodeAt(i) + ((hash << 5) - hash)
+  }
+  const index = Math.abs(hash % backgroundColors.length)
+  return backgroundColors[index]
+})
+
+// 提取文字逻辑:中文取后2位,英文取前2位大写
+const avatarText = computed(() => {
+  const name = props.name ? props.name.trim() : '?'
+  if (!name) return '?'
+  
+  // 判断是否包含中文字符
+  if (/[\u4e00-\u9fa5]/.test(name)) {
+    return name.length > 2 ? name.slice(-2) : name
+  }
+  // 英文名取前两个字母
+  return name.slice(0, 2).toUpperCase()
+})
+
+const avatarStyle = computed(() => ({
+  width: `${props.size}px`,
+  height: `${props.size}px`,
+  lineHeight: `${props.size}px`,
+  fontSize: `${props.size * 0.4}px`, // 字体大小随尺寸自适应
+  backgroundColor: backgroundColor.value,
+  borderRadius: '50%', // 圆形
+  color: '#fff',
+  textAlign: 'center' as const,
+  display: 'inline-block',
+  fontWeight: 'bold',
+  flexShrink: 0 // 防止在 Flex 布局中被压缩
+}))
+</script>
+
+<style scoped>
+.user-avatar {
+  user-select: none;
+  cursor: pointer;
+  transition: opacity 0.2s;
+}
+.user-avatar:hover {
+  opacity: 0.9;
+}
+</style>

+ 5 - 0
frontend/src/router/index.ts

@@ -138,6 +138,11 @@ const routes: Array<RouteRecordRaw> = [
         path: 'help',
         path: 'help',
         name: 'Help',
         name: 'Help',
         component: () => import('../views/Help.vue')
         component: () => import('../views/Help.vue')
+      },
+      {
+        path: 'messages',
+        name: 'Messages',
+        component: () => import('../views/message/index.vue')
       }
       }
     ]
     ]
   }
   }

+ 6 - 1
frontend/src/views/Dashboard.vue

@@ -16,6 +16,11 @@
             <span>快捷导航</span>
             <span>快捷导航</span>
           </el-menu-item>
           </el-menu-item>
 
 
+          <el-menu-item index="/dashboard/messages">
+            <el-icon><ChatDotRound /></el-icon>
+            <span>消息中心</span>
+          </el-menu-item>
+
           <el-menu-item 
           <el-menu-item 
             v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')" 
             v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')" 
             index="/dashboard/apps"
             index="/dashboard/apps"
@@ -129,7 +134,7 @@
 import { computed, onMounted, ref, reactive } from 'vue'
 import { computed, onMounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
 import { useAuthStore } from '../store/auth'
-import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting } from '@element-plus/icons-vue'
+import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight, Lock, Setting, ChatDotRound } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import api from '../utils/request'
 import api from '../utils/request'
 
 

+ 657 - 0
frontend/src/views/message/index.vue

@@ -0,0 +1,657 @@
+<template>
+  <div class="message-layout">
+    <!-- 左侧:会话列表 -->
+    <div class="sidebar">
+      <div class="search-bar">
+        <el-input 
+          v-model="searchText" 
+          placeholder="搜索联系人..." 
+          prefix-icon="Search" 
+          clearable
+        />
+        <el-button icon="Plus" circle class="add-btn" @click="showUserSelector = true" />
+      </div>
+
+      <div class="conversation-list" v-loading="loadingConversations">
+        <div 
+          v-for="chat in filteredConversations" 
+          :key="chat.user_id"
+          class="chat-item"
+          :class="{ active: currentChatId === chat.user_id }"
+          @click="selectChat(chat)"
+        >
+          <UserAvatar 
+            :name="chat.full_name || chat.username || '未知'" 
+            :userId="chat.user_id" 
+            :size="40" 
+          />
+          
+          <div class="chat-info">
+            <div class="chat-header">
+              <span class="chat-name">{{ chat.full_name || chat.username }}</span>
+              <span class="chat-time">{{ formatTime(chat.updated_at) }}</span>
+            </div>
+            <div class="chat-preview">
+              {{ chat.last_message }}
+            </div>
+          </div>
+          
+          <!-- 未读红点 -->
+          <div v-if="chat.unread_count > 0" class="unread-badge">
+            {{ chat.unread_count > 99 ? '99+' : chat.unread_count }}
+          </div>
+        </div>
+        
+        <el-empty v-if="conversations.length === 0 && !loadingConversations" description="暂无消息" :image-size="60" />
+      </div>
+    </div>
+
+    <!-- 右侧:聊天窗口 -->
+    <div class="chat-window">
+      <template v-if="currentChatId">
+        <!-- 顶部标题 -->
+        <header class="chat-header-bar">
+          <h3>{{ currentChatUser?.full_name || currentChatUser?.username }}</h3>
+        </header>
+
+        <!-- 消息流区域 -->
+        <main class="message-stream" ref="scrollContainer">
+          <div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-me': msg.sender_id === currentUserId }">
+            
+            <UserAvatar 
+              v-if="msg.sender_id !== currentUserId"
+              :name="currentChatUser?.full_name || currentChatUser?.username || '?'" 
+              :userId="msg.sender_id" 
+              :size="36" 
+              class="msg-avatar"
+            />
+            
+            <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>
+               </div>
+               <div class="message-time">{{ formatTime(msg.created_at) }}</div>
+            </div>
+
+            <!-- Removed UserAvatar for Me -->
+          </div>
+        </main>
+
+        <!-- 底部输入框 -->
+        <footer class="input-area">
+          <div class="toolbar">
+            <el-upload
+              class="upload-demo"
+              action="#"
+              :http-request="handleUpload"
+              :show-file-list="false"
+              accept="image/*"
+            >
+              <el-icon title="图片" class="tool-icon"><Picture /></el-icon>
+            </el-upload>
+            <!-- <el-icon title="文件" class="tool-icon"><Folder /></el-icon> -->
+          </div>
+          <textarea 
+            v-model="inputMessage" 
+            @keydown.enter.prevent="sendMessage"
+            placeholder="输入消息..."
+            class="input-textarea"
+          ></textarea>
+          <div class="send-actions">
+            <el-button type="primary" @click="sendMessage" :disabled="!inputMessage.trim()">发送</el-button>
+          </div>
+        </footer>
+      </template>
+      
+      <div v-else class="empty-state">
+        <el-empty description="选择一个联系人开始聊天" />
+      </div>
+    </div>
+    
+    <!-- 用户选择器弹窗 -->
+    <el-dialog v-model="showUserSelector" title="发起聊天" width="500px">
+       <el-select
+        v-model="selectedUserId"
+        filterable
+        remote
+        reserve-keyword
+        placeholder="搜索用户(输入手机号或名字)"
+        :remote-method="searchUsersRemote"
+        :loading="searchingUsers"
+        style="width: 100%"
+      >
+        <el-option
+          v-for="item in userOptions"
+          :key="item.id"
+          :label="`${item.name || item.username || '未命名'} (${item.mobile})`"
+          :value="item.id"
+        />
+      </el-select>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="showUserSelector = false">取消</el-button>
+          <el-button type="primary" @click="startNewChat">确定</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed, watch, nextTick } from 'vue'
+import UserAvatar from '@/components/UserAvatar.vue'
+import { Search, Plus, Picture, Folder, Document } from '@element-plus/icons-vue'
+import api from '@/utils/request'
+import { useAuthStore } from '@/store/auth'
+import { ElMessage } from 'element-plus'
+
+const authStore = useAuthStore()
+const currentUser = computed(() => authStore.user)
+const currentUserId = computed(() => authStore.user?.id)
+
+// State
+const searchText = ref('')
+const loadingConversations = ref(false)
+const conversations = ref<any[]>([])
+const currentChatId = ref<number | null>(null)
+const currentChatUser = ref<any>(null)
+const messages = ref<any[]>([])
+const inputMessage = ref('')
+const scrollContainer = ref<HTMLElement | null>(null)
+
+// User Selector
+const showUserSelector = ref(false)
+const selectedUserId = ref<number | null>(null)
+const userOptions = ref<any[]>([])
+const searchingUsers = ref(false)
+
+// Computed
+const filteredConversations = computed(() => {
+  if (!searchText.value) return conversations.value
+  const lower = searchText.value.toLowerCase()
+  return conversations.value.filter(c => 
+    (c.full_name && c.full_name.toLowerCase().includes(lower)) ||
+    (c.username && c.username.toLowerCase().includes(lower))
+  )
+})
+
+// Lifecycle
+onMounted(() => {
+  fetchConversations()
+  if (!currentUser.value) authStore.fetchUser()
+})
+
+// Methods
+const initWebSocket = () => {
+  if (!currentUser.value) return
+  
+  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+  // Use relative path or configured base URL
+  const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/messages?token=${localStorage.getItem('token')}`
+  
+  const ws = new WebSocket(wsUrl)
+  
+  ws.onmessage = (event) => {
+    if (event.data === 'pong') return
+    try {
+      const msg = JSON.parse(event.data)
+      if (msg.type === 'NEW_MESSAGE') {
+        const newMessage = msg.data
+        // If current chat is open, append message
+        if (currentChatId.value === newMessage.sender_id || (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id)) {
+           // Handle case where sender_id is myself (from another device) or currentChat is sender
+           messages.value.push(newMessage)
+           scrollToBottom()
+           // Mark read if window focused? (Skip for now)
+        } else if (newMessage.sender_id === currentUserId.value && currentChatId.value === newMessage.receiver_id) {
+           // Message sent by me from another tab/device
+           messages.value.push(newMessage)
+           scrollToBottom()
+        }
+        
+        // Update sidebar list
+        updateConversationPreview(
+            newMessage.sender_id === currentUserId.value ? newMessage.receiver_id : newMessage.sender_id,
+            newMessage.content,
+            newMessage.content_type
+        )
+        
+        // Update unread count if not current chat
+        if (newMessage.sender_id !== currentUserId.value && currentChatId.value !== 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
+        }
+      }
+    } catch (e) {
+      console.error('WS parse error', e)
+    }
+  }
+  
+  ws.onopen = () => {
+    // Start heartbeat
+    setInterval(() => {
+      if (ws.readyState === WebSocket.OPEN) ws.send('ping')
+    }, 30000)
+  }
+}
+
+const fetchConversations = async () => {
+  loadingConversations.value = true
+  try {
+    const res = await api.get('/messages/conversations')
+    conversations.value = res.data
+    initWebSocket() // Start WS after data loaded
+  } catch (e) {
+    console.error(e)
+  } finally {
+    loadingConversations.value = false
+  }
+}
+
+const selectChat = async (chat: any) => {
+  currentChatId.value = chat.user_id
+  currentChatUser.value = chat
+  
+  // Mark as read locally (API call typically happens here or on scroll)
+  // For simplicity, we just load history
+  await loadHistory(chat.user_id)
+  
+  // Clear unread count locally
+  const conv = conversations.value.find(c => c.user_id === chat.user_id)
+  if (conv) conv.unread_count = 0
+}
+
+const loadHistory = async (userId: number) => {
+  try {
+    const res = await api.get(`/messages/history/${userId}`)
+    // API returns newest first, reverse for display
+    messages.value = res.data.reverse()
+    scrollToBottom()
+    
+    // Mark messages as read
+    // Iterate and find unread... or just call a "read all from this user" endpoint?
+    const unreadIds = messages.value.filter(m => !m.is_read && m.receiver_id === currentUserId.value).map(m => m.id)
+    if (unreadIds.length > 0) {
+        // Implement batch read
+        // For efficiency, we mark one by one for now, or ideally backend supports batch
+        // Using Promise.all to parallelize
+        await Promise.all(unreadIds.map(id => api.put(`/messages/${id}/read`)))
+        
+        // Update local state for unread count
+        const conv = conversations.value.find(c => c.user_id === userId)
+        if (conv) conv.unread_count = 0
+    }
+    
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+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 = ''
+    scrollToBottom()
+    
+    // Update conversation list preview
+    updateConversationPreview(currentChatId.value, res.data.content, 'TEXT')
+    
+  } catch (e) {
+    ElMessage.error('发送失败')
+  }
+}
+
+const handleUpload = async (options: any) => {
+  const { file } = options
+  const formData = new FormData()
+  formData.append('file', file)
+  
+  try {
+    // 1. Upload
+    const uploadRes = await api.post('/messages/upload', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
+    
+    const url = uploadRes.data.url // assuming response { url: '...' }
+    
+    // 2. Send Message
+    const payload = {
+      receiver_id: currentChatId.value,
+      content: url, // Or JSON
+      type: 'MESSAGE',
+      content_type: 'IMAGE',
+      title: '图片'
+    }
+    
+    const res = await api.post('/messages/', payload)
+    messages.value.push(res.data)
+    scrollToBottom()
+    updateConversationPreview(currentChatId.value!, '[图片]', 'IMAGE')
+    
+  } catch (e) {
+    ElMessage.error('上传失败')
+  }
+}
+
+const updateConversationPreview = (userId: number, content: string, type: string) => {
+  const conv = conversations.value.find(c => c.user_id === userId)
+  if (conv) {
+    conv.last_message = type === 'TEXT' ? content : `[${type}]`
+    conv.updated_at = new Date().toISOString()
+    // Move to top
+    conversations.value = [conv, ...conversations.value.filter(c => c.user_id !== userId)]
+  } else {
+    // New conversation? Fetch list again or manually construct
+    fetchConversations()
+  }
+}
+
+const scrollToBottom = () => {
+  nextTick(() => {
+    if (scrollContainer.value) {
+      scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
+    }
+  })
+}
+
+const formatTime = (timeStr: string) => {
+  if (!timeStr) return ''
+  const date = new Date(timeStr)
+  const now = new Date()
+  
+  if (date.toDateString() === now.toDateString()) {
+    // Today: HH:mm
+    return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+  }
+  // Other: MM-DD
+  return `${date.getMonth() + 1}-${date.getDate()}`
+}
+
+// User Search Logic
+const searchUsersRemote = async (query: string) => {
+  if (query) {
+    searchingUsers.value = true
+    try {
+      // Assuming existing user search API
+      const res = await api.get('/users/search', { params: { q: query, limit: 10 } })
+      userOptions.value = res.data.data || res.data // Adapt to actual response structure
+    } catch (e) {
+      console.error(e)
+    } finally {
+      searchingUsers.value = false
+    }
+  } else {
+    userOptions.value = []
+  }
+}
+
+const startNewChat = () => {
+  if (!selectedUserId.value) return
+  
+  const user = userOptions.value.find(u => u.id === selectedUserId.value)
+  if (user) {
+    // Check if exists
+    const existing = conversations.value.find(c => c.user_id === user.id)
+    if (existing) {
+      selectChat(existing)
+    } else {
+      // Add temporary conversation item
+      const newChat = {
+        user_id: user.id,
+        username: user.mobile, // Use mobile as username
+        full_name: user.name || user.username || user.mobile, // Better fallback
+        unread_count: 0,
+        last_message: '',
+        last_message_type: 'TEXT',
+        updated_at: new Date().toISOString()
+      }
+      conversations.value.unshift(newChat)
+      selectChat(newChat)
+    }
+  }
+  showUserSelector.value = false
+  selectedUserId.value = null
+}
+
+</script>
+
+<style scoped>
+.message-layout {
+  display: flex;
+  height: calc(100vh - 120px); /* Adjust based on header/padding */
+  background: #fff;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
+  border: 1px solid #e6e6e6;
+}
+
+.sidebar {
+  width: 280px;
+  background: #f7f7f7;
+  border-right: 1px solid #e6e6e6;
+  display: flex;
+  flex-direction: column;
+}
+
+.search-bar {
+  padding: 15px;
+  display: flex;
+  gap: 10px;
+  background: #f7f7f7;
+  border-bottom: 1px solid #e6e6e6;
+}
+
+.conversation-list {
+  flex: 1;
+  overflow-y: auto;
+}
+
+.chat-item {
+  display: flex;
+  padding: 12px 15px;
+  cursor: pointer;
+  position: relative;
+  transition: background 0.2s;
+}
+
+.chat-item:hover {
+  background: #e9e9e9;
+}
+
+.chat-item.active {
+  background: #c6c6c6;
+}
+
+.chat-info {
+  margin-left: 10px;
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+.chat-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+
+.chat-name {
+  font-weight: 500;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.chat-time {
+  font-size: 12px;
+  color: #999;
+}
+
+.chat-preview {
+  font-size: 13px;
+  color: #888;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.unread-badge {
+  position: absolute;
+  right: 10px;
+  bottom: 12px;
+  background: #ff4d4f;
+  color: white;
+  border-radius: 10px;
+  padding: 0 6px;
+  font-size: 12px;
+  height: 18px;
+  line-height: 18px;
+}
+
+/* Chat Window */
+.chat-window {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #f5f5f5;
+  min-width: 0;
+}
+
+.chat-header-bar {
+  height: 60px;
+  border-bottom: 1px solid #e6e6e6;
+  padding: 0 20px;
+  display: flex;
+  align-items: center;
+  background: #f5f5f5;
+}
+
+.chat-header-bar h3 {
+  margin: 0;
+  font-size: 16px;
+}
+
+.message-stream {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20px;
+}
+
+.message-row {
+  display: flex;
+  margin-bottom: 20px;
+  gap: 10px;
+}
+
+.message-row.is-me {
+  flex-direction: row-reverse;
+}
+
+.message-content-wrapper {
+  max-width: 70%;
+  display: flex;
+  flex-direction: column;
+}
+
+.message-row.is-me .message-content-wrapper {
+  align-items: flex-end;
+}
+
+.message-bubble {
+  background: #fff;
+  padding: 10px 14px;
+  border-radius: 4px;
+  position: relative;
+  font-size: 14px;
+  line-height: 1.5;
+  word-wrap: break-word;
+}
+
+.message-row.is-me .message-bubble {
+  background: #95ec69; /* WeChat green */
+}
+
+.message-time {
+  font-size: 12px;
+  color: #b2b2b2;
+  margin-top: 4px;
+}
+
+.msg-image {
+  max-width: 200px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.input-area {
+  height: 160px;
+  border-top: 1px solid #e6e6e6;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+  padding: 0 20px 20px;
+}
+
+.toolbar {
+  height: 40px;
+  display: flex;
+  align-items: center;
+  gap: 15px;
+}
+
+.tool-icon {
+  font-size: 20px;
+  color: #666;
+  cursor: pointer;
+}
+.tool-icon:hover {
+  color: #333;
+}
+
+.input-textarea {
+  flex: 1;
+  border: none;
+  resize: none;
+  outline: none;
+  font-size: 14px;
+  font-family: inherit;
+}
+
+.send-actions {
+  display: flex;
+  justify-content: flex-end;
+}
+</style>

+ 4 - 0
frontend/tsconfig.json

@@ -13,6 +13,10 @@
     "isolatedModules": true,
     "isolatedModules": true,
     "noEmit": true,
     "noEmit": true,
     "jsx": "preserve",
     "jsx": "preserve",
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    },
 
 
     /* Linting */
     /* Linting */
     "strict": true,
     "strict": true,

+ 2 - 1
frontend/vite.config.ts

@@ -7,7 +7,8 @@ export default defineConfig({
   plugins: [vue()],
   plugins: [vue()],
   resolve: {
   resolve: {
     alias: {
     alias: {
-      'vue': 'vue/dist/vue.esm-bundler.js'
+      'vue': 'vue/dist/vue.esm-bundler.js',
+      '@': path.resolve(__dirname, './src')
     }
     }
   },
   },
   server: {
   server: {

+ 32 - 0
sql/V3__add_message_system.sql

@@ -0,0 +1,32 @@
+CREATE TABLE IF NOT EXISTS messages (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    sender_id INT DEFAULT NULL COMMENT 'Sender User ID',
+    receiver_id INT NOT NULL COMMENT 'Receiver User ID',
+    app_id INT DEFAULT NULL COMMENT 'Source Application ID',
+    type VARCHAR(20) NOT NULL COMMENT 'MESSAGE or NOTIFICATION',
+    content_type VARCHAR(20) NOT NULL DEFAULT 'TEXT' COMMENT 'TEXT, IMAGE, VIDEO, FILE',
+    title VARCHAR(255) NOT NULL COMMENT 'Message Title',
+    content TEXT NOT NULL COMMENT 'Message Content or JSON',
+    action_url VARCHAR(1000) DEFAULT NULL COMMENT 'SSO Jump URL',
+    action_text VARCHAR(50) DEFAULT NULL COMMENT 'Button Text',
+    is_read TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Is Read',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    read_at TIMESTAMP NULL DEFAULT NULL,
+    INDEX idx_receiver_id (receiver_id),
+    INDEX idx_sender_id (sender_id),
+    INDEX idx_app_id (app_id),
+    FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE,
+    FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE SET NULL,
+    FOREIGN KEY (app_id) REFERENCES applications(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Messages Table';
+
+CREATE TABLE IF NOT EXISTS user_devices (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    user_id INT NOT NULL,
+    device_token VARCHAR(255) NOT NULL COMMENT 'Push Token',
+    platform VARCHAR(20) NOT NULL COMMENT 'ios, android, harmony',
+    device_name VARCHAR(100) DEFAULT NULL,
+    last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uq_user_device (user_id, device_token),
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='User Push Devices';