瀏覽代碼

V2.3.1 新增组织备注

liuq 23 小時之前
父節點
當前提交
3c05cb198d

+ 4 - 0
backend/app/api/v1/api.py

@@ -5,11 +5,15 @@ from app.api.v1.endpoints import (
     open_api, logs, system_logs, backup, login_logs,
     user_import, system, system_config, sms_auth,
     messages, messages_upload, ws, client_distributions, identity_qr,
+    organizations,
 )
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
+api_router.include_router(
+    organizations.router, prefix="/organizations", tags=["组织 (Organizations)"]
+)
 api_router.include_router(user_import.router, prefix="/users", tags=["用户导入 (User Import)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(client_distributions.router, prefix="/client-distributions", tags=["客户端分发 (Client Distribution)"])

+ 23 - 2
backend/app/api/v1/endpoints/messages.py

@@ -21,6 +21,16 @@ import json
 router = APIRouter()
 
 
+def _conversation_remarks(msg: Message, other_user: Optional[User]) -> Optional[str]:
+    """会话列表备注:应用通知(有 app_id) / 旧系统通知(无 app_id) / 对端用户组织名。"""
+    if msg.type == MessageType.NOTIFICATION:
+        return "应用通知" if msg.app_id else None
+    if not other_user:
+        return None
+    org = other_user.organization
+    return org.name if org else None
+
+
 def _conversation_last_preview(msg: Message) -> str:
     """会话列表「最后一条」预览文案:用户通知类型展示 title,便于列表识别。"""
     ct = msg.content_type
@@ -332,10 +342,20 @@ def get_conversations(
     - 私信:按用户聚合
     - 系统通知:按应用(app)拆分成多个会话,类似多个“系统私信”
     """
-    # 查找所有与我相关的消息,并预加载 app 信息,便于显示应用名
+    current_user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == current_user.id)
+        .one()
+    )
+    # 查找所有与我相关的消息,预加载 app 与收发件人组织,避免 N+1
     messages = (
         db.query(Message)
-        .options(joinedload(Message.app))
+        .options(
+            joinedload(Message.app),
+            joinedload(Message.sender).joinedload(User.organization),
+            joinedload(Message.receiver).joinedload(User.organization),
+        )
         .filter(
             or_(
                 Message.sender_id == current_user.id,
@@ -412,6 +432,7 @@ def get_conversations(
                 "is_system": is_system,
                 "app_id": app_id,
                 "app_name": app_name,
+                "remarks": _conversation_remarks(msg, other_user),
             }
         
         # 累加未读数 (只计算接收方是自己的未读消息)

+ 146 - 0
backend/app/api/v1/endpoints/organizations.py

@@ -0,0 +1,146 @@
+import logging
+from typing import List, Any, Dict
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+from sqlalchemy.exc import IntegrityError
+
+from app.api.v1 import deps
+from app.core.utils import get_client_ip
+from app.models.user import User
+from app.models.organization import Organization
+from app.schemas.organization import (
+    OrganizationCreate,
+    OrganizationUpdate,
+    OrganizationResponse,
+)
+from app.services.captcha_service import CaptchaService
+from app.services.log_service import LogService
+from app.schemas.operation_log import ActionType
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+@router.get("/", response_model=List[OrganizationResponse], summary="组织列表")
+def list_organizations(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """扁平组织列表(登录用户可拉取下拉选项)。"""
+    return (
+        db.query(Organization)
+        .order_by(Organization.sort_order.asc(), Organization.id.asc())
+        .all()
+    )
+
+
+@router.post("/", response_model=OrganizationResponse, summary="创建组织")
+def create_organization(
+    body: OrganizationCreate,
+    request: Request,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != "SUPER_ADMIN":
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    org = Organization(
+        name=body.name.strip(),
+        description=body.description,
+        sort_order=body.sort_order,
+    )
+    db.add(org)
+    try:
+        db.commit()
+        db.refresh(org)
+    except IntegrityError:
+        db.rollback()
+        raise HTTPException(status_code=400, detail="组织名称已存在")
+
+    LogService.create_log(
+        db=db,
+        operator_id=current_user.id,
+        action_type=ActionType.ORG_CREATE,
+        target_user_id=None,
+        target_mobile=None,
+        ip_address=get_client_ip(request),
+        details={
+            "resource": "organization",
+            "organization_id": org.id,
+            "name": org.name,
+            "description": org.description,
+            "sort_order": org.sort_order,
+        },
+    )
+    logger.info(f"创建组织: {org.name} (Operator: {current_user.mobile})")
+    return org
+
+
+@router.put("/{organization_id}", response_model=OrganizationResponse, summary="更新组织")
+def update_organization(
+    organization_id: int,
+    body: OrganizationUpdate,
+    request: Request,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != "SUPER_ADMIN":
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    if not CaptchaService.verify_captcha(body.captcha_id, body.captcha_code):
+        logger.warning(f"更新组织失败: 验证码错误 (User: {current_user.mobile})")
+        raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
+    org = db.query(Organization).filter(Organization.id == organization_id).first()
+    if not org:
+        raise HTTPException(status_code=404, detail="组织不存在")
+
+    raw = body.model_dump(exclude_unset=True, exclude={"captcha_id", "captcha_code"})
+    if not raw:
+        raise HTTPException(status_code=400, detail="请至少修改一项内容")
+
+    changes: Dict[str, Any] = {}
+    if "name" in raw and raw["name"] is not None:
+        new_name = raw["name"].strip()
+        if new_name != org.name:
+            changes["name"] = {"old": org.name, "new": new_name}
+    if "description" in raw:
+        if raw["description"] != org.description:
+            changes["description"] = {"old": org.description, "new": raw["description"]}
+    if "sort_order" in raw and raw["sort_order"] is not None:
+        if raw["sort_order"] != org.sort_order:
+            changes["sort_order"] = {"old": org.sort_order, "new": raw["sort_order"]}
+
+    if not changes:
+        raise HTTPException(status_code=400, detail="未检测到变更")
+
+    if "name" in raw and raw["name"] is not None:
+        org.name = raw["name"].strip()
+    if "description" in raw:
+        org.description = raw["description"]
+    if "sort_order" in raw and raw["sort_order"] is not None:
+        org.sort_order = raw["sort_order"]
+
+    try:
+        db.commit()
+        db.refresh(org)
+    except IntegrityError:
+        db.rollback()
+        raise HTTPException(status_code=400, detail="组织名称已存在")
+
+    LogService.create_log(
+        db=db,
+        operator_id=current_user.id,
+        action_type=ActionType.ORG_UPDATE,
+        target_user_id=None,
+        target_mobile=None,
+        ip_address=get_client_ip(request),
+        details={
+            "resource": "organization",
+            "organization_id": org.id,
+            "changes": changes,
+        },
+    )
+    logger.info(f"更新组织: {org.name} (Operator: {current_user.mobile})")
+    return org

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

@@ -1,7 +1,7 @@
 from typing import List, Any
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Request, Query
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
 from sqlalchemy import or_
 from sqlalchemy.exc import IntegrityError
 
@@ -9,8 +9,18 @@ from app.api.v1 import deps
 from app.core import security
 from app.core.utils import generate_english_name, get_client_ip
 from app.models.user import User, UserRole
+from app.models.organization import Organization
 from app.models.mapping import AppUserMapping
-from app.schemas.user import User as UserSchema, UserCreate, UserUpdate, UserList, PromoteUserRequest, BatchResetEnglishNameRequest, DeleteUserRequest
+from app.schemas.user import (
+    User as UserSchema,
+    UserCreate,
+    UserUpdate,
+    UserList,
+    PromoteUserRequest,
+    BatchResetEnglishNameRequest,
+    BatchSetOrganizationRequest,
+    DeleteUserRequest,
+)
 from app.services.webhook_service import WebhookService
 from app.services.captcha_service import CaptchaService
 from app.services.sms_service import SmsService
@@ -31,9 +41,13 @@ def search_users(
     搜索用户(支持姓名、手机号、英文名)。
     支持用户 JWT Token 和应用 API Key (X-App-Access-Token) 两种认证方式。
     """
-    query = db.query(User).filter(
-        User.is_deleted == 0,
-        User.status == "ACTIVE"
+    query = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(
+            User.is_deleted == 0,
+            User.status == "ACTIVE",
+        )
     )
     
     if keyword:
@@ -62,6 +76,7 @@ def read_users(
     name: str = None,
     english_name: str = None,
     keyword: str = None,
+    organization_id: int = None,
     db: Session = Depends(deps.get_db),
     current_user: User = Depends(deps.get_current_active_user),
 ):
@@ -69,9 +84,20 @@ def read_users(
     获取用户列表。已登录用户即可访问。
     支持通过 mobile, name, english_name 精确/模糊筛选,
     也支持通过 keyword 进行跨字段模糊搜索。
+
+    organization_id:
+    - None: 不过滤
+    - 0: 仅未分配组织(organization_id IS NULL)
+    - >0: 指定组织
     """
     # Filter out soft-deleted users
     query = db.query(User).filter(User.is_deleted == 0)
+
+    if organization_id is not None:
+        if organization_id == 0:
+            query = query.filter(User.organization_id.is_(None))
+        else:
+            query = query.filter(User.organization_id == organization_id)
     
     if status:
         query = query.filter(User.status == status)
@@ -97,7 +123,13 @@ def read_users(
         )
         
     total = query.count()
-    users = query.order_by(User.id.desc()).offset(skip).limit(limit).all()
+    users = (
+        query.options(joinedload(User.organization))
+        .order_by(User.id.desc())
+        .offset(skip)
+        .limit(limit)
+        .all()
+    )
     return {"total": total, "items": users}
 
 @router.post("/", response_model=UserSchema, summary="创建用户")
@@ -160,17 +192,30 @@ def create_user(
             english_name = f"{original_english_name}{counter}"
             counter += 1
 
+    org_id = None
+    if user_in.organization_id is not None:
+        org = db.query(Organization).filter(Organization.id == user_in.organization_id).first()
+        if not org:
+            raise HTTPException(status_code=400, detail="组织不存在")
+        org_id = user_in.organization_id
+
     db_user = User(
         mobile=user_in.mobile,
         name=user_in.name,
         english_name=english_name,
         password_hash=security.get_password_hash(user_in.password),
         status="ACTIVE", # Admin created users are active by default
-        role=user_in.role or UserRole.ORDINARY_USER
+        role=user_in.role or UserRole.ORDINARY_USER,
+        organization_id=org_id,
     )
     db.add(db_user)
     db.commit()
-    db.refresh(db_user)
+    db_user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == db_user.id)
+        .first()
+    )
     
     # Log Operation
     LogService.create_log(
@@ -264,6 +309,61 @@ def batch_reset_english_name(
     
     return {"success": True, "count": success_count}
 
+
+@router.post("/batch/organization", summary="批量设置用户所属组织")
+def batch_set_organization(
+    *,
+    db: Session = Depends(deps.get_db),
+    req: BatchSetOrganizationRequest,
+    request: Request,
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    将选中用户设为同一组织,或取消归属(organization_id 为 null)。
+    需超级管理员与管理员密码验证。
+    """
+    if current_user.role != "SUPER_ADMIN":
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    if not security.verify_password(req.admin_password, current_user.password_hash):
+        logger.warning(f"批量设置组织失败: 密码错误 (Admin: {current_user.mobile})")
+        raise HTTPException(status_code=403, detail="管理员密码错误")
+
+    if req.organization_id is not None:
+        org = db.query(Organization).filter(Organization.id == req.organization_id).first()
+        if not org:
+            raise HTTPException(status_code=400, detail="组织不存在")
+
+    user_ids = list(dict.fromkeys(req.user_ids))
+    users = db.query(User).filter(User.id.in_(user_ids), User.is_deleted == 0).all()
+    if len(users) != len(user_ids):
+        raise HTTPException(status_code=400, detail="部分用户不存在或已删除")
+
+    for user in users:
+        old_oid = user.organization_id
+        user.organization_id = req.organization_id
+        db.add(user)
+        if old_oid != req.organization_id:
+            LogService.create_log(
+                db=db,
+                operator_id=current_user.id,
+                action_type=ActionType.UPDATE,
+                target_user_id=user.id,
+                target_mobile=user.mobile,
+                ip_address=get_client_ip(request),
+                details={
+                    "field": "organization_id",
+                    "old": old_oid,
+                    "new": req.organization_id,
+                    "reason": "BATCH_ORGANIZATION",
+                },
+            )
+
+    db.commit()
+    logger.info(f"批量设置组织完成: {len(users)} 个用户 (Admin: {current_user.mobile})")
+    return {"success": True, "count": len(users)}
+
+
 @router.put("/{user_id}", response_model=UserSchema, summary="更新用户")
 def update_user(
     *,
@@ -359,6 +459,25 @@ def update_user(
             
             actions.append((ActionType.CHANGE_ROLE, {"old": user.role, "new": update_data["role"]}))
 
+    if "organization_id" in update_data:
+        if current_user.role != "SUPER_ADMIN":
+            del update_data["organization_id"]
+        else:
+            if not user_in.admin_password or not security.verify_password(user_in.admin_password, current_user.password_hash):
+                logger.warning(f"修改用户组织失败: 密码错误")
+                raise HTTPException(status_code=403, detail="管理员密码错误")
+            oid = update_data["organization_id"]
+            if oid is not None:
+                org = db.query(Organization).filter(Organization.id == oid).first()
+                if not org:
+                    raise HTTPException(status_code=400, detail="组织不存在")
+            actions.append(
+                (
+                    ActionType.UPDATE,
+                    {"field": "organization_id", "old": user.organization_id, "new": oid},
+                )
+            )
+
     if "admin_password" in update_data:
         del update_data["admin_password"]
 
@@ -406,6 +525,12 @@ def update_user(
 
     db.commit()
     db.refresh(user)
+    user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == user.id)
+        .first()
+    )
     
     # Trigger Webhook
     event_type = "UPDATE"
@@ -544,11 +669,18 @@ def delete_user(
 @router.get("/me", response_model=UserSchema, summary="获取当前用户信息")
 def read_user_me(
     current_user: User = Depends(deps.get_current_active_user),
+    db: Session = Depends(deps.get_db),
 ):
     """
     获取当前登录用户的信息。
     """
-    return current_user
+    user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == current_user.id)
+        .first()
+    )
+    return user
 
 @router.get("/{user_id}", response_model=UserSchema, summary="根据ID获取用户")
 def read_user_by_id(
@@ -559,7 +691,12 @@ def read_user_by_id(
     """
     根据用户ID获取特定用户信息。
     """
-    user = db.query(User).filter(User.id == user_id).first()
+    user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == user_id)
+        .first()
+    )
     if not user:
          raise HTTPException(
             status_code=404,

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

@@ -9,4 +9,5 @@ from app.models.backup import BackupRecord, BackupSettings
 from app.models.message import Message
 from app.models.device import UserDevice
 from app.models.app_category import AppCategory
+from app.models.organization import Organization
 from app.models.client_distribution import ClientDistribution, ClientVersion

+ 18 - 0
backend/app/models/organization.py

@@ -0,0 +1,18 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime
+from sqlalchemy.sql import func
+from sqlalchemy.orm import relationship
+from app.core.database import Base
+
+
+class Organization(Base):
+    __tablename__ = "organizations"
+
+    id = Column(Integer, primary_key=True, index=True)
+    name = Column(String(100), unique=True, nullable=False)
+    description = Column(Text, nullable=True)
+    sort_order = Column(Integer, default=0, nullable=False)
+
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
+
+    users = relationship("User", back_populates="organization")

+ 5 - 1
backend/app/models/user.py

@@ -1,6 +1,7 @@
 import enum
-from sqlalchemy import Column, Integer, String, Enum, DateTime
+from sqlalchemy import Column, Integer, String, Enum, DateTime, ForeignKey
 from sqlalchemy.sql import func
+from sqlalchemy.orm import relationship
 from app.core.database import Base
 
 class UserStatus(str, enum.Enum):
@@ -23,6 +24,9 @@ class User(Base):
 
     name = Column(String(100), nullable=True, comment="User Chinese Name")
     english_name = Column(String(100), nullable=True, comment="User English Name (Pinyin)")
+
+    organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
+    organization = relationship("Organization", back_populates="users")
     
     status = Column(Enum(UserStatus), default=UserStatus.PENDING, nullable=False)
     role = Column(Enum(UserRole), default=UserRole.DEVELOPER, nullable=False)

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

@@ -84,6 +84,9 @@ class ConversationResponse(BaseModel):
     app_id: Optional[int] = None
     app_name: Optional[str] = None
 
+    # 列表副文案:有 app 的应用通知为「应用通知」;无 app 的旧系统会话为空;用户会话为对端组织名,无组织为空
+    remarks: Optional[str] = None
+
     class Config:
         from_attributes = True
 

+ 2 - 0
backend/app/schemas/operation_log.py

@@ -17,6 +17,8 @@ class ActionType(str, Enum):
     REGENERATE_SECRET = "REGENERATE_SECRET"
     SYNC_M2M = "SYNC_M2M"
     SYNC = "SYNC"
+    ORG_CREATE = "ORG_CREATE"
+    ORG_UPDATE = "ORG_UPDATE"
 
 class OperationLogBase(BaseModel):
     app_id: Optional[int] = None

+ 32 - 0
backend/app/schemas/organization.py

@@ -0,0 +1,32 @@
+from typing import Optional
+from pydantic import BaseModel, Field
+from datetime import datetime
+
+
+class OrganizationBase(BaseModel):
+    name: str = Field(..., min_length=1, max_length=100)
+    description: Optional[str] = None
+    sort_order: int = 0
+
+
+class OrganizationCreate(OrganizationBase):
+    pass
+
+
+class OrganizationUpdate(BaseModel):
+    """更新组织须填写图形验证码(管理员验证码)。"""
+
+    name: Optional[str] = Field(None, min_length=1, max_length=100)
+    description: Optional[str] = None
+    sort_order: Optional[int] = None
+    captcha_id: str = Field(..., min_length=1, description="图形验证码 ID")
+    captcha_code: str = Field(..., min_length=1, description="图形验证码")
+
+
+class OrganizationResponse(OrganizationBase):
+    id: int
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True

+ 24 - 3
backend/app/schemas/user.py

@@ -1,7 +1,6 @@
-from typing import Optional
-from pydantic import BaseModel, EmailStr, Field, field_validator
+from typing import Optional, Any, List
+from pydantic import BaseModel, Field, field_validator, model_validator
 from datetime import datetime
-from typing import List
 
 # 中国大陆手机号(与 M2M 同步等接口一致)
 _CN_MOBILE_PATTERN = r"^1[3-9]\d{9}$"
@@ -10,6 +9,7 @@ class UserBase(BaseModel):
     mobile: str
     name: Optional[str] = None
     english_name: Optional[str] = None
+    organization_id: Optional[int] = None
     status: str = "PENDING"
     role: Optional[str] = "ORDINARY_USER"
     is_deleted: int = 0
@@ -38,11 +38,19 @@ class BatchResetEnglishNameRequest(BaseModel):
     user_ids: List[int]
     admin_password: str
 
+
+class BatchSetOrganizationRequest(BaseModel):
+    """批量设置用户所属组织;organization_id 为 null 表示取消归属。"""
+    user_ids: List[int] = Field(..., min_length=1, max_length=500)
+    organization_id: Optional[int] = None
+    admin_password: str
+
 class UserUpdate(BaseModel):
     password: Optional[str] = None
     mobile: Optional[str] = None
     name: Optional[str] = None
     english_name: Optional[str] = None
+    organization_id: Optional[int] = None
     status: Optional[str] = None
     role: Optional[str] = None
     is_deleted: Optional[int] = None
@@ -74,9 +82,22 @@ class UserSyncRequest(BaseModel):
 
 class UserInDBBase(UserBase):
     id: int
+    organization_name: Optional[str] = None
     created_at: datetime
     updated_at: datetime
 
+    @model_validator(mode="before")
+    @classmethod
+    def flatten_organization(cls, data: Any) -> Any:
+        from app.models.user import User as UserORM
+
+        if isinstance(data, UserORM):
+            d = {c.name: getattr(data, c.name) for c in data.__table__.columns}
+            org = getattr(data, "organization", None)
+            d["organization_name"] = org.name if org is not None else None
+            return d
+        return data
+
     class Config:
         from_attributes = True
 

+ 12 - 2
docs/api_message_system_v1.md

@@ -280,7 +280,11 @@ erDiagram
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
-    "updated_at": "2026-02-23T10:05:00"
+    "updated_at": "2026-02-23T10:05:00",
+    "is_system": true,
+    "app_id": null,
+    "app_name": null,
+    "remarks": null
   },
   {
     "user_id": 102,
@@ -289,11 +293,17 @@ erDiagram
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message_type": "IMAGE",
-    "updated_at": "2026-02-22T18:30:00"
+    "updated_at": "2026-02-22T18:30:00",
+    "is_system": false,
+    "app_id": null,
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
 ]
 ```
 
+*   **remarks**: 有 `app_id` 的应用通知会话为 `"应用通知"`;`NOTIFICATION` 且无 `app_id` 的旧会话为 `null`;私信为对端组织名,无组织为 `null`。
+
 ### 3.4 获取聊天记录 (Get History)
 
 *   **Endpoint**: `GET /messages/history/{other_user_id}`

+ 12 - 2
frontend/public/docs/api_message_system_v1.md

@@ -210,7 +210,11 @@ erDiagram
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
-    "updated_at": "2026-02-23T10:05:00"
+    "updated_at": "2026-02-23T10:05:00",
+    "is_system": true,
+    "app_id": null,
+    "app_name": null,
+    "remarks": null
   },
   {
     "user_id": 102,
@@ -219,11 +223,17 @@ erDiagram
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message_type": "IMAGE",
-    "updated_at": "2026-02-22T18:30:00"
+    "updated_at": "2026-02-22T18:30:00",
+    "is_system": false,
+    "app_id": null,
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
 ]
 ```
 
+*   **remarks**: 有 `app_id` 的应用通知会话为 `"应用通知"`;`NOTIFICATION` 且无 `app_id` 的旧会话为 `null`;私信为对端组织名,无组织为 `null`。
+
 ### 3.3 获取聊天记录 (Get History)
 
 *   **Endpoint**: `GET /messages/history/{other_user_id}`

+ 37 - 3
frontend/public/docs/client_api_guide.md

@@ -97,6 +97,7 @@ Content-Type: application/json
 | `is_system` | boolean | 是否系统/应用通知会话。 |
 | `app_id` | number \| null | 应用主键(通知会话时可能有)。 |
 | `app_name` | string \| null | 应用名称。 |
+| `remarks` | string \| null | 列表副文案:有 `app_id` 的应用通知会话为 `"应用通知"`;`NOTIFICATION` 且无 `app_id` 的旧会话为 `null`;与用户的私信会话为**对端用户**所属组织名称,对端无组织则为 `null`。 |
 
 请求与响应示例:
 
@@ -117,7 +118,8 @@ Authorization: Bearer <JWT_TOKEN>
     "updated_at": "2026-03-18T10:00:00",
     "is_system": true,
     "app_id": 101,
-    "app_name": "OA系统"
+    "app_name": "OA系统",
+    "remarks": "应用通知"
   }
 ]
 ```
@@ -666,6 +668,7 @@ function getConversationKey(evt: WsEvent, currentUserId: number, currentChatUser
 - 连接断开后自动重连(建议指数退避)
 - 重连成功后主动拉取 `conversations` 和当前窗口 `history`
 - WS 收到消息时仅做增量更新,避免全量刷新
+- 会话列表中的 `remarks`(见 §2.4)仅由 `GET /messages/conversations` 返回;WebSocket `NEW_MESSAGE` 推送不含该字段,若 UI 需要副标题文案,以补拉后的会话列表为准
 
 ### 5.7 可直接使用的前端示例(Web/SPA)
 
@@ -877,7 +880,38 @@ export function startMessageWs(token: string) {
 
 ## 6. 联系人查询接口
 
-### 6.1 推荐:搜索接口
+### 6.1 获取当前登录用户
+
+- **接口**:`GET {{API_BASE_URL}}/users/me`
+- **认证**:`Authorization: Bearer <JWT_TOKEN>`
+- **说明**:返回当前 Access Token 对应用户的信息。响应中含 **`organization_id`**(所属组织 ID,未归属则为 `null`)与 **`organization_name`**(组织名称,与 ID 对应;未归属则为 `null`)。**不返回嵌套的完整组织对象**;若需组织更多字段,请使用组织管理相关接口(以平台 OpenAPI 为准)。
+
+请求示例:
+
+```http
+GET {{API_BASE_URL}}/users/me HTTP/1.1
+Authorization: Bearer <JWT_TOKEN>
+```
+
+响应示例:
+
+```json
+{
+  "id": 2048,
+  "mobile": "13800138000",
+  "name": "张三",
+  "english_name": "zhangsan",
+  "organization_id": 10,
+  "organization_name": "某某部门",
+  "status": "ACTIVE",
+  "role": "ORDINARY_USER",
+  "is_deleted": 0,
+  "created_at": "2026-01-01T12:00:00+08:00",
+  "updated_at": "2026-04-01T10:00:00+08:00"
+}
+```
+
+### 6.2 推荐:搜索接口
 
 - **接口**:`GET {{API_BASE_URL}}/users/search?q=关键词&limit=20`
 - **用途**:发起私信时搜索联系人
@@ -897,7 +931,7 @@ export function startMessageWs(token: string) {
 ]
 ```
 
-### 6.2 管理分页接口
+### 6.3 管理分页接口
 
 - **接口**:`GET {{API_BASE_URL}}/users/?skip=0&limit=20&keyword=张三`
 - **用途**:管理端完整分页检索

+ 12 - 3
frontend/public/docs/message_integration.md

@@ -508,7 +508,11 @@ Authorization: Bearer xxx
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
-    "updated_at": "2026-02-23T10:05:00"
+    "updated_at": "2026-02-23T10:05:00",
+    "is_system": true,
+    "app_id": null,
+    "app_name": null,
+    "remarks": null
   },
   {
     "user_id": 102,
@@ -517,16 +521,21 @@ Authorization: Bearer xxx
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message_type": "IMAGE",
-    "updated_at": "2026-02-22T18:30:00"
+    "updated_at": "2026-02-22T18:30:00",
+    "is_system": false,
+    "app_id": null,
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
 ]
 ```
 
 **说明:**
 
-- `user_id: 0` 表示系统通知会话
+- `user_id: 0` 表示兼容的「全部系统通知会话(无 `app_id` 的旧数据)
 - `unread_count` 表示该会话的未读消息数
 - `last_message` 显示最后一条消息内容(多媒体类型显示为 `[TYPE]`)
+- `remarks`:有 `app_id` 的应用通知为「应用通知」;无 `app_id` 的旧系统通知为 `null`;私信为对端组织名,无组织为 `null`
 
 ### 4.2 获取聊天历史记录
 

+ 35 - 0
frontend/src/api/organizations.ts

@@ -0,0 +1,35 @@
+import api from '../utils/request'
+
+export interface Organization {
+  id: number
+  name: string
+  description?: string | null
+  sort_order: number
+  created_at: string
+  updated_at: string
+}
+
+export const getOrganizations = () => {
+  return api.get<Organization[]>('/organizations/')
+}
+
+export const createOrganization = (data: {
+  name: string
+  description?: string
+  sort_order?: number
+}) => {
+  return api.post<Organization>('/organizations/', data)
+}
+
+export const updateOrganization = (
+  id: number,
+  data: {
+    name?: string
+    description?: string
+    sort_order?: number
+    captcha_id: string
+    captcha_code: string
+  }
+) => {
+  return api.put<Organization>(`/organizations/${id}`, data)
+}

+ 12 - 0
frontend/src/api/users.ts

@@ -7,6 +7,8 @@ export interface User {
   status: string
   name?: string
   english_name?: string
+  organization_id?: number | null
+  organization_name?: string | null
 }
 
 export const searchUsers = (keyword: string) => {
@@ -36,3 +38,13 @@ export const deleteUserWithVerification = (userId: number, data: DeleteUserReque
   return api.post<User>(`/users/${userId}/delete`, data)
 }
 
+export interface BatchSetOrganizationRequest {
+  user_ids: number[]
+  organization_id?: number | null
+  admin_password: string
+}
+
+export const batchSetOrganization = (data: BatchSetOrganizationRequest) => {
+  return api.post<{ success: boolean; count: number }>('/users/batch/organization', data)
+}
+

+ 314 - 4
frontend/src/views/UserList.vue

@@ -28,6 +28,16 @@
           <el-option label="开发者" value="DEVELOPER" />
           <el-option label="管理员" value="SUPER_ADMIN" />
         </el-select>
+        <el-select
+          v-model="organizationFilter"
+          placeholder="组织筛选"
+          clearable
+          @change="handleSearch"
+          style="width: 180px"
+        >
+          <el-option label="未分配" :value="0" />
+          <el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
+        </el-select>
         
         <el-divider direction="vertical" class="action-divider" />
         
@@ -43,6 +53,16 @@
         <el-button type="warning" plain @click="handleBatchResetClick" :disabled="selectedUsers.length === 0">
           <el-icon style="margin-right: 4px"><EditPen /></el-icon> 批量重置英文名
         </el-button>
+        <el-button
+          v-if="isSuperAdmin"
+          type="primary"
+          plain
+          @click="openBatchOrgDialog"
+          :disabled="selectedUsers.length === 0"
+        >
+          批量设置组织
+        </el-button>
+        <el-button v-if="isSuperAdmin" plain @click="openOrgManageDialog">组织管理</el-button>
         <el-button @click="openLogDrawer">
           <el-icon style="margin-right: 4px"><List /></el-icon> 操作日志
         </el-button>
@@ -62,6 +82,11 @@
       <el-table-column prop="mobile" label="手机号" min-width="120" />
       <el-table-column prop="name" label="姓名" min-width="100" />
       <el-table-column prop="english_name" label="英文名" min-width="120" />
+      <el-table-column prop="organization_name" label="所属组织" min-width="120">
+        <template #default="scope">
+          {{ scope.row.organization_name || '—' }}
+        </template>
+      </el-table-column>
       <el-table-column prop="role" label="角色" width="120" align="center">
         <template #default="scope">
           <el-tag :type="scope.row.role === 'SUPER_ADMIN' ? 'danger' : (scope.row.role === 'DEVELOPER' ? 'warning' : 'info')">
@@ -269,6 +294,82 @@
       </template>
     </el-dialog>
 
+    <!-- Batch set organization -->
+    <el-dialog v-model="batchOrgDialogVisible" title="批量设置组织" width="440px">
+      <p style="margin-bottom: 12px; color: #606266">
+        已选择 <strong>{{ selectedUsers.length }}</strong> 位用户;可选择组织或取消归属。
+      </p>
+      <el-form label-position="top">
+        <el-form-item label="所属组织">
+          <el-select v-model="batchOrgTargetId" clearable placeholder="不选择则表示取消归属" style="width: 100%">
+            <el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="管理员密码验证" required>
+          <el-input
+            v-model="batchOrgPassword"
+            type="password"
+            show-password
+            placeholder="请输入管理员密码"
+            autocomplete="new-password"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="batchOrgDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="batchOrgLoading" @click="confirmBatchOrg">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- Organization CRUD (super admin) -->
+    <el-dialog v-model="orgManageVisible" title="组织管理" width="640px" @open="loadOrgListForManage">
+      <p class="text-muted" style="margin: 0 0 12px">组织创建后不可删除,仅可编辑;编辑时需验证图形验证码。</p>
+      <div style="margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center">
+        <el-input v-model="newOrgName" placeholder="新组织名称" style="width: 200px" clearable />
+        <el-input v-model="newOrgDesc" placeholder="描述(可选)" style="width: 200px" clearable />
+        <el-input-number v-model="newOrgSort" :min="0" placeholder="排序" style="width: 120px" />
+        <el-button type="primary" :loading="orgCreating" @click="submitNewOrg">添加</el-button>
+      </div>
+      <el-table :data="orgList" v-loading="orgManageLoading" border stripe size="small" max-height="360">
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="name" label="名称" min-width="120" />
+        <el-table-column prop="description" label="描述" min-width="100" show-overflow-tooltip />
+        <el-table-column prop="sort_order" label="排序" width="80" />
+        <el-table-column label="操作" width="80" fixed="right">
+          <template #default="scope">
+            <el-button type="primary" link @click="openEditOrg(scope.row)">编辑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-dialog>
+
+    <el-dialog v-model="editOrgVisible" title="编辑组织" width="480px" @opened="refreshEditOrgCaptcha">
+      <p class="text-muted" style="margin-top: 0">请填写图形验证码以确认管理员身份。</p>
+      <el-form label-width="88px">
+        <el-form-item label="名称" required>
+          <el-input v-model="editOrgForm.name" maxlength="100" show-word-limit />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="editOrgForm.description" type="textarea" rows="2" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="editOrgForm.sort_order" :min="0" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="图形验证码" required class="captcha-item">
+          <div class="captcha-row-edit">
+            <el-input v-model="editOrgForm.captcha_code" placeholder="请输入右侧验证码" maxlength="10" />
+            <div class="captcha-img" @click="refreshEditOrgCaptcha" v-if="editOrgCaptchaImage">
+              <img :src="editOrgCaptchaImage" alt="captcha" />
+            </div>
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editOrgVisible = false">取消</el-button>
+        <el-button type="primary" :loading="editOrgSubmitting" @click="submitEditOrg">保存</el-button>
+      </template>
+    </el-dialog>
+
     <!-- Batch Reset Dialog -->
     <el-dialog v-model="batchResetDialogVisible" title="批量重置英文名" width="400px">
         <div class="warning-text" style="margin-bottom: 20px; color: #e6a23c; display: flex; align-items: flex-start; gap: 8px;">
@@ -321,6 +422,11 @@
             <el-option label="超级管理员" value="SUPER_ADMIN" />
           </el-select>
         </el-form-item>
+        <el-form-item v-if="isSuperAdmin" label="所属组织">
+          <el-select v-model="createForm.organization_id" clearable placeholder="未分配" style="width: 100%">
+            <el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="管理员验证" prop="admin_password">
            <el-input 
                 v-model="createForm.admin_password" 
@@ -350,6 +456,11 @@
         <el-form-item label="英文名" prop="english_name">
           <el-input v-model="editForm.english_name" placeholder="请输入英文名(选填,自动生成拼音)" />
         </el-form-item>
+        <el-form-item v-if="isSuperAdmin" label="所属组织">
+          <el-select v-model="editForm.organization_id" clearable placeholder="未分配" style="width: 100%">
+            <el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="管理员验证" prop="admin_password">
            <el-input 
                 v-model="editForm.admin_password" 
@@ -383,6 +494,8 @@
                 <el-option label="启用" value="ENABLE" />
                 <el-option label="变更角色" value="CHANGE_ROLE" />
                 <el-option label="重置密码" value="RESET_PASSWORD" />
+                <el-option label="新建组织" value="ORG_CREATE" />
+                <el-option label="编辑组织" value="ORG_UPDATE" />
             </el-select>
             <el-input 
                 v-model="logFilter.keyword" 
@@ -462,7 +575,14 @@ import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from
 import api from '../utils/request'
 import { getLogs, OperationLog } from '../api/logs'
 import { sendSmsCode } from '../api/smsAuth'
-import { deleteUserWithVerification } from '../api/users'
+import { deleteUserWithVerification, batchSetOrganization } from '../api/users'
+import {
+  getOrganizations,
+  createOrganization,
+  updateOrganization,
+  type Organization,
+} from '../api/organizations'
+import { getCaptcha } from '../api/public'
 import { useAuthStore } from '../store/auth'
 import UserImportDialog from '../components/UserImportDialog.vue'
 
@@ -483,14 +603,28 @@ interface User {
   status: string
   role: string
   created_at: string
+  organization_id?: number | null
+  organization_name?: string | null
 }
 
 const users = ref<User[]>([])
 const loading = ref(false)
 const statusFilter = ref('')
 const roleFilter = ref('')
+const organizationFilter = ref<number | undefined>(undefined)
 const searchQuery = ref('')
 
+const orgList = ref<Organization[]>([])
+
+const fetchOrganizations = async () => {
+  try {
+    const res = await getOrganizations()
+    orgList.value = res.data || []
+  } catch {
+    orgList.value = []
+  }
+}
+
 const currentPage = ref(1)
 const pageSize = ref(10)
 const total = ref(0)
@@ -507,7 +641,8 @@ const createForm = reactive({
   english_name: '',
   password: '',
   role: 'ORDINARY_USER',
-  admin_password: ''
+  admin_password: '',
+  organization_id: null as number | null,
 })
 
 // Edit User Logic
@@ -519,7 +654,8 @@ const editForm = reactive({
     mobile: '',
     name: '',
     english_name: '',
-    admin_password: ''
+    admin_password: '',
+    organization_id: null as number | null,
 })
 const editRules = reactive<FormRules>({
     mobile: [
@@ -539,6 +675,7 @@ const handleEditUser = (user: User) => {
     editForm.mobile = user.mobile
     editForm.name = user.name || ''
     editForm.english_name = user.english_name || ''
+    editForm.organization_id = user.organization_id ?? null
     editForm.admin_password = ''
     refreshDynamicField()
     editDialogVisible.value = true
@@ -554,6 +691,7 @@ const submitEditUser = async () => {
                     mobile: editForm.mobile,
                     name: editForm.name,
                     english_name: editForm.english_name,
+                    organization_id: editForm.organization_id,
                     admin_password: editForm.admin_password
                 })
                 ElMessage.success('用户信息更新成功')
@@ -601,6 +739,7 @@ const handleCreateUser = () => {
   createForm.password = ''
   createForm.role = 'ORDINARY_USER'
   createForm.admin_password = ''
+  createForm.organization_id = null
   refreshDynamicField()
   createDialogVisible.value = true
 }
@@ -634,6 +773,9 @@ const fetchUsers = async () => {
     if (statusFilter.value) params.status = statusFilter.value
     if (roleFilter.value) params.role = roleFilter.value
     if (searchQuery.value) params.keyword = searchQuery.value
+    if (organizationFilter.value !== undefined) {
+      params.organization_id = organizationFilter.value
+    }
     
     const res = await api.get('/users/', { params })
     if (res.data && Array.isArray(res.data.items)) {
@@ -1013,13 +1155,161 @@ const getActionLabel = (type: string) => {
         'ENABLE': '启用',
         'RESET_PASSWORD': '重置密码',
         'CHANGE_ROLE': '变更角色',
-        'SYNC_M2M': 'M2M 同步'
+        'SYNC_M2M': 'M2M 同步',
+        'ORG_CREATE': '新建组织',
+        'ORG_UPDATE': '编辑组织',
     }
     return map[type] || type
 }
 
+const batchOrgDialogVisible = ref(false)
+const batchOrgTargetId = ref<number | undefined>(undefined)
+const batchOrgPassword = ref('')
+const batchOrgLoading = ref(false)
+
+const orgManageVisible = ref(false)
+const orgManageLoading = ref(false)
+const newOrgName = ref('')
+const newOrgDesc = ref('')
+const newOrgSort = ref(0)
+const orgCreating = ref(false)
+
+const editOrgVisible = ref(false)
+const editOrgSubmitting = ref(false)
+const editOrgCaptchaImage = ref('')
+const editOrgForm = reactive({
+  id: 0,
+  name: '',
+  description: '',
+  sort_order: 0,
+  captcha_id: '',
+  captcha_code: '',
+})
+
+const refreshEditOrgCaptcha = async () => {
+  try {
+    const res = await getCaptcha()
+    editOrgCaptchaImage.value = res.data.image
+    editOrgForm.captcha_id = res.data.captcha_id
+    editOrgForm.captcha_code = ''
+  } catch {
+    editOrgCaptchaImage.value = ''
+  }
+}
+
+const openEditOrg = (row: Organization) => {
+  editOrgForm.id = row.id
+  editOrgForm.name = row.name
+  editOrgForm.description = row.description ?? ''
+  editOrgForm.sort_order = row.sort_order
+  editOrgForm.captcha_code = ''
+  editOrgVisible.value = true
+}
+
+const submitEditOrg = async () => {
+  const name = editOrgForm.name.trim()
+  if (!name) {
+    ElMessage.warning('请输入组织名称')
+    return
+  }
+  if (!editOrgForm.captcha_code) {
+    ElMessage.warning('请输入图形验证码')
+    return
+  }
+  editOrgSubmitting.value = true
+  try {
+    const desc = editOrgForm.description.trim()
+    await updateOrganization(editOrgForm.id, {
+      name,
+      description: desc || undefined,
+      sort_order: editOrgForm.sort_order,
+      captcha_id: editOrgForm.captcha_id,
+      captcha_code: editOrgForm.captcha_code,
+    })
+    ElMessage.success('已保存')
+    editOrgVisible.value = false
+    await fetchOrganizations()
+  } catch {
+    refreshEditOrgCaptcha()
+  } finally {
+    editOrgSubmitting.value = false
+  }
+}
+
+const loadOrgListForManage = async () => {
+  orgManageLoading.value = true
+  try {
+    await fetchOrganizations()
+  } finally {
+    orgManageLoading.value = false
+  }
+}
+
+const openOrgManageDialog = () => {
+  orgManageVisible.value = true
+}
+
+const submitNewOrg = async () => {
+  const name = newOrgName.value.trim()
+  if (!name) {
+    ElMessage.warning('请输入组织名称')
+    return
+  }
+  orgCreating.value = true
+  try {
+    await createOrganization({
+      name,
+      description: newOrgDesc.value.trim() || undefined,
+      sort_order: newOrgSort.value ?? 0,
+    })
+    ElMessage.success('已添加')
+    newOrgName.value = ''
+    newOrgDesc.value = ''
+    newOrgSort.value = 0
+    await fetchOrganizations()
+  } catch {
+    //
+  } finally {
+    orgCreating.value = false
+  }
+}
+
+const openBatchOrgDialog = () => {
+  if (selectedUsers.value.length === 0) {
+    ElMessage.warning('请先选择用户')
+    return
+  }
+  batchOrgTargetId.value = undefined
+  batchOrgPassword.value = ''
+  batchOrgDialogVisible.value = true
+}
+
+const confirmBatchOrg = async () => {
+  if (!batchOrgPassword.value) {
+    ElMessage.warning('请输入管理员密码')
+    return
+  }
+  batchOrgLoading.value = true
+  try {
+    const res = await batchSetOrganization({
+      user_ids: selectedUsers.value.map((u) => u.id),
+      organization_id: batchOrgTargetId.value ?? null,
+      admin_password: batchOrgPassword.value,
+    })
+    ElMessage.success(`已更新 ${res.data.count} 位用户的组织`)
+    batchOrgDialogVisible.value = false
+    fetchUsers()
+    selectedUsers.value = []
+  } catch {
+    //
+  } finally {
+    batchOrgLoading.value = false
+  }
+}
+
 onMounted(() => {
   fetchUsers()
+  fetchOrganizations()
   if (authStore.token && !authStore.user) {
     authStore.fetchUser().catch(() => {})
   }
@@ -1105,6 +1395,26 @@ onUnmounted(() => {
 .sms-row .el-input {
     flex: 1;
 }
+.captcha-row-edit {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+}
+.captcha-row-edit .el-input {
+  flex: 1;
+}
+.captcha-img {
+  cursor: pointer;
+  flex-shrink: 0;
+  height: 40px;
+  line-height: 0;
+}
+.captcha-img img {
+  height: 40px;
+  display: block;
+  border-radius: 4px;
+}
 :deep(.danger-dropdown-item) {
     color: #f56c6c;
 }

+ 31 - 4
frontend/src/views/help/ClientApi.vue

@@ -8,7 +8,7 @@
       </el-button>
     </div>
     <p class="intro">
-      本文档面向客户端(Web/移动端),说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)、个人身份二维码、短信验证码重置密码(忘记密码,开放接口)等接口。
+      本文档面向客户端(Web/移动端),说明消息接收、私信、通知、会话聚合、WebSocket 通信、当前用户信息(含组织)、联系人查询、应用中心(快捷导航)、个人身份二维码、短信验证码重置密码(忘记密码,开放接口)等接口。
     </p>
 
     <div class="section">
@@ -38,7 +38,7 @@
         </thead>
         <tbody>
           <tr><td><code>GET /api/v1/messages/unread-count</code></td><td>当前用户作为<strong>接收方</strong>的<strong>全局</strong>未读消息条数(整数)。</td></tr>
-          <tr><td><code>GET /api/v1/messages/conversations</code></td><td>会话聚合列表,每行含 <code>user_id</code>、<code>unread_count</code>、<code>last_message</code> 等。</td></tr>
+          <tr><td><code>GET /api/v1/messages/conversations</code></td><td>会话聚合列表,每行含 <code>user_id</code>、<code>unread_count</code>、<code>last_message</code>、<code>remarks</code> 等。</td></tr>
           <tr><td><code>GET /api/v1/messages/history/{other_user_id}</code></td><td>某一会话的聊天记录(分页)。</td></tr>
           <tr><td><code>PUT /api/v1/messages/history/{other_user_id}/read-all</code></td><td>仅将该会话范围内、你是接收方且未读的消息标为已读。</td></tr>
         </tbody>
@@ -112,6 +112,7 @@ Content-Type: application/json
           <tr><td><code>is_system</code></td><td>boolean</td><td>是否系统/应用通知会话。</td></tr>
           <tr><td><code>app_id</code></td><td>number | null</td><td>应用主键(通知会话时有值)。</td></tr>
           <tr><td><code>app_name</code></td><td>string | null</td><td>应用名称。</td></tr>
+          <tr><td><code>remarks</code></td><td>string | null</td><td>列表副文案:有 <code>app_id</code> 的应用通知为「应用通知」;无 <code>app_id</code> 的旧系统通知为 <code>null</code>;私信为对端组织名,无组织为 <code>null</code>。</td></tr>
         </tbody>
       </table>
       <div class="code-block">
@@ -135,7 +136,8 @@ Content-Type: application/json
     "updated_at": "2026-03-18T10:00:00",
     "is_system": true,
     "app_id": 101,
-    "app_name": "OA系统"
+    "app_name": "OA系统",
+    "remarks": "应用通知"
   },
   {
     "user_id": 2048,
@@ -147,7 +149,8 @@ Content-Type: application/json
     "updated_at": "2026-03-18T09:20:00",
     "is_system": false,
     "app_id": null,
-    "app_name": null
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
 ]</pre>
       </div>
@@ -613,11 +616,35 @@ ws.onmessage = (event) =&gt; {
     <div class="section">
       <h3>7. 联系人查询接口</h3>
       <ul>
+        <li><strong>当前用户</strong>:<code>GET /api/v1/users/me</code>(含 <code>organization_id</code>、<code>organization_name</code>,未归属组织时二者为 <code>null</code>;无嵌套组织对象)</li>
         <li><strong>推荐接口</strong>:<code>GET /api/v1/users/search?q=关键词&amp;limit=20</code></li>
         <li><strong>搜索字段</strong>:手机号、姓名、英文名</li>
         <li><strong>管理分页接口</strong>:<code>GET /api/v1/users/?skip=0&amp;limit=20&amp;keyword=xxx</code></li>
       </ul>
 
+      <h4>示例:获取当前登录用户</h4>
+      <div class="code-block">
+        <pre>
+GET /api/v1/users/me HTTP/1.1
+Authorization: Bearer &lt;JWT_TOKEN&gt;</pre>
+      </div>
+      <div class="code-block">
+        <pre>
+{
+  "id": 2048,
+  "mobile": "13800138000",
+  "name": "张三",
+  "english_name": "zhangsan",
+  "organization_id": 10,
+  "organization_name": "某某部门",
+  "status": "ACTIVE",
+  "role": "ORDINARY_USER",
+  "is_deleted": 0,
+  "created_at": "2026-01-01T12:00:00+08:00",
+  "updated_at": "2026-04-01T10:00:00+08:00"
+}</pre>
+      </div>
+
       <h4>示例:搜索联系人(推荐)</h4>
       <div class="code-block">
         <pre>

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

@@ -585,7 +585,11 @@ GET /api/v1/apps/?search=OA
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
-    "updated_at": "2026-02-23T10:05:00"
+    "updated_at": "2026-02-23T10:05:00",
+    "is_system": true,
+    "app_id": null,
+    "app_name": null,
+    "remarks": null
   },
   {
     "user_id": 102,
@@ -594,16 +598,21 @@ GET /api/v1/apps/?search=OA
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message_type": "IMAGE",
-    "updated_at": "2026-02-22T18:30:00"
+    "updated_at": "2026-02-22T18:30:00",
+    "is_system": false,
+    "app_id": null,
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
 ]
         </pre>
       </div>
       <p><strong>说明:</strong></p>
       <ul>
-        <li><code>user_id: 0</code> 表示系统通知会话</li>
+        <li><code>user_id: 0</code> 表示兼容的「全部系统通知会话(无 <code>app_id</code> 的旧数据)</li>
         <li><code>unread_count</code> 表示该会话的未读消息数</li>
         <li><code>last_message</code> 显示最后一条消息内容(多媒体类型显示为 <code>[TYPE]</code>)</li>
+        <li><code>remarks</code>:有 <code>app_id</code> 的应用通知为「应用通知」;无 <code>app_id</code> 的旧系统通知为 <code>null</code>;私信为对端组织名,无组织为 <code>null</code></li>
       </ul>
 
       <h4>4.2 获取聊天历史记录</h4>

+ 39 - 12
frontend/src/views/message/index.vue

@@ -50,6 +50,7 @@
               </span>
               <span class="chat-time">{{ formatTime(chat.updated_at) }}</span>
             </div>
+            <div v-if="chat.remarks" class="chat-remarks">{{ chat.remarks }}</div>
             <div class="chat-preview">
               {{ chat.last_message }}
             </div>
@@ -69,15 +70,18 @@
       <template v-if="currentChatId !== null">
         <!-- 顶部标题 -->
         <header class="chat-header-bar">
-          <h3>
-            <!-- 系统会话优先显示应用名 -->
-            <template v-if="currentChatUser?.is_system">
-              {{ currentChatUser.app_name || currentChatUser.full_name || currentChatUser.username }}
-            </template>
-            <template v-else>
-              {{ currentChatUser?.full_name || currentChatUser?.username }}
-            </template>
-          </h3>
+          <div class="chat-header-titles">
+            <h3>
+              <!-- 系统会话优先显示应用名 -->
+              <template v-if="currentChatUser?.is_system">
+                {{ currentChatUser.app_name || currentChatUser.full_name || currentChatUser.username }}
+              </template>
+              <template v-else>
+                {{ currentChatUser?.full_name || currentChatUser?.username }}
+              </template>
+            </h3>
+            <p v-if="currentChatUser?.remarks" class="chat-header-remarks">{{ currentChatUser.remarks }}</p>
+          </div>
         </header>
 
         <!-- 消息流区域 -->
@@ -316,7 +320,9 @@ const filteredConversations = computed(() => {
   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))
+    (c.username && c.username.toLowerCase().includes(lower)) ||
+    (c.remarks && String(c.remarks).toLowerCase().includes(lower)) ||
+    (c.app_name && String(c.app_name).toLowerCase().includes(lower))
   )
 })
 
@@ -730,6 +736,15 @@ const handleNotificationAction = async (msg: any) => {
   margin-bottom: 4px;
 }
 
+.chat-remarks {
+  font-size: 12px;
+  color: #999;
+  margin: 0 0 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .chat-name {
   font-weight: 500;
   color: #333;
@@ -774,17 +789,29 @@ const handleNotificationAction = async (msg: any) => {
 }
 
 .chat-header-bar {
-  height: 60px;
+  min-height: 60px;
   border-bottom: 1px solid #e6e6e6;
-  padding: 0 20px;
+  padding: 10px 20px;
   display: flex;
   align-items: center;
   background: #f5f5f5;
 }
 
+.chat-header-titles {
+  min-width: 0;
+}
+
 .chat-header-bar h3 {
   margin: 0;
   font-size: 16px;
+  line-height: 1.3;
+}
+
+.chat-header-remarks {
+  margin: 4px 0 0;
+  font-size: 12px;
+  color: #888;
+  line-height: 1.3;
 }
 
 .message-stream {

+ 23 - 0
sql/V6__add_organizations.sql

@@ -0,0 +1,23 @@
+-- V6__add_organizations.sql
+-- 用户所属组织(扁平列表),users.organization_id 可空、无默认
+
+CREATE TABLE IF NOT EXISTS organizations (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '组织名称(全局唯一)',
+    description TEXT NULL COMMENT '描述',
+    sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uq_organizations_name (name),
+    INDEX idx_organizations_sort (sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户所属组织(扁平)';
+
+ALTER TABLE users
+ADD COLUMN organization_id INT NULL COMMENT '所属组织ID' AFTER english_name;
+
+ALTER TABLE users
+ADD CONSTRAINT fk_users_organization
+FOREIGN KEY (organization_id) REFERENCES organizations(id)
+ON DELETE SET NULL;
+
+CREATE INDEX idx_users_organization_id ON users(organization_id);