[feat]:[FL-109][增加系统消息发送功能]

- 后端:创建system_messages表模型和Schema
- 后端:实现消息创建、查询、标记已读的服务层
- 后端:新增REST API接口(需admin.system_message权限)
- 前端:完善系统消息抽屉弹窗,显示消息列表
- 前端:自动加载未读数量,支持标记已读
- 数据库:新增迁移脚本建表

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-13 23:27:12 +08:00
parent e58614872d
commit 1d11bf9fc3
8 changed files with 407 additions and 5 deletions
+2
View File
@@ -12,6 +12,7 @@ from .v1.flower_monitor import router as flower_monitor_router
from .v1.lightning import router as lightning_router
from .v1.lines import router as lines_router
from .v1.scheduled_tasks import router as scheduled_tasks_router
from .v1.system_messages import router as system_messages_router
from .v1.system_params import router as system_params_router
from .v1.task_monitor import router as task_monitor_router
from .v1.tower_models import router as tower_models_router
@@ -29,6 +30,7 @@ v1_router.include_router(atp_assets_router)
v1_router.include_router(atp_models_router)
v1_router.include_router(task_monitor_router)
v1_router.include_router(scheduled_tasks_router)
v1_router.include_router(system_messages_router)
v1_router.include_router(system_params_router)
v1_router.include_router(elevation_router)
v1_router.include_router(fault_recurrence_router)
+74
View File
@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user, require_permission
from ...schemas.system_message import (
SystemMessageCreateRequest,
SystemMessageListResponse,
SystemMessageMarkReadRequest,
SystemMessagePublic,
)
from ...services.system_message_service import (
create_system_message,
get_unread_count,
list_user_messages,
mark_messages_as_read,
)
router = APIRouter(prefix="/system-messages", tags=["system_messages"])
@router.post("", response_model=SystemMessagePublic, status_code=status.HTTP_201_CREATED)
def create_message(
payload: SystemMessageCreateRequest,
_: CurrentUser = Depends(require_permission("admin.system_message")),
db: Session = Depends(get_db),
) -> SystemMessagePublic:
"""创建系统消息(需要admin.system_message权限)"""
message = create_system_message(db, payload)
return SystemMessagePublic.model_validate(message)
@router.get("/me", response_model=SystemMessageListResponse)
def get_my_messages(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
unread_only: bool = Query(default=False),
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db),
) -> SystemMessageListResponse:
"""获取当前用户的系统消息"""
messages, total, unread_count = list_user_messages(
db,
user_id=current_user.user.id,
limit=limit,
offset=offset,
unread_only=unread_only,
)
return SystemMessageListResponse(
items=[SystemMessagePublic.model_validate(m) for m in messages],
total=total,
unread_count=unread_count,
)
@router.get("/me/unread-count")
def get_my_unread_count(
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, int]:
"""获取当前用户未读消息数量"""
count = get_unread_count(db, current_user.user.id)
return {"unread_count": count}
@router.post("/me/mark-read")
def mark_my_messages_read(
payload: SystemMessageMarkReadRequest,
current_user: CurrentUser = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, int]:
"""标记消息为已读"""
affected = mark_messages_as_read(db, current_user.user.id, payload.message_ids)
return {"affected": affected}
+2 -1
View File
@@ -4,7 +4,7 @@ Import all model modules during package initialization so SQLAlchemy can
resolve string-based relationships regardless of route/service import order.
"""
from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_param, tower_model, tower_profile, user, wine, worker_registry
from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
__all__ = [
"atp_asset",
@@ -22,6 +22,7 @@ __all__ = [
"object_group",
"rbac",
"scheduled_task",
"system_message",
"system_param",
"tower_model",
"tower_profile",
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
from .base import utcnow
if TYPE_CHECKING:
from .user import User
class SystemMessage(Base):
__tablename__ = "system_messages"
id: Mapped[str] = mapped_column(
"message_id",
String(36),
primary_key=True,
default=lambda: uuid4().hex,
)
title: Mapped[str] = mapped_column(String(255))
content: Mapped[str] = mapped_column(Text)
message_type: Mapped[str] = mapped_column(
String(32),
default="info",
index=True,
)
target_user_id: Mapped[str | None] = mapped_column(
String(36),
index=True,
nullable=True,
)
is_read: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(
"created_at",
DateTime(timezone=False),
default=utcnow,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
"read_at",
DateTime(timezone=False),
nullable=True,
)
+32
View File
@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
class SystemMessagePublic(BaseModel):
id: str
title: str
content: str
message_type: str
target_user_id: str | None
is_read: bool
created_at: datetime
read_at: datetime | None
class SystemMessageListResponse(BaseModel):
items: list[SystemMessagePublic]
total: int
unread_count: int
class SystemMessageCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
content: str = Field(min_length=1)
message_type: Literal["info", "warning", "error", "success"] = Field(default="info")
target_user_id: str | None = Field(default=None, description="发送给特定用户,为空则全员广播")
class SystemMessageMarkReadRequest(BaseModel):
message_ids: list[str] = Field(min_length=1, description="要标记为已读的消息ID列表")
@@ -0,0 +1,96 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from ..models.system_message import SystemMessage
from ..schemas.system_message import SystemMessageCreateRequest
if TYPE_CHECKING:
from ..schemas.system_message import SystemMessagePublic
def create_system_message(
db: Session,
payload: SystemMessageCreateRequest,
) -> SystemMessage:
"""创建系统消息"""
message = SystemMessage(
title=payload.title,
content=payload.content,
message_type=payload.message_type,
target_user_id=payload.target_user_id,
)
db.add(message)
db.commit()
db.refresh(message)
return message
def list_user_messages(
db: Session,
user_id: str,
limit: int = 50,
offset: int = 0,
unread_only: bool = False,
) -> tuple[list[SystemMessage], int, int]:
"""获取用户的系统消息列表"""
# 构建查询条件:全员广播或者特定用户
query = select(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None))
)
if unread_only:
query = query.where(SystemMessage.is_read == False)
# 获取总数
total_query = select(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None))
)
total = db.scalar(select(len(total_query.subquery().c.message_id)))
# 获取未读数
unread_query = select(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
SystemMessage.is_read == False,
)
unread_count = db.scalar(select(len(unread_query.subquery().c.message_id)))
# 按创建时间倒序
query = query.order_by(SystemMessage.created_at.desc())
query = query.limit(limit).offset(offset)
messages = list(db.scalars(query).all())
return messages, total or 0, unread_count or 0
def mark_messages_as_read(
db: Session,
user_id: str,
message_ids: list[str],
) -> int:
"""标记消息为已读"""
stmt = (
update(SystemMessage)
.where(
SystemMessage.id.in_(message_ids),
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
SystemMessage.is_read == False,
)
.values(is_read=True, read_at=datetime.utcnow())
)
result = db.execute(stmt)
db.commit()
return result.rowcount or 0
def get_unread_count(db: Session, user_id: str) -> int:
"""获取用户未读消息数量"""
query = select(SystemMessage).where(
(SystemMessage.target_user_id == user_id) | (SystemMessage.target_user_id.is_(None)),
SystemMessage.is_read == False,
)
count = db.scalar(select(len(query.subquery().c.message_id)))
return count or 0
+25
View File
@@ -0,0 +1,25 @@
-- Migration: Add system_messages table for system notifications
-- Date: 2026-06-13
-- Description: Create system_messages table to support sending notifications to users
CREATE TABLE IF NOT EXISTS system_messages (
message_id VARCHAR(36) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
message_type VARCHAR(32) NOT NULL DEFAULT 'info',
target_user_id VARCHAR(36),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
read_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX idx_system_messages_target_user ON system_messages(target_user_id);
CREATE INDEX idx_system_messages_is_read ON system_messages(is_read);
CREATE INDEX idx_system_messages_created_at ON system_messages(created_at);
CREATE INDEX idx_system_messages_type ON system_messages(message_type);
-- Notes:
-- - target_user_id is NULL for broadcast messages (sent to all users)
-- - target_user_id references users.user_id for user-specific messages
-- - message_type values: 'info', 'warning', 'error', 'success'
-- - is_read tracks whether the message has been read by the target user
+127 -4
View File
@@ -39,12 +39,15 @@ import {
Button,
Drawer,
Dropdown,
Empty,
Grid,
Layout as AntLayout,
List,
Menu as AntMenu,
Result,
Space,
Spin,
Tag,
Tooltip,
Typography,
type MenuProps,
@@ -368,11 +371,65 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
[logout],
);
const [messageDrawerOpen, setMessageDrawerOpen] = useState(false);
const [messages, setMessages] = useState<any[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
const loadMessages = useCallback(async () => {
setLoadingMessages(true);
try {
const response = await fetchWithAuth("/api/v1/system-messages/me?limit=50");
if (!response.ok) {
return;
}
const data = await response.json();
setMessages(data.items || []);
setUnreadMessageCount(data.unread_count || 0);
} catch (error) {
console.error("Failed to load messages:", error);
} finally {
setLoadingMessages(false);
}
}, [fetchWithAuth]);
const loadUnreadCount = useCallback(async () => {
try {
const response = await fetchWithAuth("/api/v1/system-messages/me/unread-count");
if (!response.ok) {
return;
}
const data = await response.json();
setUnreadMessageCount(data.unread_count || 0);
} catch (error) {
console.error("Failed to load unread count:", error);
}
}, [fetchWithAuth]);
useEffect(() => {
if (user) {
void loadUnreadCount();
}
}, [loadUnreadCount, user]);
const onSystemMessageClick = useCallback(() => {
// TODO: Implement system message modal/drawer
// For now, just reset the unread count
setUnreadMessageCount(0);
}, []);
setMessageDrawerOpen(true);
void loadMessages();
}, [loadMessages]);
const markAsRead = useCallback(async (messageIds: string[]) => {
try {
const response = await fetchWithAuth("/api/v1/system-messages/me/mark-read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message_ids: messageIds }),
});
if (response.ok) {
await loadMessages();
}
} catch (error) {
console.error("Failed to mark messages as read:", error);
}
}, [fetchWithAuth, loadMessages]);
const navigationMenu = (
<AntMenu
@@ -515,6 +572,72 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
{navigationMenu}
</Drawer>
<Drawer
title="系统消息"
placement="right"
open={messageDrawerOpen}
width={480}
onClose={() => setMessageDrawerOpen(false)}
>
{loadingMessages ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin />
</div>
) : messages.length === 0 ? (
<Empty description="暂无消息" />
) : (
<List
dataSource={messages}
renderItem={(item: any) => (
<List.Item
key={item.id}
style={{
opacity: item.is_read ? 0.6 : 1,
backgroundColor: item.is_read ? "transparent" : "var(--ant-color-bg-container-active)",
}}
actions={
!item.is_read
? [
<Button
key="mark-read"
type="link"
size="small"
onClick={() => markAsRead([item.id])}
>
</Button>,
]
: undefined
}
>
<List.Item.Meta
title={
<Space>
<span>{item.title}</span>
<Tag color={
item.message_type === "error" ? "red" :
item.message_type === "warning" ? "orange" :
item.message_type === "success" ? "green" : "blue"
}>
{item.message_type}
</Tag>
</Space>
}
description={
<Space direction="vertical" size={4} style={{ width: "100%" }}>
<Typography.Text>{item.content}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{new Date(item.created_at).toLocaleString("zh-CN")}
</Typography.Text>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
<AntLayout className="admin-design-main">
<Content className="admin-design-content">
<div className="admin-design-page-body">