listMessages(@Validated MessageQueryParam param) {
+ return messageQueryService.listMessages(param, UserSecurityTool.getUserId());
+ }
+
+ /**
+ * 查询未读消息数
+ */
+ @GetMapping("/unread-count")
+ public Long countUnread() {
+ return messageQueryService.countUnread(UserSecurityTool.getUserId());
+ }
+
+ /**
+ * 按类型查询未读消息数
+ */
+ @GetMapping("/unread-count-by-type")
+ public MessageUnreadCountDto countUnreadByType() {
+ return messageQueryService.countUnreadByType(UserSecurityTool.getUserId());
+ }
+
+ /**
+ * 标记消息已读
+ */
+ @PostMapping("/read/{messageId}")
+ public void markRead(@PathVariable Long messageId) {
+ messageService.markRead(messageId, UserSecurityTool.getUserId());
+ }
+}
diff --git a/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageDto.java b/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageDto.java
new file mode 100644
index 0000000..e05c0cd
--- /dev/null
+++ b/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageDto.java
@@ -0,0 +1,37 @@
+package org.jiayunet.pojo.dto.message;
+
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * 消息列表出参
+ *
+ * @author zk
+ */
+@Data
+public class MessageDto {
+
+ private Long id;
+
+ /** 消息类型 1=系统消息 2=运营消息 3=订单消息 */
+ private Integer type;
+
+ /** 消息标题 */
+ private String title;
+
+ /** 消息内容 */
+ private String content;
+
+ /** 关联业务类型 */
+ private String bizType;
+
+ /** 关联业务ID */
+ private Long bizId;
+
+ /** 是否已读 */
+ private Boolean read;
+
+ /** 创建时间 */
+ private Instant createTime;
+}
diff --git a/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageUnreadCountDto.java b/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageUnreadCountDto.java
new file mode 100644
index 0000000..42c69e2
--- /dev/null
+++ b/client-api/src/main/java/org/jiayunet/pojo/dto/message/MessageUnreadCountDto.java
@@ -0,0 +1,24 @@
+package org.jiayunet.pojo.dto.message;
+
+import lombok.Data;
+
+/**
+ * 各类型消息未读数出参
+ *
+ * @author zk
+ */
+@Data
+public class MessageUnreadCountDto {
+
+ /** 总未读数 */
+ private Long total;
+
+ /** 系统消息未读数 */
+ private Long system;
+
+ /** 运营消息未读数 */
+ private Long operation;
+
+ /** 订单消息未读数 */
+ private Long order;
+}
diff --git a/client-api/src/main/java/org/jiayunet/pojo/param/message/MessageQueryParam.java b/client-api/src/main/java/org/jiayunet/pojo/param/message/MessageQueryParam.java
new file mode 100644
index 0000000..e02ca34
--- /dev/null
+++ b/client-api/src/main/java/org/jiayunet/pojo/param/message/MessageQueryParam.java
@@ -0,0 +1,18 @@
+package org.jiayunet.pojo.param.message;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.jiayunet.pojo.PageParam;
+
+/**
+ * 消息分页查询入参
+ *
+ * @author zk
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class MessageQueryParam extends PageParam {
+
+ /** 消息类型筛选,null=全部 */
+ private Integer type;
+}
diff --git a/client-api/src/main/java/org/jiayunet/service/MessageQueryService.java b/client-api/src/main/java/org/jiayunet/service/MessageQueryService.java
new file mode 100644
index 0000000..00def88
--- /dev/null
+++ b/client-api/src/main/java/org/jiayunet/service/MessageQueryService.java
@@ -0,0 +1,90 @@
+package org.jiayunet.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.jiayunet.mapper.MessageMapper;
+import org.jiayunet.mapper.MessageReadMapper;
+import org.jiayunet.pojo.PageResult;
+import org.jiayunet.pojo.dto.message.MessageDto;
+import org.jiayunet.pojo.dto.message.MessageUnreadCountDto;
+import org.jiayunet.pojo.param.message.MessageQueryParam;
+import org.jiayunet.pojo.po.Message;
+import org.jiayunet.pojo.po.MessageRead;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * C端消息查询服务
+ * 提供用户消息分页列表和未读数查询
+ * 依赖:MessageMapper、MessageReadMapper
+ * 使用表:bg_message(查询消息)、bg_message_read(查询已读状态)
+ *
+ * @author zk
+ */
+@Service
+public class MessageQueryService {
+
+ @Autowired
+ private MessageMapper messageMapper;
+
+ @Autowired
+ private MessageReadMapper messageReadMapper;
+
+ /**
+ * 分页查询用户消息列表
+ * 1. 查询用户可见消息(指定用户+全员)分页 2. 批量查已读状态 3. 组装DTO返回
+ */
+ public PageResult listMessages(MessageQueryParam param, Long userId) {
+ Page page = messageMapper.selectPage(param.toPage(), new LambdaQueryWrapper()
+ .and(w -> w.eq(Message::getUserId, userId).or().eq(Message::getTargetType, 2))
+ .eq(param.getType() != null, Message::getType, param.getType()).orderByDesc(Message::getCreateTime));
+
+ List messageIds = page.getRecords().stream().map(Message::getId).collect(Collectors.toList());
+
+ // 批量查已读状态
+ Set readIds = messageIds.isEmpty() ? Set.of() : messageReadMapper.selectList(new LambdaQueryWrapper().eq(MessageRead::getUserId, userId).in(MessageRead::getMessageId, messageIds)).stream().map(MessageRead::getMessageId).collect(Collectors.toSet());
+
+ List dtoList = page.getRecords().stream().map(msg -> {
+ MessageDto dto = new MessageDto();
+ BeanUtils.copyProperties(msg, dto);
+ dto.setRead(readIds.contains(msg.getId()));
+ return dto;
+ }).collect(Collectors.toList());
+
+ return new PageResult<>(page.getCurrent(), page.getSize(), page.getTotal(), dtoList);
+ }
+
+ /**
+ * 查询未读消息数
+ */
+ public Long countUnread(Long userId) {
+ Long totalCount = messageMapper.selectCount(new LambdaQueryWrapper().and(w -> w.eq(Message::getUserId, userId).or().eq(Message::getTargetType, 2)));
+ Long readCount = messageReadMapper.selectCount(new LambdaQueryWrapper().eq(MessageRead::getUserId, userId));
+ return Math.max(0, totalCount - readCount);
+ }
+
+ /**
+ * 按类型查询未读消息数
+ * 分别统计系统消息(1)、运营消息(2)、订单消息(3)的未读数量
+ */
+ public MessageUnreadCountDto countUnreadByType(Long userId) {
+ Set readIds = messageReadMapper.selectList(new LambdaQueryWrapper().eq(MessageRead::getUserId, userId).select(MessageRead::getMessageId)).stream().map(MessageRead::getMessageId).collect(Collectors.toSet());
+
+ Map unreadMap = messageMapper.selectList(new LambdaQueryWrapper()
+ .and(w -> w.eq(Message::getUserId, userId).or().eq(Message::getTargetType, 2)).select(Message::getId, Message::getType))
+ .stream().filter(msg -> !readIds.contains(msg.getId())).collect(Collectors.groupingBy(Message::getType, Collectors.counting()));
+
+ MessageUnreadCountDto dto = new MessageUnreadCountDto();
+ dto.setSystem(unreadMap.getOrDefault(1, 0L));
+ dto.setOperation(unreadMap.getOrDefault(2, 0L));
+ dto.setOrder(unreadMap.getOrDefault(3, 0L));
+ dto.setTotal(dto.getSystem() + dto.getOperation() + dto.getOrder());
+ return dto;
+ }
+}
diff --git a/manager/src/main/java/org/jiayunet/mapper/MessageMapper.java b/manager/src/main/java/org/jiayunet/mapper/MessageMapper.java
new file mode 100644
index 0000000..ff0832b
--- /dev/null
+++ b/manager/src/main/java/org/jiayunet/mapper/MessageMapper.java
@@ -0,0 +1,13 @@
+package org.jiayunet.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.jiayunet.pojo.po.Message;
+
+/**
+ * 站内信消息Mapper
+ *
+ * @author zk
+ */
+@Mapper
+public interface MessageMapper extends CommonMapper {
+}
diff --git a/manager/src/main/java/org/jiayunet/mapper/MessageReadMapper.java b/manager/src/main/java/org/jiayunet/mapper/MessageReadMapper.java
new file mode 100644
index 0000000..d95587d
--- /dev/null
+++ b/manager/src/main/java/org/jiayunet/mapper/MessageReadMapper.java
@@ -0,0 +1,13 @@
+package org.jiayunet.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.jiayunet.pojo.po.MessageRead;
+
+/**
+ * 消息已读状态Mapper
+ *
+ * @author zk
+ */
+@Mapper
+public interface MessageReadMapper extends CommonMapper {
+}
diff --git a/manager/src/main/java/org/jiayunet/pojo/po/Message.java b/manager/src/main/java/org/jiayunet/pojo/po/Message.java
new file mode 100644
index 0000000..dce0428
--- /dev/null
+++ b/manager/src/main/java/org/jiayunet/pojo/po/Message.java
@@ -0,0 +1,46 @@
+package org.jiayunet.pojo.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * 站内信消息表(bg_message)
+ * 统一存储系统消息、运营消息、订单消息
+ *
+ * @author zk
+ */
+@Data
+@TableName("bg_message")
+public class Message {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 消息类型 1=系统消息 2=运营消息 3=订单消息 */
+ private Integer type;
+
+ /** 目标类型 1=指定用户 2=全部用户 */
+ private Integer targetType;
+
+ /** 目标用户ID,targetType=1时必填,2时为NULL */
+ private Long userId;
+
+ /** 消息标题 */
+ private String title;
+
+ /** 消息内容 */
+ private String content;
+
+ /** 关联业务类型,如 order/resume_diagnose */
+ private String bizType;
+
+ /** 关联业务ID,配合 bizType 跳转 */
+ private Long bizId;
+
+ /** 创建时间 */
+ private Instant createTime;
+}
diff --git a/manager/src/main/java/org/jiayunet/pojo/po/MessageRead.java b/manager/src/main/java/org/jiayunet/pojo/po/MessageRead.java
new file mode 100644
index 0000000..9f82800
--- /dev/null
+++ b/manager/src/main/java/org/jiayunet/pojo/po/MessageRead.java
@@ -0,0 +1,31 @@
+package org.jiayunet.pojo.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * 消息已读状态表(bg_message_read)
+ * 记录用户对消息的已读状态,用户读了才插入一条记录
+ *
+ * @author zk
+ */
+@Data
+@TableName("bg_message_read")
+public class MessageRead {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ /** 消息ID,关联 bg_message.id */
+ private Long messageId;
+
+ /** 用户ID */
+ private Long userId;
+
+ /** 阅读时间 */
+ private Instant readTime;
+}
diff --git a/manager/src/main/java/org/jiayunet/service/MessageService.java b/manager/src/main/java/org/jiayunet/service/MessageService.java
new file mode 100644
index 0000000..c075568
--- /dev/null
+++ b/manager/src/main/java/org/jiayunet/service/MessageService.java
@@ -0,0 +1,76 @@
+package org.jiayunet.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.jiayunet.mapper.MessageMapper;
+import org.jiayunet.mapper.MessageReadMapper;
+import org.jiayunet.pojo.po.Message;
+import org.jiayunet.pojo.po.MessageRead;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.Assert;
+
+import java.time.Instant;
+
+/**
+ * 站内信消息服务
+ * 提供消息发送和已读标记能力,供 C 端和 B 端共用
+ * 使用表:bg_message(消息主表)、bg_message_read(已读状态表)
+ *
+ * @author zk
+ */
+@Service
+public class MessageService {
+
+ @Autowired
+ private MessageMapper messageMapper;
+
+ @Autowired
+ private MessageReadMapper messageReadMapper;
+
+ /**
+ * 发送消息(指定用户)
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void sendToUser(Integer type, Long userId, String title, String content, String bizType, Long bizId) {
+ Assert.notNull(userId, "目标用户ID不能为空");
+ Message msg = new Message();
+ msg.setType(type);
+ msg.setTargetType(1);
+ msg.setUserId(userId);
+ msg.setTitle(title);
+ msg.setContent(content);
+ msg.setBizType(bizType);
+ msg.setBizId(bizId);
+ msg.setCreateTime(Instant.now());
+ messageMapper.insert(msg);
+ }
+
+ /**
+ * 发送消息(全部用户)
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void sendToAll(Integer type, String title, String content) {
+ Message msg = new Message();
+ msg.setType(type);
+ msg.setTargetType(2);
+ msg.setTitle(title);
+ msg.setContent(content);
+ msg.setCreateTime(Instant.now());
+ messageMapper.insert(msg);
+ }
+
+ /**
+ * 标记消息已读(幂等,重复调用不报错)
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void markRead(Long messageId, Long userId) {
+ Long count = messageReadMapper.selectCount(new LambdaQueryWrapper().eq(MessageRead::getMessageId, messageId).eq(MessageRead::getUserId, userId));
+ if (count > 0) return;
+ MessageRead read = new MessageRead();
+ read.setMessageId(messageId);
+ read.setUserId(userId);
+ read.setReadTime(Instant.now());
+ messageReadMapper.insert(read);
+ }
+}