找回密码
 立即注册
首页 业界区 安全 企业级管理系统的站内信怎么轻量级优雅实现 ...

企业级管理系统的站内信怎么轻量级优雅实现

愆蟠唉 2025-11-14 20:00:01
一、什么是站内信?

站内信(In-App Messaging 或 Internal Messaging)是指在一个软件系统或平台内部,用户之间或系统与用户之间进行非实时或准实时文字通信的功能模块。它不依赖外部通信渠道(如短信、邮件),而是完全在应用内部完成消息的发送、接收与管理。
在企业级管理系统(如 OA、ERP、CRM、HRM、项目管理平台等)中,站内信是信息触达和协同办公的重要基础设施
二、站内信的核心功能

功能类别

  • 消息收发        支持系统自动发送通知,或用户之间发送私信。
  • 消息分类        如:系统通知、审批提醒、任务指派、公告、私聊等。
  • 已读/未读状态        用户可标记消息为已读,系统可统计未读数量(常用于红点提示)。
  • 消息列表与详情        提供消息中心页面,支持分页、筛选、搜索、按时间排序。
  • 批量操作        如“全部标为已读”、“批量删除”等,提升操作效率。
  • 实时提醒(可选)        通过 WebSocket 或轮询,在新消息到达时即时通知用户。
  • 多端同步        Web、移动端等不同终端的消息状态保持一致。
  • 权限与安全        用户只能查看自己的消息,敏感内容需防泄露、防篡改。
三、对比多种实现方式

(一)、按消息投递模型分类

在企业级管理系统中,实现站内信(In-App Messaging)有多种技术路径和架构方案。不同的实现方式适用于不同规模、性能要求、实时性需求和系统复杂度。下面从核心维度出发,系统性地介绍实现站内信的多种方式,并对比其优缺点与适用场景。
一、按消息投递模型分类

  • 写扩散(Push 模型 / Fan-out on Write)
<blockquote>
原理:发送消息时,为每个接收者单独写入一条记录到其“收件箱”。
优点
查询快:用户读消息只需查自己的 inbox 表,无需 join。
支持个性化:可标记不同用户是否已读、是否删除。
缺点
写压力大:群发 1000 人 = 写 1000 条记录。
存储冗余:相同内容重复存储。
适用场景
用户量中等( new ConcurrentHashMap());        // 创建一个新的 SseEmitter 实例,超时时间设置为一天 避免连接之后直接关闭浏览器导致连接停滞        SseEmitter emitter = new SseEmitter(86400000L);        emitters.put(token, emitter);        // 当 emitter 完成、超时或发生错误时,从映射表中移除对应的 token        emitter.onCompletion(() -> {            SseEmitter remove = emitters.remove(token);            if (remove != null) {                remove.complete();            }        });        emitter.onTimeout(() -> {            SseEmitter remove = emitters.remove(token);            if (remove != null) {                remove.complete();            }        });        emitter.onError((e) -> {            SseEmitter remove = emitters.remove(token);            if (remove != null) {                remove.complete();            }        });        try {            // 向客户端发送一条连接成功的事件            emitter.send(SseEmitter.event().comment("connected"));        } catch (IOException e) {            // 如果发送消息失败,则从映射表中移除 emitter            emitters.remove(token);        }        return emitter;    }    /**     * 断开指定用户的 SSE 连接     *     * @param userIdAndTenantId 用户的唯一标识符,用于区分不同用户的连接     * @param token  用户的唯一令牌,用于识别具体的连接     */    public void disconnect(String userIdAndTenantId, String token) {        if (userIdAndTenantId == null || token == null) {            return;        }        Map emitters = USER_TOKEN_EMITTERS.get(userIdAndTenantId);        if (MapUtil.isNotEmpty(emitters)) {            try {                SseEmitter sseEmitter = emitters.get(token);                sseEmitter.send(SseEmitter.event().comment("disconnected"));                sseEmitter.complete();            } catch (Exception ignore) {            }            emitters.remove(token);        } else {            USER_TOKEN_EMITTERS.remove(userIdAndTenantId);        }    }    /**     * 订阅SSE消息主题,并提供一个消费者函数来处理接收到的消息     *     * @param consumer 处理SSE消息的消费者函数     */    public void subscribeMessage(Consumer consumer) {        // 使用RedisTemplate实现订阅逻辑        redisTemplate.execute(connection -> {            connection.subscribe((message, pattern) -> {                try {                    // 反序列化消息                    String body = new String(message.getBody());                    // 添加数据格式检查                    if (body.startsWith("[")) {                        log.warn("接收到意外的数组格式数据: {}", body);                        // 如果确实是数组格式,可以选择处理第一个元素或跳过                        JSONArray array = JSONUtil.parseArray(body);                        if (!array.isEmpty()) {                            SseMessageDto sseMessage = JSONUtil.toBean(array.getJSONObject(0), SseMessageDto.class);                            consumer.accept(sseMessage);                        }                        return;                    }                    SseMessageDto sseMessage = JSONUtil.toBean(body, SseMessageDto.class);                    // 执行消费逻辑                    consumer.accept(sseMessage);                } catch (Exception e) {                    log.error("处理SSE订阅消息异常", e);                }            }, SSE_TOPIC.getBytes());            return null;        }, true);    }    /**     * 向指定的用户会话发送消息     *     * @param userIdAndTenantId  要发送消息的用户id     * @param message 要发送的消息内容     */    public void sendMessage(String userIdAndTenantId, String message) {        Map emitters = USER_TOKEN_EMITTERS.get(userIdAndTenantId);        if (MapUtil.isNotEmpty(emitters)) {            for (Map.Entry entry : emitters.entrySet()) {                try {                    SseEmitter sseEmitter = entry.getValue();                    sseEmitter.send(SseEmitter.event()                            .name("message")                            .data(message));                } catch (Exception e) {                    SseEmitter remove = emitters.remove(entry.getKey());                    if (remove != null) {                        remove.complete();                    }                }            }        } else {            USER_TOKEN_EMITTERS.remove(userIdAndTenantId);        }    }    /**     * 推送未读数量     * @param userIdAndTenantId userId+租户Id     * @param unreadCount  未读 数量     */    public void sendMessage(String userIdAndTenantId, Long unreadCount) {        Map emitters = USER_TOKEN_EMITTERS.get(userIdAndTenantId);        if (MapUtil.isNotEmpty(emitters)) {            for (Map.Entry entry : emitters.entrySet()) {                try {                    SseEmitter sseEmitter = entry.getValue();                    sseEmitter.send(SseEmitter.event()                            .name("unreadCount")                            .data(unreadCount < 0 ? 0 : unreadCount));                } catch (Exception e) {                    SseEmitter remove = emitters.remove(entry.getKey());                    if (remove != null) {                        remove.complete();                    }                }            }        } else {            USER_TOKEN_EMITTERS.remove(userIdAndTenantId);        }    }    /**     * 本机全用户会话发送消息     *     * @param message 要发送的消息内容     */    public void sendMessage(String message) {        for (String userIdAndTenantId : USER_TOKEN_EMITTERS.keySet()) {            sendMessage(userIdAndTenantId, message);        }    }    /**     * 发布SSE订阅消息     *     * @param sseMessageDto 要发布的SSE消息对象     */    public void publishMessage(SseMessageDto sseMessageDto) {        SseMessageDto broadcastMessage = new SseMessageDto();        broadcastMessage.setMessage(sseMessageDto.getMessage());        broadcastMessage.setUserIds(sseMessageDto.getUserIds());        // 使用RedisTemplate发布消息        redisTemplate.convertAndSend(SSE_TOPIC, broadcastMessage);        log.info("SSE发送主题订阅消息topic:{} session keys:{} message:{}",                SSE_TOPIC, sseMessageDto.getUserIds(), sseMessageDto.getMessage());    }    /**     * 向所有的用户发布订阅的消息(群发)     *     * @param message 要发布的消息内容     */    public void publishAll(String message) {        SseMessageDto broadcastMessage = new SseMessageDto();        broadcastMessage.setMessage(message);        // 使用RedisTemplate发布消息        redisTemplate.convertAndSend(SSE_TOPIC, broadcastMessage);        log.info("向所有的用户发布订阅的消息:{} session keys:{} message:{}",                SSE_TOPIC, "all", message);    }}[/code]SSE的监听器
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4.     <meta charset="UTF-8">
  5.     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6.     <title>SSE站内信系统</title>
  7.    
  8. </head>
  9. <body>
  10.     <h1>SSE站内信系统</h1>
  11.    
  12.     未连接
  13.    
  14.    
  15.         <button id="connectBtn">连接SSE</button>
  16.         <button id="disconnectBtn">断开连接</button>
  17.         <button id="refreshBtn">刷新历史消息</button>
  18.         <button id="clearBtn">清空消息</button>
  19.    
  20.    
  21.    
  22.         <h2>消息列表</h2>
  23.         
  24.    
  25.    
  26. </body>
  27. </html>
复制代码
SSE收发消息的工具类
  1. CREATE TABLE "public"."sys_message" (
  2.   "message_id" int8 NOT NULL,
  3.   "title" varchar(255) COLLATE "pg_catalog"."default",
  4.   "business_type" varchar(255) COLLATE "pg_catalog"."default",
  5.   "content" text COLLATE "pg_catalog"."default",
  6.   "sender_id" int8,
  7.   "sender_name" varchar(255) COLLATE "pg_catalog"."default",
  8.   "receiver_id" int8,
  9.   "receiver_name" varchar(255) COLLATE "pg_catalog"."default",
  10.   "is_read" bool,
  11.   "read_time" timestamp(6),
  12.   "create_time" timestamp(6),
  13.   "update_time" timestamp(6),
  14.   "tenant_id" varchar(64) COLLATE "pg_catalog"."default" DEFAULT '000000'::character varying,
  15.   CONSTRAINT "sys_message_pkey" PRIMARY KEY ("message_id")
  16. )
  17. ;
  18. ALTER TABLE "public"."sys_message"
  19.   OWNER TO "postgres";
  20. COMMENT ON COLUMN "public"."sys_message"."message_id" IS '主键';
  21. COMMENT ON COLUMN "public"."sys_message"."title" IS '标题';
  22. COMMENT ON COLUMN "public"."sys_message"."business_type" IS '业务分类【服务消息|系统消息|预警消息】';
  23. COMMENT ON COLUMN "public"."sys_message"."content" IS '站内信内容';
  24. COMMENT ON COLUMN "public"."sys_message"."sender_id" IS '站内信发送者Id';
  25. COMMENT ON COLUMN "public"."sys_message"."sender_name" IS '发送者名称';
  26. COMMENT ON COLUMN "public"."sys_message"."receiver_id" IS '站内信接收者Id';
  27. COMMENT ON COLUMN "public"."sys_message"."receiver_name" IS '接受者名称';
  28. COMMENT ON COLUMN "public"."sys_message"."is_read" IS 'true=已读';
  29. COMMENT ON COLUMN "public"."sys_message"."read_time" IS '站内信阅读时间';
  30. COMMENT ON COLUMN "public"."sys_message"."create_time" IS '站内信生产时间';
  31. COMMENT ON COLUMN "public"."sys_message"."update_time" IS '排序时间,默认就是生产时间';
  32. COMMENT ON COLUMN "public"."sys_message"."tenant_id" IS '租户Id';
复制代码
SSE手动装配
  1. import com.baomidou.mybatisplus.annotation.IdType;
  2. import com.baomidou.mybatisplus.annotation.TableId;
  3. import com.baomidou.mybatisplus.annotation.TableName;
  4. import com.fasterxml.jackson.annotation.JsonFormat;
  5. import lombok.Data;
  6. import org.springframework.format.annotation.DateTimeFormat;
  7. import java.time.LocalDateTime;
  8. /** 站内信息
  9. * 万里悲秋常作客,百年多病独登台
  10. * @author : makeJava
  11. */
  12. @Data
  13. @TableName("sys_message")
  14. public class SysMessage {
  15.     // 站内信消息Id
  16.     @TableId(value = "message_id", type = IdType.ASSIGN_ID)
  17.     @JsonFormat(shape = JsonFormat.Shape.STRING)
  18.     private Long messageId;
  19.     // 站内信标题
  20.     private String title;
  21.     // 业务分类【服务消息|系统消息|预警消息】
  22.     private String businessType;
  23.     // 站内信内容
  24.     private String content;
  25.     // 站内信发送者Id {如果发送者Id为-1就是所有人都能搜到}
  26.     private Long senderId;
  27.     // 站内信发送者名称
  28.     private String senderName;
  29.     // 站内信接收者Id
  30.     private Long receiverId;
  31.     // 站内信接收者名称
  32.     private String receiverName;
  33.     // true=已读
  34.     private Boolean isRead;
  35.     // 站内信阅读时间
  36.     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  37.     @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
  38.     private LocalDateTime readTime;
  39.     // 站内信创建时间
  40.     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  41.     @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
  42.     private LocalDateTime createTime;
  43.     // 排序使用时间
  44.     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  45.     @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm")
  46.     private LocalDateTime updateTime;
  47.     // 租户隔离
  48.     private String tenantId;
  49. }
复制代码
SSE接口层
  1. import com.baomidou.mybatisplus.core.mapper.BaseMapper;
  2. import com.dcqc.irs.system.domain.SysMessage;
  3. import org.apache.ibatis.annotations.Mapper;
  4. /**
  5. * 万里悲秋常作客,百年多病独登台
  6. * @author : makeJava
  7. */
  8. @Mapper
  9. public interface SysMessageMapper extends BaseMapper<SysMessage> {
  10. }
复制代码
实体对象
  1. import com.baomidou.mybatisplus.extension.service.IService;
  2. import com.dcqc.irs.system.domain.SysMessage;
  3. /**
  4. * 万里悲秋常作客,百年多病独登台
  5. * @author : makeJava
  6. */
  7. public interface SysMessageService extends IService<SysMessage> {
  8.     long countReadMessage(Long userId,String tenantId);
  9. }
  10. /**
  11. * 万里悲秋常作客,百年多病独登台
  12. * @author : makeJava
  13. */
  14. @Service
  15. @Slf4j
  16. public class SysMessageServiceImpl extends ServiceImpl<SysMessageMapper, SysMessage> implements SysMessageService {
  17.     @Resource
  18.     private SysMessageMapper sysMessageMapper;
  19.     /**
  20.      * 接收系统---站内信消息
  21.      * @param event event
  22.      */
  23.     @EventListener
  24.     public void onSysMessageEvent(SysMessageEvent event) {
  25.         log.info("接收到系统消息: {}", event.getTitle());
  26.         // 创建消息
  27.         SysMessage sysMessage = new SysMessage();
  28.         sysMessage.setMessageId(event.getMessageId());
  29.         sysMessage.setTitle(event.getTitle());
  30.         sysMessage.setContent(event.getContent());
  31.         sysMessage.setSenderId(event.getSenderId());
  32.         sysMessage.setSenderName(event.getSenderName());
  33.         sysMessage.setReceiverId(event.getReceiverId());
  34.         sysMessage.setReceiverName(event.getReceiverName());
  35.         sysMessage.setTenantId(event.getTenantId());
  36.         sysMessage.setIsRead(false);
  37.         sysMessage.setBusinessType(event.getBusinessType());
  38.         sysMessage.setCreateTime(LocalDateTime.now());
  39.         sysMessage.setUpdateTime(LocalDateTime.now());
  40.         if (sysMessage.getMessageId() == null) {
  41.             sysMessage.setMessageId(IdWorker.getId());
  42.         }
  43.         save(sysMessage);
  44.     }
  45.     /**
  46.      * 统计未读消息
  47.      * @param userId userId
  48.      * @param tenantId tenantId
  49.      * @return long
  50.      */
  51.     @Override
  52.     public long countReadMessage(Long userId, String tenantId) {
  53.         return sysMessageMapper.selectCount(Wrappers.lambdaQuery(SysMessage.class).eq(SysMessage::getReceiverId, userId).eq(SysMessage::getTenantId, tenantId).eq(SysMessage::getIsRead, false));
  54.     }
  55. }
复制代码
效果

1.png

模拟给他发一条消息

2.png

检查SSE的连接客户端收到

3.png

断开连接

4.png

F12查看SSE长连接

5.png

SSE搭建的站内信结束

出处:http://www.cnblogs.com/gtnotgod】/个性签名:独学而无友,则孤陋而寡闻。做一个灵魂有趣的人!
如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦,博主在此感谢!
Java入门到入坟
万水千山总是情,打赏一分行不行,所以如果你心情还比较高兴,也是可以扫码打赏博主,哈哈哈(っ•̀ω•́)っ✎⁾⁾!

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册