[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.lightning import router as lightning_router
|
||||||
from .v1.lines import router as lines_router
|
from .v1.lines import router as lines_router
|
||||||
from .v1.scheduled_tasks import router as scheduled_tasks_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.system_params import router as system_params_router
|
||||||
from .v1.task_monitor import router as task_monitor_router
|
from .v1.task_monitor import router as task_monitor_router
|
||||||
from .v1.tower_models import router as tower_models_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(atp_models_router)
|
||||||
v1_router.include_router(task_monitor_router)
|
v1_router.include_router(task_monitor_router)
|
||||||
v1_router.include_router(scheduled_tasks_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(system_params_router)
|
||||||
v1_router.include_router(elevation_router)
|
v1_router.include_router(elevation_router)
|
||||||
v1_router.include_router(fault_recurrence_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.
|
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__ = [
|
__all__ = [
|
||||||
"atp_asset",
|
"atp_asset",
|
||||||
@@ -22,6 +22,7 @@ __all__ = [
|
|||||||
"object_group",
|
"object_group",
|
||||||
"rbac",
|
"rbac",
|
||||||
"scheduled_task",
|
"scheduled_task",
|
||||||
|
"system_message",
|
||||||
"system_param",
|
"system_param",
|
||||||
"tower_model",
|
"tower_model",
|
||||||
"tower_profile",
|
"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,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
Empty,
|
||||||
Grid,
|
Grid,
|
||||||
Layout as AntLayout,
|
Layout as AntLayout,
|
||||||
|
List,
|
||||||
Menu as AntMenu,
|
Menu as AntMenu,
|
||||||
Result,
|
Result,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
type MenuProps,
|
type MenuProps,
|
||||||
@@ -368,11 +371,65 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
[logout],
|
[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(() => {
|
const onSystemMessageClick = useCallback(() => {
|
||||||
// TODO: Implement system message modal/drawer
|
setMessageDrawerOpen(true);
|
||||||
// For now, just reset the unread count
|
void loadMessages();
|
||||||
setUnreadMessageCount(0);
|
}, [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 = (
|
const navigationMenu = (
|
||||||
<AntMenu
|
<AntMenu
|
||||||
@@ -515,6 +572,72 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
{navigationMenu}
|
{navigationMenu}
|
||||||
</Drawer>
|
</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">
|
<AntLayout className="admin-design-main">
|
||||||
<Content className="admin-design-content">
|
<Content className="admin-design-content">
|
||||||
<div className="admin-design-page-body">
|
<div className="admin-design-page-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user