Procházet zdrojové kódy

V2.3.1 新增组织备注

liuq před 2 dny
rodič
revize
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,
     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, client_distributions, identity_qr,
     messages, messages_upload, ws, client_distributions, identity_qr,
+    organizations,
 )
 )
 
 
 api_router = APIRouter()
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
 api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Users)"])
 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(user_import.router, prefix="/users", tags=["用户导入 (User Import)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(client_distributions.router, prefix="/client-distributions", tags=["客户端分发 (Client Distribution)"])
 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()
 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:
 def _conversation_last_preview(msg: Message) -> str:
     """会话列表「最后一条」预览文案:用户通知类型展示 title,便于列表识别。"""
     """会话列表「最后一条」预览文案:用户通知类型展示 title,便于列表识别。"""
     ct = msg.content_type
     ct = msg.content_type
@@ -332,10 +342,20 @@ def get_conversations(
     - 私信:按用户聚合
     - 私信:按用户聚合
     - 系统通知:按应用(app)拆分成多个会话,类似多个“系统私信”
     - 系统通知:按应用(app)拆分成多个会话,类似多个“系统私信”
     """
     """
-    # 查找所有与我相关的消息,并预加载 app 信息,便于显示应用名
+    current_user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == current_user.id)
+        .one()
+    )
+    # 查找所有与我相关的消息,预加载 app 与收发件人组织,避免 N+1
     messages = (
     messages = (
         db.query(Message)
         db.query(Message)
-        .options(joinedload(Message.app))
+        .options(
+            joinedload(Message.app),
+            joinedload(Message.sender).joinedload(User.organization),
+            joinedload(Message.receiver).joinedload(User.organization),
+        )
         .filter(
         .filter(
             or_(
             or_(
                 Message.sender_id == current_user.id,
                 Message.sender_id == current_user.id,
@@ -412,6 +432,7 @@ def get_conversations(
                 "is_system": is_system,
                 "is_system": is_system,
                 "app_id": app_id,
                 "app_id": app_id,
                 "app_name": app_name,
                 "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
 from typing import List, Any
 import logging
 import logging
 from fastapi import APIRouter, Depends, HTTPException, Body, BackgroundTasks, Request, Query
 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 import or_
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
 
 
@@ -9,8 +9,18 @@ from app.api.v1 import deps
 from app.core import security
 from app.core import security
 from app.core.utils import generate_english_name, get_client_ip
 from app.core.utils import generate_english_name, get_client_ip
 from app.models.user import User, UserRole
 from app.models.user import User, UserRole
+from app.models.organization import Organization
 from app.models.mapping import AppUserMapping
 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.webhook_service import WebhookService
 from app.services.captcha_service import CaptchaService
 from app.services.captcha_service import CaptchaService
 from app.services.sms_service import SmsService
 from app.services.sms_service import SmsService
@@ -31,9 +41,13 @@ def search_users(
     搜索用户(支持姓名、手机号、英文名)。
     搜索用户(支持姓名、手机号、英文名)。
     支持用户 JWT Token 和应用 API Key (X-App-Access-Token) 两种认证方式。
     支持用户 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:
     if keyword:
@@ -62,6 +76,7 @@ def read_users(
     name: str = None,
     name: str = None,
     english_name: str = None,
     english_name: str = None,
     keyword: str = None,
     keyword: str = None,
+    organization_id: int = None,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
     current_user: User = Depends(deps.get_current_active_user),
     current_user: User = Depends(deps.get_current_active_user),
 ):
 ):
@@ -69,9 +84,20 @@ def read_users(
     获取用户列表。已登录用户即可访问。
     获取用户列表。已登录用户即可访问。
     支持通过 mobile, name, english_name 精确/模糊筛选,
     支持通过 mobile, name, english_name 精确/模糊筛选,
     也支持通过 keyword 进行跨字段模糊搜索。
     也支持通过 keyword 进行跨字段模糊搜索。
+
+    organization_id:
+    - None: 不过滤
+    - 0: 仅未分配组织(organization_id IS NULL)
+    - >0: 指定组织
     """
     """
     # Filter out soft-deleted users
     # Filter out soft-deleted users
     query = db.query(User).filter(User.is_deleted == 0)
     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:
     if status:
         query = query.filter(User.status == status)
         query = query.filter(User.status == status)
@@ -97,7 +123,13 @@ def read_users(
         )
         )
         
         
     total = query.count()
     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}
     return {"total": total, "items": users}
 
 
 @router.post("/", response_model=UserSchema, summary="创建用户")
 @router.post("/", response_model=UserSchema, summary="创建用户")
@@ -160,17 +192,30 @@ def create_user(
             english_name = f"{original_english_name}{counter}"
             english_name = f"{original_english_name}{counter}"
             counter += 1
             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(
     db_user = User(
         mobile=user_in.mobile,
         mobile=user_in.mobile,
         name=user_in.name,
         name=user_in.name,
         english_name=english_name,
         english_name=english_name,
         password_hash=security.get_password_hash(user_in.password),
         password_hash=security.get_password_hash(user_in.password),
         status="ACTIVE", # Admin created users are active by default
         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.add(db_user)
     db.commit()
     db.commit()
-    db.refresh(db_user)
+    db_user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == db_user.id)
+        .first()
+    )
     
     
     # Log Operation
     # Log Operation
     LogService.create_log(
     LogService.create_log(
@@ -264,6 +309,61 @@ def batch_reset_english_name(
     
     
     return {"success": True, "count": success_count}
     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="更新用户")
 @router.put("/{user_id}", response_model=UserSchema, summary="更新用户")
 def update_user(
 def update_user(
     *,
     *,
@@ -359,6 +459,25 @@ def update_user(
             
             
             actions.append((ActionType.CHANGE_ROLE, {"old": user.role, "new": update_data["role"]}))
             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:
     if "admin_password" in update_data:
         del update_data["admin_password"]
         del update_data["admin_password"]
 
 
@@ -406,6 +525,12 @@ def update_user(
 
 
     db.commit()
     db.commit()
     db.refresh(user)
     db.refresh(user)
+    user = (
+        db.query(User)
+        .options(joinedload(User.organization))
+        .filter(User.id == user.id)
+        .first()
+    )
     
     
     # Trigger Webhook
     # Trigger Webhook
     event_type = "UPDATE"
     event_type = "UPDATE"
@@ -544,11 +669,18 @@ def delete_user(
 @router.get("/me", response_model=UserSchema, summary="获取当前用户信息")
 @router.get("/me", response_model=UserSchema, summary="获取当前用户信息")
 def read_user_me(
 def read_user_me(
     current_user: User = Depends(deps.get_current_active_user),
     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获取用户")
 @router.get("/{user_id}", response_model=UserSchema, summary="根据ID获取用户")
 def read_user_by_id(
 def read_user_by_id(
@@ -559,7 +691,12 @@ def read_user_by_id(
     """
     """
     根据用户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:
     if not user:
          raise HTTPException(
          raise HTTPException(
             status_code=404,
             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.message import Message
 from app.models.device import UserDevice
 from app.models.device import UserDevice
 from app.models.app_category import AppCategory
 from app.models.app_category import AppCategory
+from app.models.organization import Organization
 from app.models.client_distribution import ClientDistribution, ClientVersion
 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
 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.sql import func
+from sqlalchemy.orm import relationship
 from app.core.database import Base
 from app.core.database import Base
 
 
 class UserStatus(str, enum.Enum):
 class UserStatus(str, enum.Enum):
@@ -23,6 +24,9 @@ class User(Base):
 
 
     name = Column(String(100), nullable=True, comment="User Chinese Name")
     name = Column(String(100), nullable=True, comment="User Chinese Name")
     english_name = Column(String(100), nullable=True, comment="User English Name (Pinyin)")
     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)
     status = Column(Enum(UserStatus), default=UserStatus.PENDING, nullable=False)
     role = Column(Enum(UserRole), default=UserRole.DEVELOPER, 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_id: Optional[int] = None
     app_name: Optional[str] = None
     app_name: Optional[str] = None
 
 
+    # 列表副文案:有 app 的应用通知为「应用通知」;无 app 的旧系统会话为空;用户会话为对端组织名,无组织为空
+    remarks: Optional[str] = None
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 

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

@@ -17,6 +17,8 @@ class ActionType(str, Enum):
     REGENERATE_SECRET = "REGENERATE_SECRET"
     REGENERATE_SECRET = "REGENERATE_SECRET"
     SYNC_M2M = "SYNC_M2M"
     SYNC_M2M = "SYNC_M2M"
     SYNC = "SYNC"
     SYNC = "SYNC"
+    ORG_CREATE = "ORG_CREATE"
+    ORG_UPDATE = "ORG_UPDATE"
 
 
 class OperationLogBase(BaseModel):
 class OperationLogBase(BaseModel):
     app_id: Optional[int] = None
     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 datetime import datetime
-from typing import List
 
 
 # 中国大陆手机号(与 M2M 同步等接口一致)
 # 中国大陆手机号(与 M2M 同步等接口一致)
 _CN_MOBILE_PATTERN = r"^1[3-9]\d{9}$"
 _CN_MOBILE_PATTERN = r"^1[3-9]\d{9}$"
@@ -10,6 +9,7 @@ class UserBase(BaseModel):
     mobile: str
     mobile: str
     name: Optional[str] = None
     name: Optional[str] = None
     english_name: Optional[str] = None
     english_name: Optional[str] = None
+    organization_id: Optional[int] = None
     status: str = "PENDING"
     status: str = "PENDING"
     role: Optional[str] = "ORDINARY_USER"
     role: Optional[str] = "ORDINARY_USER"
     is_deleted: int = 0
     is_deleted: int = 0
@@ -38,11 +38,19 @@ class BatchResetEnglishNameRequest(BaseModel):
     user_ids: List[int]
     user_ids: List[int]
     admin_password: str
     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):
 class UserUpdate(BaseModel):
     password: Optional[str] = None
     password: Optional[str] = None
     mobile: Optional[str] = None
     mobile: Optional[str] = None
     name: Optional[str] = None
     name: Optional[str] = None
     english_name: Optional[str] = None
     english_name: Optional[str] = None
+    organization_id: Optional[int] = None
     status: Optional[str] = None
     status: Optional[str] = None
     role: Optional[str] = None
     role: Optional[str] = None
     is_deleted: Optional[int] = None
     is_deleted: Optional[int] = None
@@ -74,9 +82,22 @@ class UserSyncRequest(BaseModel):
 
 
 class UserInDBBase(UserBase):
 class UserInDBBase(UserBase):
     id: int
     id: int
+    organization_name: Optional[str] = None
     created_at: datetime
     created_at: datetime
     updated_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:
     class Config:
         from_attributes = True
         from_attributes = True
 
 

+ 12 - 2
docs/api_message_system_v1.md

@@ -280,7 +280,11 @@ erDiagram
     "unread_count": 5,
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
     "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,
     "user_id": 102,
@@ -289,11 +293,17 @@ erDiagram
     "unread_count": 0,
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message": "[IMAGE]",
     "last_message_type": "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)
 ### 3.4 获取聊天记录 (Get History)
 
 
 *   **Endpoint**: `GET /messages/history/{other_user_id}`
 *   **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,
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
     "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,
     "user_id": 102,
@@ -219,11 +223,17 @@ erDiagram
     "unread_count": 0,
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message": "[IMAGE]",
     "last_message_type": "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)
 ### 3.3 获取聊天记录 (Get History)
 
 
 *   **Endpoint**: `GET /messages/history/{other_user_id}`
 *   **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 | 是否系统/应用通知会话。 |
 | `is_system` | boolean | 是否系统/应用通知会话。 |
 | `app_id` | number \| null | 应用主键(通知会话时可能有)。 |
 | `app_id` | number \| null | 应用主键(通知会话时可能有)。 |
 | `app_name` | string \| 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",
     "updated_at": "2026-03-18T10:00:00",
     "is_system": true,
     "is_system": true,
     "app_id": 101,
     "app_id": 101,
-    "app_name": "OA系统"
+    "app_name": "OA系统",
+    "remarks": "应用通知"
   }
   }
 ]
 ]
 ```
 ```
@@ -666,6 +668,7 @@ function getConversationKey(evt: WsEvent, currentUserId: number, currentChatUser
 - 连接断开后自动重连(建议指数退避)
 - 连接断开后自动重连(建议指数退避)
 - 重连成功后主动拉取 `conversations` 和当前窗口 `history`
 - 重连成功后主动拉取 `conversations` 和当前窗口 `history`
 - WS 收到消息时仅做增量更新,避免全量刷新
 - WS 收到消息时仅做增量更新,避免全量刷新
+- 会话列表中的 `remarks`(见 §2.4)仅由 `GET /messages/conversations` 返回;WebSocket `NEW_MESSAGE` 推送不含该字段,若 UI 需要副标题文案,以补拉后的会话列表为准
 
 
 ### 5.7 可直接使用的前端示例(Web/SPA)
 ### 5.7 可直接使用的前端示例(Web/SPA)
 
 
@@ -877,7 +880,38 @@ export function startMessageWs(token: string) {
 
 
 ## 6. 联系人查询接口
 ## 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`
 - **接口**:`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=张三`
 - **接口**:`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,
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
     "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,
     "user_id": 102,
@@ -517,16 +521,21 @@ Authorization: Bearer xxx
     "unread_count": 0,
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message": "[IMAGE]",
     "last_message_type": "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` 表示该会话的未读消息数
 - `unread_count` 表示该会话的未读消息数
 - `last_message` 显示最后一条消息内容(多媒体类型显示为 `[TYPE]`)
 - `last_message` 显示最后一条消息内容(多媒体类型显示为 `[TYPE]`)
+- `remarks`:有 `app_id` 的应用通知为「应用通知」;无 `app_id` 的旧系统通知为 `null`;私信为对端组织名,无组织为 `null`
 
 
 ### 4.2 获取聊天历史记录
 ### 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
   status: string
   name?: string
   name?: string
   english_name?: string
   english_name?: string
+  organization_id?: number | null
+  organization_name?: string | null
 }
 }
 
 
 export const searchUsers = (keyword: string) => {
 export const searchUsers = (keyword: string) => {
@@ -36,3 +38,13 @@ export const deleteUserWithVerification = (userId: number, data: DeleteUserReque
   return api.post<User>(`/users/${userId}/delete`, data)
   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="DEVELOPER" />
           <el-option label="管理员" value="SUPER_ADMIN" />
           <el-option label="管理员" value="SUPER_ADMIN" />
         </el-select>
         </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" />
         <el-divider direction="vertical" class="action-divider" />
         
         
@@ -43,6 +53,16 @@
         <el-button type="warning" plain @click="handleBatchResetClick" :disabled="selectedUsers.length === 0">
         <el-button type="warning" plain @click="handleBatchResetClick" :disabled="selectedUsers.length === 0">
           <el-icon style="margin-right: 4px"><EditPen /></el-icon> 批量重置英文名
           <el-icon style="margin-right: 4px"><EditPen /></el-icon> 批量重置英文名
         </el-button>
         </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-button @click="openLogDrawer">
           <el-icon style="margin-right: 4px"><List /></el-icon> 操作日志
           <el-icon style="margin-right: 4px"><List /></el-icon> 操作日志
         </el-button>
         </el-button>
@@ -62,6 +82,11 @@
       <el-table-column prop="mobile" label="手机号" min-width="120" />
       <el-table-column prop="mobile" label="手机号" min-width="120" />
       <el-table-column prop="name" label="姓名" min-width="100" />
       <el-table-column prop="name" label="姓名" min-width="100" />
       <el-table-column prop="english_name" label="英文名" min-width="120" />
       <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">
       <el-table-column prop="role" label="角色" width="120" align="center">
         <template #default="scope">
         <template #default="scope">
           <el-tag :type="scope.row.role === 'SUPER_ADMIN' ? 'danger' : (scope.row.role === 'DEVELOPER' ? 'warning' : 'info')">
           <el-tag :type="scope.row.role === 'SUPER_ADMIN' ? 'danger' : (scope.row.role === 'DEVELOPER' ? 'warning' : 'info')">
@@ -269,6 +294,82 @@
       </template>
       </template>
     </el-dialog>
     </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 -->
     <!-- Batch Reset Dialog -->
     <el-dialog v-model="batchResetDialogVisible" title="批量重置英文名" width="400px">
     <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;">
         <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-option label="超级管理员" value="SUPER_ADMIN" />
           </el-select>
           </el-select>
         </el-form-item>
         </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-form-item label="管理员验证" prop="admin_password">
            <el-input 
            <el-input 
                 v-model="createForm.admin_password" 
                 v-model="createForm.admin_password" 
@@ -350,6 +456,11 @@
         <el-form-item label="英文名" prop="english_name">
         <el-form-item label="英文名" prop="english_name">
           <el-input v-model="editForm.english_name" placeholder="请输入英文名(选填,自动生成拼音)" />
           <el-input v-model="editForm.english_name" placeholder="请输入英文名(选填,自动生成拼音)" />
         </el-form-item>
         </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-form-item label="管理员验证" prop="admin_password">
            <el-input 
            <el-input 
                 v-model="editForm.admin_password" 
                 v-model="editForm.admin_password" 
@@ -383,6 +494,8 @@
                 <el-option label="启用" value="ENABLE" />
                 <el-option label="启用" value="ENABLE" />
                 <el-option label="变更角色" value="CHANGE_ROLE" />
                 <el-option label="变更角色" value="CHANGE_ROLE" />
                 <el-option label="重置密码" value="RESET_PASSWORD" />
                 <el-option label="重置密码" value="RESET_PASSWORD" />
+                <el-option label="新建组织" value="ORG_CREATE" />
+                <el-option label="编辑组织" value="ORG_UPDATE" />
             </el-select>
             </el-select>
             <el-input 
             <el-input 
                 v-model="logFilter.keyword" 
                 v-model="logFilter.keyword" 
@@ -462,7 +575,14 @@ import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from
 import api from '../utils/request'
 import api from '../utils/request'
 import { getLogs, OperationLog } from '../api/logs'
 import { getLogs, OperationLog } from '../api/logs'
 import { sendSmsCode } from '../api/smsAuth'
 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 { useAuthStore } from '../store/auth'
 import UserImportDialog from '../components/UserImportDialog.vue'
 import UserImportDialog from '../components/UserImportDialog.vue'
 
 
@@ -483,14 +603,28 @@ interface User {
   status: string
   status: string
   role: string
   role: string
   created_at: string
   created_at: string
+  organization_id?: number | null
+  organization_name?: string | null
 }
 }
 
 
 const users = ref<User[]>([])
 const users = ref<User[]>([])
 const loading = ref(false)
 const loading = ref(false)
 const statusFilter = ref('')
 const statusFilter = ref('')
 const roleFilter = ref('')
 const roleFilter = ref('')
+const organizationFilter = ref<number | undefined>(undefined)
 const searchQuery = ref('')
 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 currentPage = ref(1)
 const pageSize = ref(10)
 const pageSize = ref(10)
 const total = ref(0)
 const total = ref(0)
@@ -507,7 +641,8 @@ const createForm = reactive({
   english_name: '',
   english_name: '',
   password: '',
   password: '',
   role: 'ORDINARY_USER',
   role: 'ORDINARY_USER',
-  admin_password: ''
+  admin_password: '',
+  organization_id: null as number | null,
 })
 })
 
 
 // Edit User Logic
 // Edit User Logic
@@ -519,7 +654,8 @@ const editForm = reactive({
     mobile: '',
     mobile: '',
     name: '',
     name: '',
     english_name: '',
     english_name: '',
-    admin_password: ''
+    admin_password: '',
+    organization_id: null as number | null,
 })
 })
 const editRules = reactive<FormRules>({
 const editRules = reactive<FormRules>({
     mobile: [
     mobile: [
@@ -539,6 +675,7 @@ const handleEditUser = (user: User) => {
     editForm.mobile = user.mobile
     editForm.mobile = user.mobile
     editForm.name = user.name || ''
     editForm.name = user.name || ''
     editForm.english_name = user.english_name || ''
     editForm.english_name = user.english_name || ''
+    editForm.organization_id = user.organization_id ?? null
     editForm.admin_password = ''
     editForm.admin_password = ''
     refreshDynamicField()
     refreshDynamicField()
     editDialogVisible.value = true
     editDialogVisible.value = true
@@ -554,6 +691,7 @@ const submitEditUser = async () => {
                     mobile: editForm.mobile,
                     mobile: editForm.mobile,
                     name: editForm.name,
                     name: editForm.name,
                     english_name: editForm.english_name,
                     english_name: editForm.english_name,
+                    organization_id: editForm.organization_id,
                     admin_password: editForm.admin_password
                     admin_password: editForm.admin_password
                 })
                 })
                 ElMessage.success('用户信息更新成功')
                 ElMessage.success('用户信息更新成功')
@@ -601,6 +739,7 @@ const handleCreateUser = () => {
   createForm.password = ''
   createForm.password = ''
   createForm.role = 'ORDINARY_USER'
   createForm.role = 'ORDINARY_USER'
   createForm.admin_password = ''
   createForm.admin_password = ''
+  createForm.organization_id = null
   refreshDynamicField()
   refreshDynamicField()
   createDialogVisible.value = true
   createDialogVisible.value = true
 }
 }
@@ -634,6 +773,9 @@ const fetchUsers = async () => {
     if (statusFilter.value) params.status = statusFilter.value
     if (statusFilter.value) params.status = statusFilter.value
     if (roleFilter.value) params.role = roleFilter.value
     if (roleFilter.value) params.role = roleFilter.value
     if (searchQuery.value) params.keyword = searchQuery.value
     if (searchQuery.value) params.keyword = searchQuery.value
+    if (organizationFilter.value !== undefined) {
+      params.organization_id = organizationFilter.value
+    }
     
     
     const res = await api.get('/users/', { params })
     const res = await api.get('/users/', { params })
     if (res.data && Array.isArray(res.data.items)) {
     if (res.data && Array.isArray(res.data.items)) {
@@ -1013,13 +1155,161 @@ const getActionLabel = (type: string) => {
         'ENABLE': '启用',
         'ENABLE': '启用',
         'RESET_PASSWORD': '重置密码',
         'RESET_PASSWORD': '重置密码',
         'CHANGE_ROLE': '变更角色',
         'CHANGE_ROLE': '变更角色',
-        'SYNC_M2M': 'M2M 同步'
+        'SYNC_M2M': 'M2M 同步',
+        'ORG_CREATE': '新建组织',
+        'ORG_UPDATE': '编辑组织',
     }
     }
     return map[type] || type
     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(() => {
 onMounted(() => {
   fetchUsers()
   fetchUsers()
+  fetchOrganizations()
   if (authStore.token && !authStore.user) {
   if (authStore.token && !authStore.user) {
     authStore.fetchUser().catch(() => {})
     authStore.fetchUser().catch(() => {})
   }
   }
@@ -1105,6 +1395,26 @@ onUnmounted(() => {
 .sms-row .el-input {
 .sms-row .el-input {
     flex: 1;
     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) {
 :deep(.danger-dropdown-item) {
     color: #f56c6c;
     color: #f56c6c;
 }
 }

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

@@ -8,7 +8,7 @@
       </el-button>
       </el-button>
     </div>
     </div>
     <p class="intro">
     <p class="intro">
-      本文档面向客户端(Web/移动端),说明消息接收、私信、通知、会话聚合、WebSocket 通信、联系人查询、应用中心(快捷导航)、个人身份二维码、短信验证码重置密码(忘记密码,开放接口)等接口。
+      本文档面向客户端(Web/移动端),说明消息接收、私信、通知、会话聚合、WebSocket 通信、当前用户信息(含组织)、联系人查询、应用中心(快捷导航)、个人身份二维码、短信验证码重置密码(忘记密码,开放接口)等接口。
     </p>
     </p>
 
 
     <div class="section">
     <div class="section">
@@ -38,7 +38,7 @@
         </thead>
         </thead>
         <tbody>
         <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/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>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>
           <tr><td><code>PUT /api/v1/messages/history/{other_user_id}/read-all</code></td><td>仅将该会话范围内、你是接收方且未读的消息标为已读。</td></tr>
         </tbody>
         </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>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_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>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>
         </tbody>
       </table>
       </table>
       <div class="code-block">
       <div class="code-block">
@@ -135,7 +136,8 @@ Content-Type: application/json
     "updated_at": "2026-03-18T10:00:00",
     "updated_at": "2026-03-18T10:00:00",
     "is_system": true,
     "is_system": true,
     "app_id": 101,
     "app_id": 101,
-    "app_name": "OA系统"
+    "app_name": "OA系统",
+    "remarks": "应用通知"
   },
   },
   {
   {
     "user_id": 2048,
     "user_id": 2048,
@@ -147,7 +149,8 @@ Content-Type: application/json
     "updated_at": "2026-03-18T09:20:00",
     "updated_at": "2026-03-18T09:20:00",
     "is_system": false,
     "is_system": false,
     "app_id": null,
     "app_id": null,
-    "app_name": null
+    "app_name": null,
+    "remarks": "某某科技有限公司"
   }
   }
 ]</pre>
 ]</pre>
       </div>
       </div>
@@ -613,11 +616,35 @@ ws.onmessage = (event) =&gt; {
     <div class="section">
     <div class="section">
       <h3>7. 联系人查询接口</h3>
       <h3>7. 联系人查询接口</h3>
       <ul>
       <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>:<code>GET /api/v1/users/search?q=关键词&amp;limit=20</code></li>
         <li><strong>搜索字段</strong>:手机号、姓名、英文名</li>
         <li><strong>搜索字段</strong>:手机号、姓名、英文名</li>
         <li><strong>管理分页接口</strong>:<code>GET /api/v1/users/?skip=0&amp;limit=20&amp;keyword=xxx</code></li>
         <li><strong>管理分页接口</strong>:<code>GET /api/v1/users/?skip=0&amp;limit=20&amp;keyword=xxx</code></li>
       </ul>
       </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>
       <h4>示例:搜索联系人(推荐)</h4>
       <div class="code-block">
       <div class="code-block">
         <pre>
         <pre>

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

@@ -585,7 +585,11 @@ GET /api/v1/apps/?search=OA
     "unread_count": 5,
     "unread_count": 5,
     "last_message": "您的密码已重置",
     "last_message": "您的密码已重置",
     "last_message_type": "TEXT",
     "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,
     "user_id": 102,
@@ -594,16 +598,21 @@ GET /api/v1/apps/?search=OA
     "unread_count": 0,
     "unread_count": 0,
     "last_message": "[IMAGE]",
     "last_message": "[IMAGE]",
     "last_message_type": "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>
         </pre>
       </div>
       </div>
       <p><strong>说明:</strong></p>
       <p><strong>说明:</strong></p>
       <ul>
       <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>unread_count</code> 表示该会话的未读消息数</li>
         <li><code>last_message</code> 显示最后一条消息内容(多媒体类型显示为 <code>[TYPE]</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>
       </ul>
 
 
       <h4>4.2 获取聊天历史记录</h4>
       <h4>4.2 获取聊天历史记录</h4>

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

@@ -50,6 +50,7 @@
               </span>
               </span>
               <span class="chat-time">{{ formatTime(chat.updated_at) }}</span>
               <span class="chat-time">{{ formatTime(chat.updated_at) }}</span>
             </div>
             </div>
+            <div v-if="chat.remarks" class="chat-remarks">{{ chat.remarks }}</div>
             <div class="chat-preview">
             <div class="chat-preview">
               {{ chat.last_message }}
               {{ chat.last_message }}
             </div>
             </div>
@@ -69,15 +70,18 @@
       <template v-if="currentChatId !== null">
       <template v-if="currentChatId !== null">
         <!-- 顶部标题 -->
         <!-- 顶部标题 -->
         <header class="chat-header-bar">
         <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>
         </header>
 
 
         <!-- 消息流区域 -->
         <!-- 消息流区域 -->
@@ -316,7 +320,9 @@ const filteredConversations = computed(() => {
   const lower = searchText.value.toLowerCase()
   const lower = searchText.value.toLowerCase()
   return conversations.value.filter(c => 
   return conversations.value.filter(c => 
     (c.full_name && c.full_name.toLowerCase().includes(lower)) ||
     (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;
   margin-bottom: 4px;
 }
 }
 
 
+.chat-remarks {
+  font-size: 12px;
+  color: #999;
+  margin: 0 0 4px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .chat-name {
 .chat-name {
   font-weight: 500;
   font-weight: 500;
   color: #333;
   color: #333;
@@ -774,17 +789,29 @@ const handleNotificationAction = async (msg: any) => {
 }
 }
 
 
 .chat-header-bar {
 .chat-header-bar {
-  height: 60px;
+  min-height: 60px;
   border-bottom: 1px solid #e6e6e6;
   border-bottom: 1px solid #e6e6e6;
-  padding: 0 20px;
+  padding: 10px 20px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   background: #f5f5f5;
   background: #f5f5f5;
 }
 }
 
 
+.chat-header-titles {
+  min-width: 0;
+}
+
 .chat-header-bar h3 {
 .chat-header-bar h3 {
   margin: 0;
   margin: 0;
   font-size: 16px;
   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 {
 .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);