[feat]:[FL-109][增加系统消息发送功能]
- 后端:创建system_messages表模型和Schema - 后端:实现消息创建、查询、标记已读的服务层 - 后端:新增REST API接口(需admin.system_message权限) - 前端:完善系统消息抽屉弹窗,显示消息列表 - 前端:自动加载未读数量,支持标记已读 - 数据库:新增迁移脚本建表 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user