mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-13 07:45:41 +08:00
feat(notify): 新增站内通知模块(公告管理 + 用户端铃铛 + SSE 实时推送)
- 新建 service-notify 模块: 公告广播(notify_notice)+已读表(notify_notice_read), 预留个人消息表(notify_message) - 管理端: CRUD/发布/下线/详情/分页 - 用户端: 未读数/铃铛列表/标记已读/全部已读/忽略 - SSE 实时推送: 发布公告时推送给所有在线用户, 25s 心跳保活 - i18n(zh/en): 枚举与业务错误消息 - 菜单与权限码(system:notify:notice:add/publish/view)
This commit is contained in:
15
_config/sql/update-datas.sql
Normal file
15
_config/sql/update-datas.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 通知模块菜单数据(增量, 幂等)
|
||||
-- 执行顺序: update-tables.sql -> update-datas.sql
|
||||
-- 字段顺序同 iam_perm_menu: id, pid, menu_code, client_code, name, title_cn, title_en,
|
||||
-- i18n_key, icon, hidden, hide_children_menu, component, path, redirect, sort_no,
|
||||
-- root, keep_alive, affix_tab, creator, last_modifier, version, deleted, menu_type,
|
||||
-- active_icon, badge, badge_type, badge_variants, iframe_src, link, create_time, last_modified_time
|
||||
|
||||
-- 公告通知菜单(挂在系统管理 id=3 下, 与配置/日志/权限同级)
|
||||
INSERT INTO "iam_perm_menu" VALUES (
|
||||
309, 3, 'system:notify', 'admin', 'SystemNotify', '公告通知', 'Notification',
|
||||
'menu.system.notify', 'lucide:bell', false, false,
|
||||
'/system/notify/notice/NoticeList', '/system/notify', NULL, 20,
|
||||
false, true, false, 1, 1, 0, false, 'menu', NULL, NULL, NULL, NULL, NULL,
|
||||
'2026-06-24 16:00:00+00', '2026-06-24 16:00:00+00'
|
||||
) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,24 +1,101 @@
|
||||
-- 第三方平台登录配置表: 合并前端地址字段, 移除 state 超时与作用域字段
|
||||
-- 将 frontend_base_url + callback_path 合并为 frontend_callback_url,
|
||||
-- state_timeout 不再由平台配置维护, 改用系统默认常量(300秒),
|
||||
-- scopes 字段从未参与授权请求, 各平台 scope 已硬编码, 移除该死字段
|
||||
ALTER TABLE iam_social_config ADD COLUMN IF NOT EXISTS frontend_callback_url VARCHAR(384);
|
||||
-- 迁移已有数据(拼接基础地址与回调路径)
|
||||
UPDATE iam_social_config
|
||||
SET frontend_callback_url = CONCAT(COALESCE(frontend_base_url, ''), COALESCE(callback_path, ''))
|
||||
WHERE frontend_callback_url IS NULL AND (frontend_base_url IS NOT NULL OR callback_path IS NOT NULL);
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS frontend_base_url;
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS callback_path;
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS state_timeout;
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS scopes;
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS name;
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS frontend_callback_url;
|
||||
-- ===================================
|
||||
-- 通知模块: 公告(广播) + 已读记录 + 个人消息(预留)
|
||||
-- ===================================
|
||||
|
||||
-- client_secret 改为加密存储(AES-256-GCM, 由 DataEncryptTypeHandler 透明加解密)
|
||||
-- 字段长度 VARCHAR(256) 可容纳密文(appSecret 多为 32~64 字符, 密文 < 200 字符), 无需调整
|
||||
-- 开发阶段无历史明文数据, 跳过迁移; 生产环境启用前需写一次性逻辑加密历史明文
|
||||
COMMENT ON COLUMN iam_social_config.client_secret IS '客户端密钥(加密存储)';
|
||||
-- 公告主体表(广播型通知, 1条公告 N人可见)
|
||||
CREATE TABLE IF NOT EXISTS notify_notice (
|
||||
id bigint NOT NULL,
|
||||
title varchar(128) NOT NULL,
|
||||
content text NOT NULL,
|
||||
severity varchar(16) NOT NULL DEFAULT 'normal',
|
||||
is_top boolean NOT NULL DEFAULT false,
|
||||
effective_time timestamptz(6),
|
||||
expire_time timestamptz(6),
|
||||
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||
creator bigint,
|
||||
create_time timestamptz(6),
|
||||
last_modifier bigint,
|
||||
last_modified_time timestamptz(6),
|
||||
version int NOT NULL DEFAULT 0,
|
||||
deleted boolean NOT NULL DEFAULT false,
|
||||
CONSTRAINT notify_notice_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- 回调地址不再由社交配置维护, 改由端点配置(PlatformUrlConfig)的 baseUrl 自动生成
|
||||
-- 实际回调地址为 {adminBaseUrl|merchantBaseUrl}/auth/oauth-callback/{source}
|
||||
ALTER TABLE iam_social_config DROP COLUMN IF EXISTS redirect_uri;
|
||||
COMMENT ON TABLE notify_notice IS '公告通知(广播型, 1条对多用户可见)';
|
||||
COMMENT ON COLUMN notify_notice.id IS '主键';
|
||||
COMMENT ON COLUMN notify_notice.title IS '标题';
|
||||
COMMENT ON COLUMN notify_notice.content IS '正文(Markdown原文)';
|
||||
COMMENT ON COLUMN notify_notice.severity IS '重要程度(normal普通/important重要)';
|
||||
COMMENT ON COLUMN notify_notice.is_top IS '是否置顶';
|
||||
COMMENT ON COLUMN notify_notice.effective_time IS '生效时间(为空则立即生效)';
|
||||
COMMENT ON COLUMN notify_notice.expire_time IS '过期时间(为空则永久有效)';
|
||||
COMMENT ON COLUMN notify_notice.status IS '状态(draft草稿/published发布/offline下线)';
|
||||
COMMENT ON COLUMN notify_notice.creator IS '创建人ID';
|
||||
COMMENT ON COLUMN notify_notice.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN notify_notice.last_modifier IS '最后修改人ID';
|
||||
COMMENT ON COLUMN notify_notice.last_modified_time IS '最后修改时间';
|
||||
COMMENT ON COLUMN notify_notice.version IS '版本号(乐观锁)';
|
||||
COMMENT ON COLUMN notify_notice.deleted IS '逻辑删除标志';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_notice_status ON notify_notice (status, deleted, effective_time, expire_time);
|
||||
|
||||
-- 公告已读记录(用户 x 公告, 记录已读/忽略状态)
|
||||
CREATE TABLE IF NOT EXISTS notify_notice_read (
|
||||
id bigint NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
notice_id bigint NOT NULL,
|
||||
read_time timestamptz(6),
|
||||
is_ignored boolean NOT NULL DEFAULT false,
|
||||
creator bigint,
|
||||
create_time timestamptz(6),
|
||||
CONSTRAINT notify_notice_read_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT uk_notify_notice_read UNIQUE (user_id, notice_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE notify_notice_read IS '公告已读记录(用户x公告)';
|
||||
COMMENT ON COLUMN notify_notice_read.id IS '主键';
|
||||
COMMENT ON COLUMN notify_notice_read.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN notify_notice_read.notice_id IS '公告ID';
|
||||
COMMENT ON COLUMN notify_notice_read.read_time IS '阅读时间';
|
||||
COMMENT ON COLUMN notify_notice_read.is_ignored IS '是否忽略(用户主动隐藏该公告)';
|
||||
COMMENT ON COLUMN notify_notice_read.creator IS '创建人ID';
|
||||
COMMENT ON COLUMN notify_notice_read.create_time IS '创建时间';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_notice_read_user ON notify_notice_read (user_id);
|
||||
|
||||
-- 个人消息表(定向通知, 本次预留建表, 暂不接入业务)
|
||||
CREATE TABLE IF NOT EXISTS notify_message (
|
||||
id bigint NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
title varchar(128) NOT NULL,
|
||||
content varchar(1024),
|
||||
source varchar(32),
|
||||
link varchar(255),
|
||||
extra text,
|
||||
is_read boolean NOT NULL DEFAULT false,
|
||||
creator bigint,
|
||||
create_time timestamptz(6),
|
||||
last_modifier bigint,
|
||||
last_modified_time timestamptz(6),
|
||||
version int NOT NULL DEFAULT 0,
|
||||
deleted boolean NOT NULL DEFAULT false,
|
||||
CONSTRAINT notify_message_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE notify_message IS '个人消息(定向通知, 1条对1用户)';
|
||||
COMMENT ON COLUMN notify_message.id IS '主键';
|
||||
COMMENT ON COLUMN notify_message.user_id IS '接收用户ID';
|
||||
COMMENT ON COLUMN notify_message.title IS '标题';
|
||||
COMMENT ON COLUMN notify_message.content IS '正文内容';
|
||||
COMMENT ON COLUMN notify_message.source IS '业务来源(预留, 如TRADE/REFUND等)';
|
||||
COMMENT ON COLUMN notify_message.link IS '跳转链接(内部路由或完整http外链)';
|
||||
COMMENT ON COLUMN notify_message.extra IS '跳转附加参数(JSON字符串)';
|
||||
COMMENT ON COLUMN notify_message.is_read IS '是否已读';
|
||||
COMMENT ON COLUMN notify_message.creator IS '创建人ID';
|
||||
COMMENT ON COLUMN notify_message.create_time IS '创建时间';
|
||||
COMMENT ON COLUMN notify_message.last_modifier IS '最后修改人ID';
|
||||
COMMENT ON COLUMN notify_message.last_modified_time IS '最后修改时间';
|
||||
COMMENT ON COLUMN notify_message.version IS '版本号(乐观锁)';
|
||||
COMMENT ON COLUMN notify_message.deleted IS '逻辑删除标志';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_message_user ON notify_message (user_id, deleted, is_read);
|
||||
|
||||
@@ -70,6 +70,12 @@
|
||||
<artifactId>service-baseapi</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 站内通知服务(公告 + 个人消息) -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>service-notify</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- Artemis 消息队列通用模块(供 demo 演示注入 ArtemisTemplateService) -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"normal": "Normal",
|
||||
"important": "Important"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"offline": "Offline"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"notice": "Notice",
|
||||
"message": "Message"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notExist": "Notice not found"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"normal": "普通",
|
||||
"important": "重要"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"draft": "草稿",
|
||||
"published": "发布",
|
||||
"offline": "下线"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"notice": "公告",
|
||||
"message": "消息"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notExist": "公告不存在"
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<module>service-baseapi</module>
|
||||
<module>service-iam</module>
|
||||
<module>service-system</module>
|
||||
<module>service-notify</module>
|
||||
</modules>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>daxpay-platform-service</artifactId>
|
||||
<version>4.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>service-notify</artifactId>
|
||||
<description>站内通知(公告 + 个人消息)</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- 翻译模块 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-translate</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 国际化 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-i18n</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 数据持久层依赖 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-mybatis-plus</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- web -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<!-- json 序列化配置 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-json</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- Spring 封装 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-spring</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- Redis配置 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-redis</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- hutool 缓存 -->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-cache</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
<!-- 安全认证 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>capability-auth</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 审计日志 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>capability-audit-log</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 项目配置 -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-config</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.daxpay.open.platform.notify;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/// 通知模块(公告 + 个人消息)
|
||||
@AutoConfiguration
|
||||
@ComponentScan
|
||||
@EnableScheduling
|
||||
@MapperScan(annotationClass = Mapper.class)
|
||||
public class NotifyApplication {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cn.daxpay.open.platform.notify.controller.notice;
|
||||
|
||||
import cn.daxpay.open.platform.core.annotation.PermCode;
|
||||
import cn.daxpay.open.platform.core.rest.Res;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
import cn.daxpay.open.platform.core.rest.result.Result;
|
||||
import cn.daxpay.open.platform.core.util.ValidationUtil;
|
||||
import cn.daxpay.open.platform.core.validation.ValidationGroup;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeParam;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeQuery;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeResult;
|
||||
import cn.daxpay.open.platform.notify.service.notice.NotifyNoticeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/// 公告管理(管理端)
|
||||
@PermCode(menuCode = "system:notify")
|
||||
@Validated
|
||||
@Tag(name = "公告管理")
|
||||
@RestController
|
||||
@RequestMapping("/notify/notice")
|
||||
@RequiredArgsConstructor
|
||||
public class NotifyNoticeController {
|
||||
|
||||
private final NotifyNoticeService noticeService;
|
||||
|
||||
@PermCode(code = "notice:add", nameCn = "公告管理", nameEn = "Notice Manage")
|
||||
@Operation(summary = "新建公告")
|
||||
@PostMapping("/add")
|
||||
public Result<Void> add(@RequestBody NotifyNoticeParam param) {
|
||||
ValidationUtil.validateParam(param, ValidationGroup.add.class);
|
||||
noticeService.add(param);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:add", nameCn = "公告管理", nameEn = "Notice Manage")
|
||||
@Operation(summary = "编辑公告")
|
||||
@PostMapping("/update")
|
||||
public Result<Void> update(@RequestBody NotifyNoticeParam param) {
|
||||
ValidationUtil.validateParam(param, ValidationGroup.edit.class);
|
||||
noticeService.update(param);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:add", nameCn = "公告管理", nameEn = "Notice Manage")
|
||||
@Operation(summary = "删除公告")
|
||||
@PostMapping("/delete")
|
||||
public Result<Void> delete(@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
noticeService.delete(id);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:publish", nameCn = "公告发布", nameEn = "Notice Publish")
|
||||
@Operation(summary = "发布公告")
|
||||
@PostMapping("/publish")
|
||||
public Result<Void> publish(@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
noticeService.publish(id);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:publish", nameCn = "公告发布", nameEn = "Notice Publish")
|
||||
@Operation(summary = "下线公告")
|
||||
@PostMapping("/offline")
|
||||
public Result<Void> offline(@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
noticeService.offline(id);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:view", nameCn = "公告查看", nameEn = "Notice View")
|
||||
@Operation(summary = "公告详情")
|
||||
@GetMapping("/get")
|
||||
public Result<NotifyNoticeResult> findById(@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
return Res.ok(noticeService.findById(id));
|
||||
}
|
||||
|
||||
@PermCode(code = "notice:view", nameCn = "公告查看", nameEn = "Notice View")
|
||||
@Operation(summary = "公告分页")
|
||||
@GetMapping("/page")
|
||||
public Result<PageResult<NotifyNoticeResult>> page(PageParam pageParam, NotifyNoticeQuery query) {
|
||||
return Res.ok(noticeService.page(pageParam, query));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.daxpay.open.platform.notify.controller.notice;
|
||||
|
||||
import cn.daxpay.open.platform.capability.auth.util.SecurityUtil;
|
||||
import cn.daxpay.open.platform.core.rest.Res;
|
||||
import cn.daxpay.open.platform.core.rest.result.Result;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyUserNoticeQuery;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeBriefResult;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyUnreadCountResult;
|
||||
import cn.daxpay.open.platform.notify.service.notice.NotifySseService;
|
||||
import cn.daxpay.open.platform.notify.service.notice.NotifyUserNoticeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// 站内通知(用户端, 登录即可访问)
|
||||
@Validated
|
||||
@Tag(name = "站内通知")
|
||||
@RestController
|
||||
@RequestMapping("/notify/user")
|
||||
@RequiredArgsConstructor
|
||||
public class NotifyUserController {
|
||||
|
||||
private final NotifyUserNoticeService userNoticeService;
|
||||
|
||||
private final NotifySseService sseService;
|
||||
|
||||
@Operation(summary = "未读数")
|
||||
@GetMapping("/unread-count")
|
||||
public Result<NotifyUnreadCountResult> unreadCount() {
|
||||
return Res.ok(userNoticeService.unreadCount());
|
||||
}
|
||||
|
||||
@Operation(summary = "铃铛通知列表")
|
||||
@GetMapping("/page")
|
||||
public Result<List<NotifyNoticeBriefResult>> page(NotifyUserNoticeQuery query) {
|
||||
return Res.ok(userNoticeService.list(query));
|
||||
}
|
||||
|
||||
@Operation(summary = "标记单条已读")
|
||||
@PostMapping("/read")
|
||||
public Result<Void> read(@NotBlank(message = "{validation.field.type.notBlank}") String type,
|
||||
@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
userNoticeService.markRead(type, id);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "全部已读(清空)")
|
||||
@PostMapping("/read-all")
|
||||
public Result<Void> readAll() {
|
||||
userNoticeService.readAll();
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "忽略(隐藏)")
|
||||
@PostMapping("/ignore")
|
||||
public Result<Void> ignore(@NotBlank(message = "{validation.field.type.notBlank}") String type,
|
||||
@NotNull(message = "{validation.field.id.notNull}") Long id) {
|
||||
userNoticeService.ignore(type, id);
|
||||
return Res.ok();
|
||||
}
|
||||
|
||||
/// 建立实时推送连接(Server-Sent Events)
|
||||
///
|
||||
/// 浏览器 EventSource 同源连接, 依赖 Sa-Token 会话识别(同源 cookie);
|
||||
/// 收到推送时前端刷新未读数与铃铛列表.
|
||||
@Operation(summary = "建立实时推送连接(SSE)")
|
||||
@GetMapping(value = "/sse/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter sseConnect() {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
return sseService.connect(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.daxpay.open.platform.notify.convert.notice;
|
||||
|
||||
import cn.daxpay.open.platform.notify.entity.message.NotifyMessage;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNotice;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeParam;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeBriefResult;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeResult;
|
||||
import org.mapstruct.BeanMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.NullValuePropertyMappingStrategy;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/// 通知转换
|
||||
@Mapper
|
||||
public interface NotifyNoticeConvert {
|
||||
|
||||
NotifyNoticeConvert CONVERT = Mappers.getMapper(NotifyNoticeConvert.class);
|
||||
|
||||
/// 参数转实体
|
||||
NotifyNotice convert(NotifyNoticeParam in);
|
||||
|
||||
/// 实体转详情结果
|
||||
NotifyNoticeResult convert(NotifyNotice in);
|
||||
|
||||
/// 复制属性(更新时, 忽略空值)
|
||||
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
|
||||
void copy(NotifyNoticeParam param, @MappingTarget NotifyNotice entity);
|
||||
|
||||
/// 公告转铃铛摘要(类型固定为公告, 内容摘要来自正文, 已读状态由服务层填充)
|
||||
@Mapping(target = "type", constant = "notice")
|
||||
@Mapping(target = "message", source = "content")
|
||||
@Mapping(target = "isRead", ignore = true)
|
||||
@Mapping(target = "link", ignore = true)
|
||||
NotifyNoticeBriefResult toBrief(NotifyNotice in);
|
||||
|
||||
/// 个人消息转铃铛摘要(类型固定为个人消息, 内容摘要来自正文)
|
||||
@Mapping(target = "type", constant = "message")
|
||||
@Mapping(target = "message", source = "content")
|
||||
@Mapping(target = "severity", ignore = true)
|
||||
@Mapping(target = "isTop", ignore = true)
|
||||
NotifyNoticeBriefResult convert(NotifyMessage in);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.daxpay.open.platform.notify.dao.message;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
|
||||
import cn.daxpay.open.platform.notify.entity.message.NotifyMessage;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// 个人消息(预留)
|
||||
@Repository
|
||||
@AllArgsConstructor
|
||||
public class NotifyMessageManager extends BaseManager<NotifyMessageMapper, NotifyMessage> {
|
||||
|
||||
/// 查询用户未读个人消息
|
||||
public List<NotifyMessage> findAllByUserAndUnread(Long userId) {
|
||||
return lambdaQuery()
|
||||
.eq(NotifyMessage::getUserId, userId)
|
||||
.eq(NotifyMessage::getIsRead, false)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.daxpay.open.platform.notify.dao.message;
|
||||
|
||||
import cn.daxpay.open.platform.notify.entity.message.NotifyMessage;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/// 个人消息(预留)
|
||||
@Mapper
|
||||
public interface NotifyMessageMapper extends MPJBaseMapper<NotifyMessage> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.daxpay.open.platform.notify.dao.notice;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.base.MpIdEntity;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNotice;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeQuery;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/// 公告
|
||||
@Repository
|
||||
@AllArgsConstructor
|
||||
public class NotifyNoticeManager extends BaseManager<NotifyNoticeMapper, NotifyNotice> {
|
||||
|
||||
/// 管理端分页查询
|
||||
public Page<NotifyNotice> page(PageParam pageParam, NotifyNoticeQuery query) {
|
||||
Page<NotifyNotice> mpPage = MpUtil.getMpPage(pageParam);
|
||||
return lambdaQuery()
|
||||
.like(StrUtil.isNotBlank(query.getTitle()), NotifyNotice::getTitle, query.getTitle())
|
||||
.eq(StrUtil.isNotBlank(query.getStatus()), NotifyNotice::getStatus, query.getStatus())
|
||||
.eq(StrUtil.isNotBlank(query.getSeverity()), NotifyNotice::getSeverity, query.getSeverity())
|
||||
.orderByDesc(NotifyNotice::getIsTop)
|
||||
.orderByDesc(MpIdEntity::getId)
|
||||
.page(mpPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.daxpay.open.platform.notify.dao.notice;
|
||||
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNotice;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/// 公告
|
||||
@Mapper
|
||||
public interface NotifyNoticeMapper extends MPJBaseMapper<NotifyNotice> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.daxpay.open.platform.notify.dao.notice;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNoticeRead;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/// 公告已读记录
|
||||
@Repository
|
||||
@AllArgsConstructor
|
||||
public class NotifyNoticeReadManager extends BaseManager<NotifyNoticeReadMapper, NotifyNoticeRead> {
|
||||
|
||||
/// 查询用户对某条公告的已读记录
|
||||
public Optional<NotifyNoticeRead> findByUserAndNotice(Long userId, Long noticeId) {
|
||||
return lambdaQuery()
|
||||
.eq(NotifyNoticeRead::getUserId, userId)
|
||||
.eq(NotifyNoticeRead::getNoticeId, noticeId)
|
||||
.oneOpt();
|
||||
}
|
||||
|
||||
/// 查询用户所有未忽略的已读记录
|
||||
public List<NotifyNoticeRead> findAllByUserAndNotIgnored(Long userId) {
|
||||
return lambdaQuery()
|
||||
.eq(NotifyNoticeRead::getUserId, userId)
|
||||
.eq(NotifyNoticeRead::getIsIgnored, false)
|
||||
.list();
|
||||
}
|
||||
|
||||
/// 查询用户所有已读记录(含忽略)
|
||||
public List<NotifyNoticeRead> findAllByUser(Long userId) {
|
||||
return lambdaQuery()
|
||||
.eq(NotifyNoticeRead::getUserId, userId)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.daxpay.open.platform.notify.dao.notice;
|
||||
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNoticeRead;
|
||||
import com.github.yulichang.base.MPJBaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/// 公告已读记录
|
||||
@Mapper
|
||||
public interface NotifyNoticeReadMapper extends MPJBaseMapper<NotifyNoticeRead> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.daxpay.open.platform.notify.entity.message;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
|
||||
import cn.daxpay.open.platform.notify.convert.notice.NotifyNoticeConvert;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeBriefResult;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// 个人消息(定向通知, 本次预留建表, 暂不接入业务)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Accessors(chain = true)
|
||||
@TableName("notify_message")
|
||||
public class NotifyMessage extends MpBaseEntity implements ToResult<NotifyNoticeBriefResult> {
|
||||
|
||||
/// 接收用户ID
|
||||
private Long userId;
|
||||
|
||||
/// 标题
|
||||
private String title;
|
||||
|
||||
/// 正文内容
|
||||
private String content;
|
||||
|
||||
/// 业务来源(预留)
|
||||
private String source;
|
||||
|
||||
/// 跳转链接(内部路由或完整http外链)
|
||||
private String link;
|
||||
|
||||
/// 跳转附加参数(JSON字符串)
|
||||
private String extra;
|
||||
|
||||
/// 是否已读
|
||||
@TableField("is_read")
|
||||
private Boolean isRead;
|
||||
|
||||
@Override
|
||||
public NotifyNoticeBriefResult toResult() {
|
||||
return NotifyNoticeConvert.CONVERT.convert(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cn.daxpay.open.platform.notify.entity.notice;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
|
||||
import cn.daxpay.open.platform.notify.convert.notice.NotifyNoticeConvert;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeResult;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/// 公告通知(广播型)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Accessors(chain = true)
|
||||
@TableName("notify_notice")
|
||||
public class NotifyNotice extends MpBaseEntity implements ToResult<NotifyNoticeResult> {
|
||||
|
||||
/// 标题
|
||||
private String title;
|
||||
|
||||
/// 正文(Markdown原文)
|
||||
private String content;
|
||||
|
||||
/// 重要程度(normal普通/important重要)
|
||||
private String severity;
|
||||
|
||||
/// 是否置顶
|
||||
@TableField("is_top")
|
||||
private Boolean isTop;
|
||||
|
||||
/// 生效时间(为空则立即生效)
|
||||
private OffsetDateTime effectiveTime;
|
||||
|
||||
/// 过期时间(为空则永久有效)
|
||||
private OffsetDateTime expireTime;
|
||||
|
||||
/// 状态(draft草稿/published发布/offline下线)
|
||||
private String status;
|
||||
|
||||
@Override
|
||||
public NotifyNoticeResult toResult() {
|
||||
return NotifyNoticeConvert.CONVERT.convert(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.daxpay.open.platform.notify.entity.notice;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.base.MpCreateEntity;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/// 公告已读记录(用户 x 公告)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Accessors(chain = true)
|
||||
@TableName("notify_notice_read")
|
||||
public class NotifyNoticeRead extends MpCreateEntity {
|
||||
|
||||
/// 用户ID
|
||||
private Long userId;
|
||||
|
||||
/// 公告ID
|
||||
private Long noticeId;
|
||||
|
||||
/// 阅读时间
|
||||
private OffsetDateTime readTime;
|
||||
|
||||
/// 是否忽略(用户主动隐藏该公告)
|
||||
@TableField("is_ignored")
|
||||
private Boolean isIgnored;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.daxpay.open.platform.notify.enums;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// 公告重要程度
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NotifySeverityEnum implements I18nSupport {
|
||||
|
||||
/// 普通
|
||||
normal("normal"),
|
||||
|
||||
/// 重要
|
||||
important("important");
|
||||
|
||||
private final String code;
|
||||
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notify_severity";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.daxpay.open.platform.notify.enums;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// 公告状态
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NotifyStatusEnum implements I18nSupport {
|
||||
|
||||
/// 草稿
|
||||
draft("draft"),
|
||||
|
||||
/// 发布
|
||||
published("published"),
|
||||
|
||||
/// 下线
|
||||
offline("offline");
|
||||
|
||||
private final String code;
|
||||
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notify_status";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.daxpay.open.platform.notify.enums;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/// 通知类型(用于 SSE 推送载荷与前端分桶)
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum NotifyTypeEnum implements I18nSupport {
|
||||
|
||||
/// 公告(广播)
|
||||
notice("notice"),
|
||||
|
||||
/// 个人消息(定向)
|
||||
message("message");
|
||||
|
||||
private final String code;
|
||||
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.notify_type";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.daxpay.open.platform.notify.param.notice;
|
||||
|
||||
import cn.daxpay.open.platform.core.validation.ValidationGroup;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Null;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/// 公告参数(管理端 新建/编辑)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "公告参数")
|
||||
public class NotifyNoticeParam {
|
||||
|
||||
@Null(message = "{validation.field.id.mustBeNullOnAdd}", groups = ValidationGroup.add.class)
|
||||
@NotNull(message = "{validation.field.id.notNull}", groups = ValidationGroup.edit.class)
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "{validation.field.title.notBlank}", groups = ValidationGroup.add.class)
|
||||
@Schema(description = "标题")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "{validation.field.content.notBlank}", groups = ValidationGroup.add.class)
|
||||
@Schema(description = "正文(Markdown)")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "重要程度(normal普通/important重要)")
|
||||
private String severity;
|
||||
|
||||
@Schema(description = "是否置顶")
|
||||
private Boolean isTop;
|
||||
|
||||
@Schema(description = "生效时间(为空则立即生效)")
|
||||
private OffsetDateTime effectiveTime;
|
||||
|
||||
@Schema(description = "过期时间(为空则永久有效)")
|
||||
private OffsetDateTime expireTime;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.daxpay.open.platform.notify.param.notice;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// 公告查询(管理端分页)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "公告查询")
|
||||
public class NotifyNoticeQuery {
|
||||
|
||||
@Schema(description = "标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "重要程度")
|
||||
private String severity;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cn.daxpay.open.platform.notify.param.notice;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// 用户端通知查询(铃铛列表)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "用户端通知查询")
|
||||
public class NotifyUserNoticeQuery {
|
||||
|
||||
@Schema(description = "是否只看未读")
|
||||
private Boolean onlyUnread;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.daxpay.open.platform.notify.result.notice;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/// 通知项(铃铛列表, 公告与个人消息统一结构)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "通知项")
|
||||
public class NotifyNoticeBriefResult {
|
||||
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "通知类型(notice公告/message个人消息)")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "内容摘要")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "重要程度(公告专用)")
|
||||
private String severity;
|
||||
|
||||
@Schema(description = "是否置顶(公告专用)")
|
||||
private Boolean isTop;
|
||||
|
||||
@Schema(description = "是否已读")
|
||||
private Boolean isRead;
|
||||
|
||||
@Schema(description = "跳转链接(个人消息专用)")
|
||||
private String link;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private OffsetDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.daxpay.open.platform.notify.result.notice;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/// 公告详情(管理端)
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "公告详情")
|
||||
public class NotifyNoticeResult {
|
||||
|
||||
@Schema(description = "主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "正文(Markdown原文)")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "重要程度(normal普通/important重要)")
|
||||
private String severity;
|
||||
|
||||
@Schema(description = "是否置顶")
|
||||
private Boolean isTop;
|
||||
|
||||
@Schema(description = "生效时间")
|
||||
private OffsetDateTime effectiveTime;
|
||||
|
||||
@Schema(description = "过期时间")
|
||||
private OffsetDateTime expireTime;
|
||||
|
||||
@Schema(description = "状态(draft草稿/published发布/offline下线)")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "创建人ID")
|
||||
private Long creator;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private OffsetDateTime createTime;
|
||||
|
||||
@Schema(description = "最后修改时间")
|
||||
private OffsetDateTime lastModifiedTime;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.daxpay.open.platform.notify.result.notice;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/// 未读数
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(title = "未读数")
|
||||
public class NotifyUnreadCountResult {
|
||||
|
||||
@Schema(description = "公告未读数")
|
||||
private Integer noticeCount;
|
||||
|
||||
@Schema(description = "个人消息未读数")
|
||||
private Integer messageCount;
|
||||
|
||||
@Schema(description = "合计未读数")
|
||||
private Integer total;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package cn.daxpay.open.platform.notify.service.notice;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
|
||||
import cn.daxpay.open.platform.core.exception.DataNotExistException;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
import cn.daxpay.open.platform.notify.convert.notice.NotifyNoticeConvert;
|
||||
import cn.daxpay.open.platform.notify.dao.notice.NotifyNoticeManager;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNotice;
|
||||
import cn.daxpay.open.platform.notify.enums.NotifySeverityEnum;
|
||||
import cn.daxpay.open.platform.notify.enums.NotifyStatusEnum;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeParam;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyNoticeQuery;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeResult;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/// 公告管理端服务(发布/编辑/下线/分页/详情)
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class NotifyNoticeService {
|
||||
|
||||
private final NotifyNoticeManager noticeManager;
|
||||
|
||||
private final NotifySseService sseService;
|
||||
|
||||
/// 新建公告(默认草稿状态, 通过发布接口上线)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public NotifyNoticeResult add(NotifyNoticeParam param) {
|
||||
NotifyNotice notice = NotifyNoticeConvert.CONVERT.convert(param);
|
||||
// 重要程度默认普通
|
||||
if (notice.getSeverity() == null) {
|
||||
notice.setSeverity(NotifySeverityEnum.normal.getCode());
|
||||
}
|
||||
// 置顶默认否
|
||||
if (notice.getIsTop() == null) {
|
||||
notice.setIsTop(false);
|
||||
}
|
||||
// 新建默认草稿
|
||||
notice.setStatus(NotifyStatusEnum.draft.getCode());
|
||||
noticeManager.save(notice);
|
||||
return notice.toResult();
|
||||
}
|
||||
|
||||
/// 编辑公告(允许编辑已发布, 状态与已读记录不变)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public NotifyNoticeResult update(NotifyNoticeParam param) {
|
||||
NotifyNotice notice = noticeManager.findById(param.getId())
|
||||
.orElseThrow(() -> new DataNotExistException("error.notify.notice.notExist"));
|
||||
NotifyNoticeConvert.CONVERT.copy(param, notice);
|
||||
noticeManager.updateById(notice);
|
||||
return notice.toResult();
|
||||
}
|
||||
|
||||
/// 删除公告(逻辑删除)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
if (!noticeManager.existedById(id)) {
|
||||
throw new DataNotExistException("error.notify.notice.notExist");
|
||||
}
|
||||
noticeManager.deleteById(id);
|
||||
}
|
||||
|
||||
/// 发布公告(草稿 -> 发布)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void publish(Long id) {
|
||||
NotifyNotice notice = noticeManager.findById(id)
|
||||
.orElseThrow(() -> new DataNotExistException("error.notify.notice.notExist"));
|
||||
notice.setStatus(NotifyStatusEnum.published.getCode());
|
||||
noticeManager.updateById(notice);
|
||||
// 实时推送给所有在线用户新公告
|
||||
sseService.publishToAll(java.util.Map.of(
|
||||
"type", "notice",
|
||||
"event", "published",
|
||||
"id", notice.getId(),
|
||||
"title", notice.getTitle() == null ? "" : notice.getTitle()
|
||||
));
|
||||
}
|
||||
|
||||
/// 下线公告(发布 -> 下线, 已读记录保留)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void offline(Long id) {
|
||||
NotifyNotice notice = noticeManager.findById(id)
|
||||
.orElseThrow(() -> new DataNotExistException("error.notify.notice.notExist"));
|
||||
notice.setStatus(NotifyStatusEnum.offline.getCode());
|
||||
noticeManager.updateById(notice);
|
||||
}
|
||||
|
||||
/// 查询公告详情
|
||||
public NotifyNoticeResult findById(Long id) {
|
||||
return noticeManager.findById(id)
|
||||
.map(NotifyNotice::toResult)
|
||||
.orElseThrow(DataNotExistException::new);
|
||||
}
|
||||
|
||||
/// 分页查询
|
||||
public PageResult<NotifyNoticeResult> page(PageParam pageParam, NotifyNoticeQuery query) {
|
||||
return MpUtil.toPageResult(noticeManager.page(pageParam, query));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.daxpay.open.platform.notify.service.notice;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/// SSE 实时推送服务(管理在线用户的 SseEmitter)
|
||||
///
|
||||
/// 单实例方案: 以 userId 维护本地 emitter 映射; 公告发布时推送给所有在线用户.
|
||||
/// 多实例横向扩展时需引入 Redis Pub/Sub 跨实例广播(预留扩展点).
|
||||
@Slf4j
|
||||
@Service
|
||||
public class NotifySseService {
|
||||
|
||||
/// userId -> SseEmitter
|
||||
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||
|
||||
/// 建立连接
|
||||
public SseEmitter connect(Long userId) {
|
||||
// 顶掉旧连接
|
||||
SseEmitter old = emitters.remove(userId);
|
||||
if (old != null) {
|
||||
try {
|
||||
old.complete();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
// 0L 表示不超时, 依靠心跳维持
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
emitters.put(userId, emitter);
|
||||
emitter.onCompletion(() -> emitters.remove(userId, emitter));
|
||||
emitter.onTimeout(() -> emitters.remove(userId, emitter));
|
||||
emitter.onError(e -> emitters.remove(userId, emitter));
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/// 主动断开
|
||||
public void disconnect(Long userId) {
|
||||
SseEmitter emitter = emitters.remove(userId);
|
||||
if (emitter != null) {
|
||||
try {
|
||||
emitter.complete();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送给所有在线用户(公告发布场景)
|
||||
public void publishToAll(Object payload) {
|
||||
if (emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
emitters.forEach((userId, emitter) -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(payload));
|
||||
} catch (IOException e) {
|
||||
emitters.remove(userId, emitter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 推送给指定用户(个人消息场景, 预留)
|
||||
public void publishToUser(Long userId, Object payload) {
|
||||
SseEmitter emitter = emitters.get(userId);
|
||||
if (emitter == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(payload));
|
||||
} catch (IOException e) {
|
||||
emitters.remove(userId, emitter);
|
||||
}
|
||||
}
|
||||
|
||||
/// 心跳: 每 25 秒发注释行, 防止 Nginx/代理超时断开
|
||||
@Scheduled(fixedRate = 25_000)
|
||||
public void heartbeat() {
|
||||
if (emitters.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
emitters.forEach((userId, emitter) -> {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().comment("heartbeat"));
|
||||
} catch (IOException e) {
|
||||
emitters.remove(userId, emitter);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package cn.daxpay.open.platform.notify.service.notice;
|
||||
|
||||
import cn.daxpay.open.platform.capability.auth.util.SecurityUtil;
|
||||
import cn.daxpay.open.platform.common.mybatisplus.base.MpIdEntity;
|
||||
import cn.daxpay.open.platform.notify.convert.notice.NotifyNoticeConvert;
|
||||
import cn.daxpay.open.platform.notify.dao.message.NotifyMessageManager;
|
||||
import cn.daxpay.open.platform.notify.dao.notice.NotifyNoticeManager;
|
||||
import cn.daxpay.open.platform.notify.dao.notice.NotifyNoticeReadManager;
|
||||
import cn.daxpay.open.platform.notify.entity.message.NotifyMessage;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNotice;
|
||||
import cn.daxpay.open.platform.notify.entity.notice.NotifyNoticeRead;
|
||||
import cn.daxpay.open.platform.notify.enums.NotifyStatusEnum;
|
||||
import cn.daxpay.open.platform.notify.enums.NotifyTypeEnum;
|
||||
import cn.daxpay.open.platform.notify.param.notice.NotifyUserNoticeQuery;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyNoticeBriefResult;
|
||||
import cn.daxpay.open.platform.notify.result.notice.NotifyUnreadCountResult;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// 用户端通知服务(未读数/列表/已读/清空/忽略)
|
||||
///
|
||||
/// 公告采用"广播 + 已读表"模型: 一条公告对全员可见, 单独维护用户阅读/忽略状态;
|
||||
/// 个人消息采用"定向"模型, 直接带 user_id. 两类在铃铛中聚合展示.
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class NotifyUserNoticeService {
|
||||
|
||||
private final NotifyNoticeManager noticeManager;
|
||||
|
||||
private final NotifyNoticeReadManager readManager;
|
||||
|
||||
private final NotifyMessageManager messageManager;
|
||||
|
||||
/// 未读数(公告 + 个人消息)
|
||||
public NotifyUnreadCountResult unreadCount() {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
// 可见公告
|
||||
List<NotifyNotice> visibleNotices = findVisibleNotices();
|
||||
// 用户已读(且未忽略)的公告id集合
|
||||
Set<Long> readNoticeIds = readManager.findAllByUserAndNotIgnored(userId).stream()
|
||||
.map(NotifyNoticeRead::getNoticeId)
|
||||
.collect(Collectors.toSet());
|
||||
int noticeUnread = (int) visibleNotices.stream()
|
||||
.filter(n -> !readNoticeIds.contains(n.getId()))
|
||||
.count();
|
||||
// 个人消息未读数(预留, 当前表无数据返回0)
|
||||
int messageUnread = messageManager.findAllByUserAndUnread(userId).size();
|
||||
return new NotifyUnreadCountResult()
|
||||
.setNoticeCount(noticeUnread)
|
||||
.setMessageCount(messageUnread)
|
||||
.setTotal(noticeUnread + messageUnread);
|
||||
}
|
||||
|
||||
/// 铃铛列表(可见公告 + 个人消息聚合, 排除被忽略的公告)
|
||||
public List<NotifyNoticeBriefResult> list(NotifyUserNoticeQuery query) {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
|
||||
// 可见公告
|
||||
List<NotifyNotice> visibleNotices = findVisibleNotices();
|
||||
// 用户阅读记录
|
||||
List<NotifyNoticeRead> reads = readManager.findAllByUser(userId);
|
||||
Map<Long, NotifyNoticeRead> readMap = reads.stream()
|
||||
.collect(Collectors.toMap(NotifyNoticeRead::getNoticeId, Function.identity(), (a, b) -> a));
|
||||
Set<Long> ignoredIds = reads.stream()
|
||||
.filter(r -> Boolean.TRUE.equals(r.getIsIgnored()))
|
||||
.map(NotifyNoticeRead::getNoticeId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<NotifyNoticeBriefResult> list = new ArrayList<>();
|
||||
// 组装公告(排除被忽略的)
|
||||
for (NotifyNotice notice : visibleNotices) {
|
||||
if (ignoredIds.contains(notice.getId())) {
|
||||
continue;
|
||||
}
|
||||
NotifyNoticeBriefResult brief = NotifyNoticeConvert.CONVERT.toBrief(notice);
|
||||
NotifyNoticeRead read = readMap.get(notice.getId());
|
||||
// 有阅读记录视为已读
|
||||
brief.setIsRead(read != null);
|
||||
list.add(brief);
|
||||
}
|
||||
|
||||
// 个人消息(未删除的全部展示, 已读后前端可删除)
|
||||
List<NotifyMessage> messages = messageManager.lambdaQuery()
|
||||
.eq(NotifyMessage::getUserId, userId)
|
||||
.orderByDesc(MpIdEntity::getId)
|
||||
.list();
|
||||
for (NotifyMessage message : messages) {
|
||||
list.add(NotifyNoticeConvert.CONVERT.convert(message));
|
||||
}
|
||||
|
||||
// 只看未读
|
||||
if (Boolean.TRUE.equals(query.getOnlyUnread())) {
|
||||
list = list.stream()
|
||||
.filter(b -> !Boolean.TRUE.equals(b.getIsRead()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// 标记单条已读
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markRead(String type, Long id) {
|
||||
if (NotifyTypeEnum.notice.getCode().equals(type)) {
|
||||
markNoticeRead(id, false);
|
||||
}
|
||||
// 个人消息标记已读预留(暂不接入业务)
|
||||
}
|
||||
|
||||
/// 清空(全部标记已读): 为所有可见未读公告补阅读记录
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void readAll() {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
List<NotifyNotice> visibleNotices = findVisibleNotices();
|
||||
Set<Long> readNoticeIds = readManager.findAllByUser(userId).stream()
|
||||
.map(NotifyNoticeRead::getNoticeId)
|
||||
.collect(Collectors.toSet());
|
||||
for (NotifyNotice notice : visibleNotices) {
|
||||
if (readNoticeIds.contains(notice.getId())) {
|
||||
continue;
|
||||
}
|
||||
NotifyNoticeRead read = new NotifyNoticeRead();
|
||||
read.setUserId(userId);
|
||||
read.setNoticeId(notice.getId());
|
||||
read.setReadTime(now);
|
||||
read.setIsIgnored(false);
|
||||
readManager.save(read);
|
||||
}
|
||||
}
|
||||
|
||||
/// 忽略(用户主动隐藏)
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void ignore(String type, Long id) {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
if (NotifyTypeEnum.notice.getCode().equals(type)) {
|
||||
markNoticeRead(id, true);
|
||||
} else if (NotifyTypeEnum.message.getCode().equals(type)) {
|
||||
// 个人消息忽略=逻辑删除(预留)
|
||||
NotifyMessage message = messageManager.findById(id).orElse(null);
|
||||
if (message != null && message.getUserId().equals(userId)) {
|
||||
messageManager.deleteById(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记公告已读/忽略(无记录则新增)
|
||||
private void markNoticeRead(Long noticeId, boolean ignored) {
|
||||
Long userId = SecurityUtil.getUserId();
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
Optional<NotifyNoticeRead> existing = readManager.findByUserAndNotice(userId, noticeId);
|
||||
if (existing.isPresent()) {
|
||||
NotifyNoticeRead read = existing.get();
|
||||
read.setReadTime(now);
|
||||
read.setIsIgnored(ignored);
|
||||
readManager.updateById(read);
|
||||
} else {
|
||||
NotifyNoticeRead read = new NotifyNoticeRead();
|
||||
read.setUserId(userId);
|
||||
read.setNoticeId(noticeId);
|
||||
read.setReadTime(now);
|
||||
read.setIsIgnored(ignored);
|
||||
readManager.save(read);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询当前生效的可见公告(已发布 + 生效期内, 置顶/时间倒序)
|
||||
private List<NotifyNotice> findVisibleNotices() {
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
return noticeManager.lambdaQuery()
|
||||
.eq(NotifyNotice::getStatus, NotifyStatusEnum.published.getCode())
|
||||
.and(w -> w.isNull(NotifyNotice::getEffectiveTime).or().le(NotifyNotice::getEffectiveTime, now))
|
||||
.and(w -> w.isNull(NotifyNotice::getExpireTime).or().gt(NotifyNotice::getExpireTime, now))
|
||||
.orderByDesc(NotifyNotice::getIsTop)
|
||||
.orderByDesc(MpIdEntity::getId)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cn.daxpay.open.platform.notify.NotifyApplication
|
||||
Reference in New Issue
Block a user