diff --git a/_config/sql/update-datas.sql b/_config/sql/update-datas.sql new file mode 100644 index 000000000..0d36f9a02 --- /dev/null +++ b/_config/sql/update-datas.sql @@ -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; diff --git a/_config/sql/update-tables.sql b/_config/sql/update-tables.sql index 1d156b840..8b0f69996 100644 --- a/_config/sql/update-tables.sql +++ b/_config/sql/update-tables.sql @@ -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); diff --git a/daxpay-payment/daxpay-payment-admin/pom.xml b/daxpay-payment/daxpay-payment-admin/pom.xml index af0961c02..c5f5a17c2 100644 --- a/daxpay-payment/daxpay-payment-admin/pom.xml +++ b/daxpay-payment/daxpay-payment-admin/pom.xml @@ -70,6 +70,12 @@ service-baseapi ${project.version} + + + cn.daxpay.open + service-notify + ${project.version} + cn.daxpay.open diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_severity.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_severity.json new file mode 100644 index 000000000..01bf3cc8c --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_severity.json @@ -0,0 +1,4 @@ +{ + "normal": "Normal", + "important": "Important" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_status.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_status.json new file mode 100644 index 000000000..2c7ba2e0f --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_status.json @@ -0,0 +1,5 @@ +{ + "draft": "Draft", + "published": "Published", + "offline": "Offline" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_type.json new file mode 100644 index 000000000..0c61adc58 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/enum/notify_type.json @@ -0,0 +1,4 @@ +{ + "notice": "Notice", + "message": "Message" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/notify/notice.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/notify/notice.json new file mode 100644 index 000000000..e4b22f606 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/en-US/error/notify/notice.json @@ -0,0 +1,3 @@ +{ + "notExist": "Notice not found" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_severity.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_severity.json new file mode 100644 index 000000000..2a97d193e --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_severity.json @@ -0,0 +1,4 @@ +{ + "normal": "普通", + "important": "重要" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_status.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_status.json new file mode 100644 index 000000000..25f9a3f9d --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_status.json @@ -0,0 +1,5 @@ +{ + "draft": "草稿", + "published": "发布", + "offline": "下线" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_type.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_type.json new file mode 100644 index 000000000..3c22f94a2 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/enum/notify_type.json @@ -0,0 +1,4 @@ +{ + "notice": "公告", + "message": "消息" +} diff --git a/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/notify/notice.json b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/notify/notice.json new file mode 100644 index 000000000..c8030c723 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-i18n/src/main/resources/i18n/zh-CN/error/notify/notice.json @@ -0,0 +1,3 @@ +{ + "notExist": "公告不存在" +} diff --git a/daxpay-platform/daxpay-platform-service/pom.xml b/daxpay-platform/daxpay-platform-service/pom.xml index bc7405fb2..2ec17665e 100644 --- a/daxpay-platform/daxpay-platform-service/pom.xml +++ b/daxpay-platform/daxpay-platform-service/pom.xml @@ -12,6 +12,7 @@ service-baseapi service-iam service-system + service-notify 4.0.0 diff --git a/daxpay-platform/daxpay-platform-service/service-notify/pom.xml b/daxpay-platform/daxpay-platform-service/service-notify/pom.xml new file mode 100644 index 000000000..1829081a8 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + cn.daxpay.open + daxpay-platform-service + 4.0.0-SNAPSHOT + + + service-notify + 站内通知(公告 + 个人消息) + + + + + cn.daxpay.open + common-translate + ${project.version} + + + + cn.daxpay.open + common-i18n + ${project.version} + + + + cn.daxpay.open + common-mybatis-plus + ${project.version} + + + + org.springframework.boot + spring-boot-starter-web + + + + cn.daxpay.open + common-json + ${project.version} + + + + cn.daxpay.open + common-spring + ${project.version} + + + + cn.daxpay.open + common-redis + ${project.version} + + + + cn.hutool + hutool-cache + ${hutool.version} + + + + cn.daxpay.open + capability-auth + ${project.version} + + + + cn.daxpay.open + capability-audit-log + ${project.version} + + + + cn.daxpay.open + common-config + ${project.version} + + + + diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/NotifyApplication.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/NotifyApplication.java new file mode 100644 index 000000000..cfd122d63 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/NotifyApplication.java @@ -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 { + +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyNoticeController.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyNoticeController.java new file mode 100644 index 000000000..4f471cac2 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyNoticeController.java @@ -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 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 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 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 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 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 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> page(PageParam pageParam, NotifyNoticeQuery query) { + return Res.ok(noticeService.page(pageParam, query)); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyUserController.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyUserController.java new file mode 100644 index 000000000..7cbf8712c --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/controller/notice/NotifyUserController.java @@ -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 unreadCount() { + return Res.ok(userNoticeService.unreadCount()); + } + + @Operation(summary = "铃铛通知列表") + @GetMapping("/page") + public Result> page(NotifyUserNoticeQuery query) { + return Res.ok(userNoticeService.list(query)); + } + + @Operation(summary = "标记单条已读") + @PostMapping("/read") + public Result 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 readAll() { + userNoticeService.readAll(); + return Res.ok(); + } + + @Operation(summary = "忽略(隐藏)") + @PostMapping("/ignore") + public Result 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); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/convert/notice/NotifyNoticeConvert.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/convert/notice/NotifyNoticeConvert.java new file mode 100644 index 000000000..9f1449bf0 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/convert/notice/NotifyNoticeConvert.java @@ -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); +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageManager.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageManager.java new file mode 100644 index 000000000..79497b616 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageManager.java @@ -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 { + + /// 查询用户未读个人消息 + public List findAllByUserAndUnread(Long userId) { + return lambdaQuery() + .eq(NotifyMessage::getUserId, userId) + .eq(NotifyMessage::getIsRead, false) + .list(); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageMapper.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageMapper.java new file mode 100644 index 000000000..12679e514 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/message/NotifyMessageMapper.java @@ -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 { + +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeManager.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeManager.java new file mode 100644 index 000000000..5bad34f92 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeManager.java @@ -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 { + + /// 管理端分页查询 + public Page page(PageParam pageParam, NotifyNoticeQuery query) { + Page 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); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeMapper.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeMapper.java new file mode 100644 index 000000000..d89cd703f --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeMapper.java @@ -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 { + +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadManager.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadManager.java new file mode 100644 index 000000000..4333d2790 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadManager.java @@ -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 { + + /// 查询用户对某条公告的已读记录 + public Optional findByUserAndNotice(Long userId, Long noticeId) { + return lambdaQuery() + .eq(NotifyNoticeRead::getUserId, userId) + .eq(NotifyNoticeRead::getNoticeId, noticeId) + .oneOpt(); + } + + /// 查询用户所有未忽略的已读记录 + public List findAllByUserAndNotIgnored(Long userId) { + return lambdaQuery() + .eq(NotifyNoticeRead::getUserId, userId) + .eq(NotifyNoticeRead::getIsIgnored, false) + .list(); + } + + /// 查询用户所有已读记录(含忽略) + public List findAllByUser(Long userId) { + return lambdaQuery() + .eq(NotifyNoticeRead::getUserId, userId) + .list(); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadMapper.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadMapper.java new file mode 100644 index 000000000..5e97e6bf2 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/dao/notice/NotifyNoticeReadMapper.java @@ -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 { + +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/message/NotifyMessage.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/message/NotifyMessage.java new file mode 100644 index 000000000..a0c97e885 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/message/NotifyMessage.java @@ -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 { + + /// 接收用户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); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNotice.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNotice.java new file mode 100644 index 000000000..5af7ed1ba --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNotice.java @@ -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 { + + /// 标题 + 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); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNoticeRead.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNoticeRead.java new file mode 100644 index 000000000..9035b1bed --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/entity/notice/NotifyNoticeRead.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifySeverityEnum.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifySeverityEnum.java new file mode 100644 index 000000000..199873bfe --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifySeverityEnum.java @@ -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"; + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyStatusEnum.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyStatusEnum.java new file mode 100644 index 000000000..607e69c59 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyStatusEnum.java @@ -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"; + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyTypeEnum.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyTypeEnum.java new file mode 100644 index 000000000..775c40427 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/enums/NotifyTypeEnum.java @@ -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"; + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeParam.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeParam.java new file mode 100644 index 000000000..0fc4f3f53 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeParam.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeQuery.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeQuery.java new file mode 100644 index 000000000..904fc3918 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyNoticeQuery.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyUserNoticeQuery.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyUserNoticeQuery.java new file mode 100644 index 000000000..6e0a18410 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/param/notice/NotifyUserNoticeQuery.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeBriefResult.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeBriefResult.java new file mode 100644 index 000000000..8b5165d12 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeBriefResult.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeResult.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeResult.java new file mode 100644 index 000000000..9ab7fc531 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyNoticeResult.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyUnreadCountResult.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyUnreadCountResult.java new file mode 100644 index 000000000..7771d8bae --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/result/notice/NotifyUnreadCountResult.java @@ -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; +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyNoticeService.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyNoticeService.java new file mode 100644 index 000000000..3282f2f6d --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyNoticeService.java @@ -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 page(PageParam pageParam, NotifyNoticeQuery query) { + return MpUtil.toPageResult(noticeManager.page(pageParam, query)); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifySseService.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifySseService.java new file mode 100644 index 000000000..fbf475b30 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifySseService.java @@ -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 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); + } + }); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyUserNoticeService.java b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyUserNoticeService.java new file mode 100644 index 000000000..16a2ff2c0 --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/java/cn/daxpay/open/platform/notify/service/notice/NotifyUserNoticeService.java @@ -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 visibleNotices = findVisibleNotices(); + // 用户已读(且未忽略)的公告id集合 + Set 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 list(NotifyUserNoticeQuery query) { + Long userId = SecurityUtil.getUserId(); + + // 可见公告 + List visibleNotices = findVisibleNotices(); + // 用户阅读记录 + List reads = readManager.findAllByUser(userId); + Map readMap = reads.stream() + .collect(Collectors.toMap(NotifyNoticeRead::getNoticeId, Function.identity(), (a, b) -> a)); + Set ignoredIds = reads.stream() + .filter(r -> Boolean.TRUE.equals(r.getIsIgnored())) + .map(NotifyNoticeRead::getNoticeId) + .collect(Collectors.toSet()); + + List 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 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 visibleNotices = findVisibleNotices(); + Set 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 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 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(); + } +} diff --git a/daxpay-platform/daxpay-platform-service/service-notify/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/daxpay-platform/daxpay-platform-service/service-notify/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..1bc112b0a --- /dev/null +++ b/daxpay-platform/daxpay-platform-service/service-notify/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.daxpay.open.platform.notify.NotifyApplication