Browse Source

更新消息中心

liuq 1 month ago
parent
commit
ea1b646b9b

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

@@ -3,7 +3,8 @@ from fastapi import APIRouter
 from app.api.v1.endpoints import (
     auth, users, apps, utils, simple_auth, oidc, 
     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()
@@ -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(oidc.router, prefix="/oidc", tags=["OIDC (OpenID Connect)"])
 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 jose import jwt, JWTError
 from sqlalchemy.orm import Session
@@ -10,6 +10,7 @@ from app.core.database import SessionLocal
 from app.models.user import User
 from app.models.application import Application
 from app.schemas.token import TokenPayload
+from app.services.signature_service import SignatureService
 
 reusable_oauth2 = OAuth2PasswordBearer(
     tokenUrl=f"{settings.API_V1_STR}/auth/login",
@@ -58,7 +59,7 @@ def get_current_user(
             if remaining_seconds < threshold:
                 expires_delta = None
                 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
                 new_token = security.create_access_token(
@@ -126,7 +127,7 @@ def get_current_user_optional(
             if remaining_seconds < threshold:
                 expires_delta = None
                 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(
                     subject=token_data.sub, 
@@ -201,3 +202,50 @@ def get_current_app(
     if not app:
         raise HTTPException(status_code=404, detail="App not found")
     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
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Body, Request
+from fastapi.responses import RedirectResponse
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
+from urllib.parse import urlencode, urlparse, parse_qs, urlunparse
 
 from app.api.v1 import deps
 from app.core import security
@@ -767,3 +769,65 @@ def validate_ticket(
         "mapped_key": mapped_key,
         "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="搜索用户")
 def search_users(
-    keyword: str = Query(None),
+    keyword: str = Query(None, alias="q"),
     limit: int = 20,
     db: Session = Depends(deps.get_db),
     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(
         User.is_deleted == 0,
-        User.status == "ACTIVE",
-        User.role.in_(["DEVELOPER", "SUPER_ADMIN"])
+        User.status == "ACTIVE"
     )
     
     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
     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_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:
         case_sensitive = True
         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.system_config import SystemConfig
 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
 apscheduler>=3.10.0
 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 {
     listen 80;
     server_name localhost;
+    
+    # Increase client body size limit for file uploads
+    client_max_body_size 50M;
 
     # Serve Static Files
     location / {
@@ -37,6 +40,9 @@ server {
     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_prefer_server_ciphers off;
+    
+    # Increase client body size limit for file uploads
+    client_max_body_size 50M;
 
     # Serve Static Files
     location / {
@@ -57,4 +63,4 @@ server {
         proxy_set_header Upgrade $http_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',
         name: 'Help',
         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>
           </el-menu-item>
 
+          <el-menu-item index="/dashboard/messages">
+            <el-icon><ChatDotRound /></el-icon>
+            <span>消息中心</span>
+          </el-menu-item>
+
           <el-menu-item 
             v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'DEVELOPER')" 
             index="/dashboard/apps"
@@ -129,7 +134,7 @@
 import { computed, onMounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 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 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,
     "noEmit": true,
     "jsx": "preserve",
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    },
 
     /* Linting */
     "strict": true,

+ 2 - 1
frontend/vite.config.ts

@@ -7,7 +7,8 @@ export default defineConfig({
   plugins: [vue()],
   resolve: {
     alias: {
-      'vue': 'vue/dist/vue.esm-bundler.js'
+      'vue': 'vue/dist/vue.esm-bundler.js',
+      '@': path.resolve(__dirname, './src')
     }
   },
   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';