Przeglądaj źródła

feat(message): 实现站内消息功能模块

- 新增消息类型枚举MessageType及MyBatis类型处理器MessageTypeHandler
- 实现消息的发送、接收、状态管理等核心功能接口
- 添加医生获取患者及其家属消息列表的专用接口
- 定义消息相关的数据传输对象(VO)和持久化对象(PO)
- 支持系统日常消息和异常数据通知的自动发送机制
- 提供消息已读、删除、未读计数等用户交互功能
- 集成消息推送逻辑,支持弹窗和订阅推送两种方式
- 实现基于角色的访问控制,确保消息安全性
- 文档同步更新,涵盖API设计、数据库字段说明和配置示例
mcbaiyun 3 tygodni temu
rodzic
commit
5e1de0d597

+ 100 - 12
docs/Dev/modules/Processing/站内消息功能设计文档.md

@@ -5,6 +5,8 @@
 ## 概述
 为提升用户互动体验,系统需支持站内消息功能,包括医生向患者/家属发送提醒、系统自动检测异常数据并发送通知。消息采用文本格式,支持已读/未读状态管理,集成推送通知,仅依赖MySQL+Spring Boot。
 
+消息类型通过MessageType枚举定义,包括SYSTEM_DAILY(系统日常消息)、DOCTOR(医生下发的消息)、SYSTEM_ANOMALY(异常数据通知),并通过MessageTypeHandler实现与数据库的自动映射转换。
+
 ## 背景和目标
 - 背景:当前APP缺乏实时消息通知功能,用户无法及时收到医生提醒或异常数据警告。
 - 目标:实现消息的发送、管理和浏览功能,支持推送通知、状态管理,接口风格与现有API统一。
@@ -29,8 +31,10 @@
    - 标记已读:`PUT /messages/{id}/read`。
    - 删除消息:`DELETE /messages/{id}`。
    - 获取未读数量:`GET /messages/unread-count`。
+   - 医生获取患者及其家属消息列表:`GET /messages/doctor/patient/{patientId}`,返回结构化的患者和家属消息数据。
 2. Service层实现消息CRUD、状态管理、推送逻辑、数据库操作、异常处理、日志记录等逻辑。异常检测集成到健康数据上传流程。
-3. 推送集成参考现有微信集成,使用订阅消息。
+3. 消息类型管理:使用MessageType枚举定义消息类型,通过MessageTypeHandler实现MyBatis枚举与数据库VARCHAR字段的自动转换。
+4. 推送集成参考现有微信集成,使用订阅消息。
 
 ## 接口清单
 
@@ -44,6 +48,7 @@
 | `/messages/{id}/read` | PUT | 标记消息已读 | 患者/家属 | 路径参数: id | R<Void> |
 | `/messages/{id}` | DELETE | 删除消息 | 患者/家属 | 路径参数: id | R<Void> |
 | `/messages/unread-count` | GET | 获取未读消息数量 | 患者/家属 | 无 | R<Integer> |
+| `/messages/doctor/patient/{patientId}` | GET | 医生获取患者及其家属消息列表 | 医生 | 路径参数: patientId | R<DoctorPatientMessagesVO> |
 
 ## API设计示例
 
@@ -85,10 +90,35 @@ public R<Page<MessageVO>> getMessageList(@RequestParam(defaultValue = "1") int p
 }
 ```
 
+### 医生获取患者及其家属消息列表接口
+
+```java
+@Operation(summary = "医生获取患者及其家属消息列表", description = "医生查看指定患者及其所有家属的消息记录")
+@ApiResponses(value = {
+  @ApiResponse(responseCode = "200", description = "获取成功",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class))),
+  @ApiResponse(responseCode = "403", description = "权限不足",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class))),
+  @ApiResponse(responseCode = "404", description = "患者不存在",
+    content = @Content(mediaType = "application/json",
+      schema = @Schema(implementation = R.class)))
+})
+@GetMapping("/messages/doctor/patient/{patientId}")
+public R<DoctorPatientMessagesVO> getPatientAndFamilyMessages(@PathVariable String patientId) {
+  // 校验医生权限、查询患者及其家属消息、返回结构化数据
+}
+```
+
 ## 配置示例(application.yml)
 
 ```yaml
-# 无特定配置,复用现有推送和数据库配置
+# MyBatis配置 - 注册MessageTypeHandler
+mybatis:
+  type-handlers-package: work.baiyun.chronicdiseaseapp.handler
+
+# 其他配置复用现有推送和数据库配置
 ```
 
 ## 数据库字段设计与兼容性
@@ -99,7 +129,7 @@ public R<Page<MessageVO>> getMessageList(@RequestParam(defaultValue = "1") int p
 - receiver_id: BIGINT NOT NULL, 接收者ID
 - content: TEXT NOT NULL, 消息内容
 - content_format: VARCHAR(20) DEFAULT 'PLAIN', 内容格式
-- type: VARCHAR(50) NOT NULL, 消息类型
+- type: VARCHAR(50) NOT NULL, 消息类型(使用MessageType枚举:SYSTEM_DAILY-系统日常消息,DOCTOR-医生下发的消息,SYSTEM_ANOMALY-异常数据通知)
 - notify_popup: TINYINT DEFAULT 0, 是否弹窗
 - notify_subscribe: TINYINT DEFAULT 0, 是否订阅推送
 - status: TINYINT DEFAULT 0, 状态(0未读1已读2已删除)
@@ -114,6 +144,48 @@ public R<Page<MessageVO>> getMessageList(@RequestParam(defaultValue = "1") int p
 
 注:实体类继承BaseEntity,自动包含上述公共字段。id、create_user、update_user在JSON响应中通过ToStringSerializer转为String。
 
+## 消息类型枚举设计
+
+### MessageType枚举
+```java
+package work.baiyun.chronicdiseaseapp.enums;
+
+public enum MessageType {
+    SYSTEM_DAILY("系统日常消息"),
+    DOCTOR("医生下发的消息"),
+    SYSTEM_ANOMALY("异常数据通知");
+
+    private final String description;
+
+    MessageType(String description) {
+        this.description = description;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+}
+```
+
+### MessageTypeHandler类型处理器
+```java
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+@MappedTypes(MessageType.class)
+public class MessageTypeHandler extends BaseTypeHandler<MessageType> {
+    // 处理MessageType枚举与数据库VARCHAR字段间的转换
+    // 存储时:enum.name() -> 数据库字符串
+    // 查询时:数据库字符串 -> MessageType.valueOf(name)
+}
+```
+
+注:MessageTypeHandler继承自MyBatis的BaseTypeHandler,实现枚举与数据库字段的自动转换。通过@MappedTypes注解指定处理的枚举类型,确保数据库中的VARCHAR字段能正确映射为MessageType枚举值。
+
 ## 数据传输对象设计
 
 ### 请求VO
@@ -134,8 +206,8 @@ public class SendMessageRequest {
     private String contentFormat = "PLAIN";
 
     @Schema(description = "消息类型", required = true, example = "DOCTOR")
-    @NotBlank(message = "类型不能为空")
-    private String type;
+    @NotNull(message = "类型不能为空")
+    private MessageType type;
 
     @Schema(description = "是否通知家属")
     private Boolean notifyFamily = false;
@@ -164,8 +236,8 @@ public class SystemDailySendRequest {
     private String content;
 
     @Schema(description = "消息类型", required = true, example = "SYSTEM_DAILY")
-    @NotBlank(message = "类型不能为空")
-    private String type = "SYSTEM_DAILY";
+    @NotNull(message = "类型不能为空")
+    private MessageType type = MessageType.SYSTEM_DAILY;
 }
 ```
 
@@ -181,8 +253,8 @@ public class SystemAnomalySendRequest {
     private Object anomalyData;
 
     @Schema(description = "消息类型", required = true, example = "SYSTEM_ANOMALY")
-    @NotBlank(message = "类型不能为空")
-    private String type = "SYSTEM_ANOMALY";
+    @NotNull(message = "类型不能为空")
+    private MessageType type = MessageType.SYSTEM_ANOMALY;
 }
 ```
 
@@ -211,7 +283,7 @@ public class MessageVO {
     private String contentFormat;
 
     @Schema(description = "消息类型", example = "DOCTOR")
-    private String type;
+    private MessageType type;
 
     @Schema(description = "是否弹窗")
     private Boolean notifyPopup;
@@ -232,7 +304,21 @@ public class MessageVO {
 }
 ```
 
-注:所有ID字段在VO中定义为String类型,避免前端精度丢失。时间字段使用@JsonFormat格式化。senderName通过注入UserInfoMapper查询UserInfo.nickname填充。
+注:所有ID字段在VO中定义为String类型,避免前端精度丢失。时间字段使用@JsonFormat格式化。senderName通过注入UserInfoMapper查询UserInfo.nickname填充。type字段使用MessageType枚举类型,在JSON序列化时会转换为枚举名称字符串。
+
+#### DoctorPatientMessagesVO(医生患者消息响应)
+```java
+@Schema(description = "医生患者消息响应")
+public class DoctorPatientMessagesVO {
+    @Schema(description = "患者消息列表")
+    private List<MessageVO> patientMessages;
+
+    @Schema(description = "家属消息列表")
+    private List<MessageVO> familyMessages;
+}
+```
+
+注:该VO用于医生查看指定患者及其家属的消息记录,将患者和家属的消息分别组织在不同的列表中,便于前端展示和管理。
 
 ### 联动查询设计
 消息模块涉及跨表查询用户信息,为填充MessageVO中的senderName字段:
@@ -245,6 +331,7 @@ public class MessageVO {
 ## 权限与安全
 - 发送接口仅医生/管理员角色可操作。
 - 接收接口公开,但需认证。
+- 医生专用接口(如获取患者及其家属消息列表)仅医生角色可访问,且需验证医生与患者的绑定关系。
 
 ## 日志与审计
 - 操作日志:`[MessageOperation] userId={}, action={}, messageId={}`
@@ -259,7 +346,8 @@ public class MessageVO {
 
 ## 兼容性与前端要求
 - 前端需支持消息列表、详情、状态管理。
-- 响应中ID字段为String。
+- 响应中ID字段为String,MessageType枚举字段在JSON中以字符串形式返回(如"DOCTOR")。
+- 前端可根据消息类型字符串进行相应的UI展示和逻辑处理。
 
 ## 迁移/变更管理
 - 先实现后端接口,测试通过后前端接入。

+ 183 - 0
src/main/java/work/baiyun/chronicdiseaseapp/controller/MessageController.java

@@ -0,0 +1,183 @@
+package work.baiyun.chronicdiseaseapp.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import work.baiyun.chronicdiseaseapp.common.R;
+import work.baiyun.chronicdiseaseapp.model.vo.DoctorPatientMessagesVO;
+import work.baiyun.chronicdiseaseapp.model.vo.MessageVO;
+import work.baiyun.chronicdiseaseapp.model.vo.SendMessageRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemAnomalySendRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemDailySendRequest;
+import work.baiyun.chronicdiseaseapp.service.MessageService;
+
+@RestController
+@RequestMapping("/messages")
+@Tag(name = "站内消息", description = "站内消息管理相关接口")
+public class MessageController {
+
+    private static final Logger logger = LoggerFactory.getLogger(MessageController.class);
+
+    @Autowired
+    private MessageService messageService;
+
+    @Operation(summary = "医生发送消息", description = "医生向患者/家属发送站内消息")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "发送成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+        @ApiResponse(responseCode = "400", description = "参数错误",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+        @ApiResponse(responseCode = "401", description = "未认证",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @PostMapping("/send")
+    public R<?> sendMessage(@RequestBody SendMessageRequest request) {
+        try {
+            String result = messageService.sendMessage(request);
+            return R.success(200, result);
+        } catch (Exception e) {
+            logger.error("[MessageError] send message error: {}", e.getMessage());
+            return R.fail(500, "发送失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "系统发送日常消息", description = "系统发送日常消息")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "发送成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @PostMapping("/system-daily-send")
+    public R<?> sendSystemDailyMessage(@RequestBody SystemDailySendRequest request) {
+        try {
+            String result = messageService.sendSystemDailyMessage(request);
+            return R.success(200, result);
+        } catch (Exception e) {
+            logger.error("[MessageError] send system daily message error: {}", e.getMessage());
+            return R.fail(500, "发送失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "系统发送异常通知", description = "系统发送异常通知")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "发送成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @PostMapping("/system-anomaly-send")
+    public R<?> sendSystemAnomalyMessage(@RequestBody SystemAnomalySendRequest request) {
+        try {
+            String result = messageService.sendSystemAnomalyMessage(request);
+            return R.success(200, result);
+        } catch (Exception e) {
+            logger.error("[MessageError] send system anomaly message error: {}", e.getMessage());
+            return R.fail(500, "发送失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取消息列表", description = "分页获取用户消息列表")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "获取成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @GetMapping("/list")
+    public R<?> getMessageList(@RequestParam(defaultValue = "1") int page,
+                                             @RequestParam(defaultValue = "10") int size,
+                                             @RequestParam(required = false) Byte status) {
+        try {
+            Page<MessageVO> result = messageService.listMessages(page, size, status);
+            return R.success(200, "ok", result);
+        } catch (Exception e) {
+            logger.error("[MessageError] get message list error: {}", e.getMessage());
+            return R.fail(500, "获取失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取消息详情", description = "获取消息详情")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "获取成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @GetMapping("/{id}")
+    public R<?> getMessageById(@PathVariable Long id) {
+        try {
+            MessageVO result = messageService.getMessageById(id);
+            return R.success(200, "ok", result);
+        } catch (Exception e) {
+            logger.error("[MessageError] get message by id error: {}", e.getMessage());
+            return R.fail(500, "获取失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "标记消息已读", description = "标记消息已读")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "标记成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @PutMapping("/{id}/read")
+    public R<?> markAsRead(@PathVariable Long id) {
+        try {
+            messageService.markAsRead(id);
+            return R.success(200, "标记成功");
+        } catch (Exception e) {
+            logger.error("[MessageError] mark as read error: {}", e.getMessage());
+            return R.fail(500, "标记失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "删除消息", description = "删除消息")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "删除成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @DeleteMapping("/{id}")
+    public R<?> deleteMessage(@PathVariable Long id) {
+        try {
+            messageService.deleteMessage(id);
+            return R.success(200, "删除成功");
+        } catch (Exception e) {
+            logger.error("[MessageError] delete message error: {}", e.getMessage());
+            return R.fail(500, "删除失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取未读消息数量", description = "获取未读消息数量")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "获取成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @GetMapping("/unread-count")
+    public R<?> getUnreadCount() {
+        try {
+            Integer result = messageService.getUnreadCount();
+            return R.success(200, "ok", result);
+        } catch (Exception e) {
+            logger.error("[MessageError] get unread count error: {}", e.getMessage());
+            return R.fail(500, "获取失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "医生获取患者及其家属消息", description = "医生获取指定患者ID及其绑定的所有家属的消息列表")
+    @ApiResponses(value = {
+        @ApiResponse(responseCode = "200", description = "获取成功",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class))),
+        @ApiResponse(responseCode = "404", description = "患者不存在",
+            content = @Content(mediaType = "application/json", schema = @Schema(implementation = R.class)))
+    })
+    @GetMapping("/doctor/patient/{patientId}")
+    public R<?> getPatientAndFamilyMessages(@PathVariable Long patientId) {
+        try {
+            DoctorPatientMessagesVO result = messageService.getPatientAndFamilyMessages(patientId);
+            return R.success(200, "ok", result);
+        } catch (Exception e) {
+            logger.error("[MessageError] get patient and family messages error: {}", e.getMessage());
+            return R.fail(500, "获取失败: " + e.getMessage());
+        }
+    }
+}

+ 17 - 0
src/main/java/work/baiyun/chronicdiseaseapp/enums/MessageType.java

@@ -0,0 +1,17 @@
+package work.baiyun.chronicdiseaseapp.enums;
+
+public enum MessageType {
+    SYSTEM_DAILY("系统日常消息"),
+    DOCTOR("医生下发的消息"),
+    SYSTEM_ANOMALY("异常数据通知");
+
+    private final String description;
+
+    MessageType(String description) {
+        this.description = description;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+}

+ 41 - 0
src/main/java/work/baiyun/chronicdiseaseapp/handler/MessageTypeHandler.java

@@ -0,0 +1,41 @@
+package work.baiyun.chronicdiseaseapp.handler;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+import org.apache.ibatis.type.MappedTypes;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@MappedTypes(MessageType.class)
+public class MessageTypeHandler extends BaseTypeHandler<MessageType> {
+
+    @Override
+    public void setNonNullParameter(PreparedStatement ps, int i, MessageType parameter, JdbcType jdbcType) throws SQLException {
+        ps.setString(i, parameter.name());
+    }
+
+    @Override
+    public MessageType getNullableResult(ResultSet rs, String columnName) throws SQLException {
+        String name = rs.getString(columnName);
+        if (rs.wasNull()) return null;
+        return MessageType.valueOf(name);
+    }
+
+    @Override
+    public MessageType getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+        String name = rs.getString(columnIndex);
+        if (rs.wasNull()) return null;
+        return MessageType.valueOf(name);
+    }
+
+    @Override
+    public MessageType getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+        String name = cs.getString(columnIndex);
+        if (cs.wasNull()) return null;
+        return MessageType.valueOf(name);
+    }
+}

+ 10 - 0
src/main/java/work/baiyun/chronicdiseaseapp/mapper/MessageMapper.java

@@ -0,0 +1,10 @@
+package work.baiyun.chronicdiseaseapp.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import work.baiyun.chronicdiseaseapp.model.po.MessagePO;
+
+@Mapper
+public interface MessageMapper extends BaseMapper<MessagePO> {
+
+}

+ 81 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/po/MessagePO.java

@@ -0,0 +1,81 @@
+package work.baiyun.chronicdiseaseapp.model.po;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.format.annotation.DateTimeFormat;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@TableName("t_messages")
+public class MessagePO extends BaseEntity {
+
+    /**
+     * 发送者ID(系统消息为0)
+     */
+    @TableField("sender_id")
+    private Long senderId;
+
+    /**
+     * 接收者ID
+     */
+    @TableField("receiver_id")
+    private Long receiverId;
+
+    /**
+     * 消息内容
+     */
+    @TableField("content")
+    private String content;
+
+    /**
+     * 内容格式
+     */
+    @TableField("content_format")
+    private String contentFormat;
+
+    /**
+     * 消息类型
+     */
+    @TableField("type")
+    private MessageType type;
+
+    /**
+     * 是否弹窗
+     */
+    @TableField("notify_popup")
+    private Byte notifyPopup;
+
+    /**
+     * 是否订阅推送
+     */
+    @TableField("notify_subscribe")
+    private Byte notifySubscribe;
+
+    /**
+     * 状态(0未读1已读2已删除)
+     */
+    @TableField("status")
+    private Byte status;
+
+    /**
+     * 阅读时间
+     */
+    @TableField("read_time")
+    @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime readTime;
+
+    /**
+     * 软删除标志
+     */
+    @TableField("is_deleted")
+    @TableLogic
+    private Integer isDeleted;
+}

+ 19 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/DoctorPatientMessagesVO.java

@@ -0,0 +1,19 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Schema(description = "医生查看患者消息数据")
+public class DoctorPatientMessagesVO {
+
+    @Schema(description = "患者消息列表")
+    private List<MessageVO> patientMessages;
+
+    @Schema(description = "家属消息列表")
+    private List<MessageVO> familyMessages;
+}

+ 52 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/MessageVO.java

@@ -0,0 +1,52 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@Schema(description = "消息信息")
+public class MessageVO {
+    @Schema(description = "消息ID", example = "1234567890123456789")
+    private String id;
+
+    @Schema(description = "发送者ID", example = "1234567890123456789")
+    private String senderId;
+
+    @Schema(description = "发送者姓名")
+    private String senderName;
+
+    @Schema(description = "接收者ID", example = "1234567890123456789")
+    private String receiverId;
+
+    @Schema(description = "消息内容")
+    private String content;
+
+    @Schema(description = "内容格式", example = "PLAIN")
+    private String contentFormat;
+
+    @Schema(description = "消息类型", example = "DOCTOR")
+    private MessageType type;
+
+    @Schema(description = "是否弹窗")
+    private Boolean notifyPopup;
+
+    @Schema(description = "是否订阅推送")
+    private Boolean notifySubscribe;
+
+    @Schema(description = "状态", example = "0")
+    private Byte status;
+
+    @Schema(description = "阅读时间", example = "2023-12-01 10:00:00")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime readTime;
+
+    @Schema(description = "创建时间", example = "2023-12-01 10:00:00")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private LocalDateTime createTime;
+}

+ 39 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/SendMessageRequest.java

@@ -0,0 +1,39 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Getter;
+import lombok.Setter;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Schema(description = "医生发送消息请求")
+public class SendMessageRequest {
+    @Schema(description = "接收者ID列表", required = true)
+    @NotEmpty(message = "接收者不能为空")
+    private List<Long> receiverIds;
+
+    @Schema(description = "消息内容", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "内容格式", example = "PLAIN")
+    private String contentFormat = "PLAIN";
+
+    @Schema(description = "消息类型", required = true, example = "DOCTOR")
+    @NotBlank(message = "类型不能为空")
+    private MessageType type;
+
+    @Schema(description = "是否通知家属")
+    private Boolean notifyFamily = false;
+
+    @Schema(description = "是否弹窗")
+    private Boolean notifyPopup;
+
+    @Schema(description = "是否订阅推送")
+    private Boolean notifySubscribe;
+}

+ 24 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/SystemAnomalySendRequest.java

@@ -0,0 +1,24 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.Setter;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+@Getter
+@Setter
+@Schema(description = "系统发送异常通知请求")
+public class SystemAnomalySendRequest {
+    @Schema(description = "患者ID", required = true)
+    @NotNull(message = "患者ID不能为空")
+    private Long patientId;
+
+    @Schema(description = "异常数据详情")
+    private Object anomalyData;
+
+    @Schema(description = "消息类型", required = true, example = "SYSTEM_ANOMALY")
+    @NotBlank(message = "类型不能为空")
+    private MessageType type = MessageType.SYSTEM_ANOMALY;
+}

+ 27 - 0
src/main/java/work/baiyun/chronicdiseaseapp/model/vo/SystemDailySendRequest.java

@@ -0,0 +1,27 @@
+package work.baiyun.chronicdiseaseapp.model.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Getter;
+import lombok.Setter;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Schema(description = "系统发送日常消息请求")
+public class SystemDailySendRequest {
+    @Schema(description = "接收者ID列表", required = true)
+    @NotEmpty(message = "接收者不能为空")
+    private List<Long> receiverIds;
+
+    @Schema(description = "消息内容", required = true)
+    @NotBlank(message = "内容不能为空")
+    private String content;
+
+    @Schema(description = "消息类型", required = true, example = "SYSTEM_DAILY")
+    @NotBlank(message = "类型不能为空")
+    private MessageType type = MessageType.SYSTEM_DAILY;
+}

+ 33 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/MessageService.java

@@ -0,0 +1,33 @@
+package work.baiyun.chronicdiseaseapp.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import work.baiyun.chronicdiseaseapp.model.vo.DoctorPatientMessagesVO;
+import work.baiyun.chronicdiseaseapp.model.vo.MessageVO;
+import work.baiyun.chronicdiseaseapp.model.vo.SendMessageRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemAnomalySendRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemDailySendRequest;
+
+public interface MessageService {
+    String sendMessage(SendMessageRequest request);
+
+    String sendSystemDailyMessage(SystemDailySendRequest request);
+
+    String sendSystemAnomalyMessage(SystemAnomalySendRequest request);
+
+    Page<MessageVO> listMessages(int page, int size, Byte status);
+
+    MessageVO getMessageById(Long id);
+
+    void markAsRead(Long id);
+
+    void deleteMessage(Long id);
+
+    Integer getUnreadCount();
+
+    /**
+     * 医生获取指定患者及其家属的消息列表
+     * @param patientId 患者ID
+     * @return 包含患者消息和家属消息的VO对象
+     */
+    DoctorPatientMessagesVO getPatientAndFamilyMessages(Long patientId);
+}

+ 5 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/UserBindingService.java

@@ -37,4 +37,9 @@ public interface UserBindingService {
      * 获取医生绑定的所有患者ID列表
      */
     java.util.List<Long> getBoundPatientIds(Long doctorUserId);
+
+    /**
+     * 获取患者绑定的所有家属ID列表
+     */
+    java.util.List<Long> getBoundFamilyIds(Long patientUserId);
 }

+ 258 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/MessageServiceImpl.java

@@ -0,0 +1,258 @@
+package work.baiyun.chronicdiseaseapp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import work.baiyun.chronicdiseaseapp.enums.ErrorCode;
+import work.baiyun.chronicdiseaseapp.exception.CustomException;
+import work.baiyun.chronicdiseaseapp.mapper.MessageMapper;
+import work.baiyun.chronicdiseaseapp.mapper.UserInfoMapper;
+import work.baiyun.chronicdiseaseapp.model.po.MessagePO;
+import work.baiyun.chronicdiseaseapp.model.po.UserInfo;
+import work.baiyun.chronicdiseaseapp.model.vo.DoctorPatientMessagesVO;
+import work.baiyun.chronicdiseaseapp.model.vo.MessageVO;
+import work.baiyun.chronicdiseaseapp.model.vo.SendMessageRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemAnomalySendRequest;
+import work.baiyun.chronicdiseaseapp.model.vo.SystemDailySendRequest;
+import work.baiyun.chronicdiseaseapp.service.MessageService;
+import work.baiyun.chronicdiseaseapp.service.UserBindingService;
+import work.baiyun.chronicdiseaseapp.enums.MessageType;
+import work.baiyun.chronicdiseaseapp.util.SecurityUtils;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+public class MessageServiceImpl implements MessageService {
+
+    private static final Logger logger = LoggerFactory.getLogger(MessageServiceImpl.class);
+
+    @Autowired
+    private MessageMapper messageMapper;
+
+    @Autowired
+    private UserInfoMapper userInfoMapper;
+
+    @Autowired
+    private UserBindingService userBindingService;
+
+    // TODO: 注入推送服务,如果有的话
+    // @Autowired
+    // private PushService pushService;
+
+    @Override
+    public String sendMessage(SendMessageRequest request) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        // 校验权限:医生
+        // TODO: 添加权限校验
+
+        // 首先处理直接接收者
+        for (Long receiverId : request.getReceiverIds()) {
+            MessagePO message = new MessagePO();
+            BeanUtils.copyProperties(request, message);
+            message.setSenderId(currentUserId);
+            message.setReceiverId(receiverId);
+            message.setContentFormat(request.getContentFormat() != null ? request.getContentFormat() : "PLAIN");
+            message.setNotifyPopup(request.getNotifyPopup() != null ? (byte) (request.getNotifyPopup() ? 1 : 0) : 0);
+            message.setNotifySubscribe(request.getNotifySubscribe() != null ? (byte) (request.getNotifySubscribe() ? 1 : 0) : 0);
+            message.setStatus((byte) 0); // 未读
+            messageMapper.insert(message);
+
+            // 推送通知
+            if (message.getNotifySubscribe() == 1) {
+                // TODO: 调用推送服务
+                // pushService.sendSubscribeMessage(receiverId, message);
+            }
+
+            logger.info("[MessageOperation] userId={}, action=send, messageId={}", currentUserId, message.getId());
+
+            // 如果勾选了通知家属,则为该病人的家属也发送消息
+            if (request.getNotifyFamily() != null && request.getNotifyFamily()) {
+                List<Long> familyIds = userBindingService.getBoundFamilyIds(receiverId);
+                for (Long familyId : familyIds) {
+                    MessagePO familyMessage = new MessagePO();
+                    BeanUtils.copyProperties(request, familyMessage);
+                    familyMessage.setSenderId(currentUserId);
+                    familyMessage.setReceiverId(familyId);
+                    familyMessage.setContentFormat(request.getContentFormat() != null ? request.getContentFormat() : "PLAIN");
+                    familyMessage.setNotifyPopup(request.getNotifyPopup() != null ? (byte) (request.getNotifyPopup() ? 1 : 0) : 0);
+                    familyMessage.setNotifySubscribe(request.getNotifySubscribe() != null ? (byte) (request.getNotifySubscribe() ? 1 : 0) : 0);
+                    familyMessage.setStatus((byte) 0); // 未读
+                    messageMapper.insert(familyMessage);
+
+                    // 推送通知给家属
+                    if (familyMessage.getNotifySubscribe() == 1) {
+                        // TODO: 调用推送服务
+                        // pushService.sendSubscribeMessage(familyId, familyMessage);
+                    }
+
+                    logger.info("[MessageOperation] userId={}, action=send_to_family, patientId={}, familyId={}, messageId={}",
+                        currentUserId, receiverId, familyId, familyMessage.getId());
+                }
+            }
+        }
+
+        // 返回最后一个消息ID
+        return "消息发送成功"; // 或返回ID列表
+    }
+
+    @Override
+    public String sendSystemDailyMessage(SystemDailySendRequest request) {
+        // 系统发送,senderId=0
+        for (Long receiverId : request.getReceiverIds()) {
+            MessagePO message = new MessagePO();
+            message.setSenderId(0L);
+            message.setReceiverId(receiverId);
+            message.setContent(request.getContent());
+            message.setContentFormat("PLAIN");
+            message.setType(request.getType());
+            message.setNotifyPopup((byte) 0);
+            message.setNotifySubscribe((byte) 0);
+            message.setStatus((byte) 0);
+            messageMapper.insert(message);
+
+            logger.info("[MessageOperation] action=system_daily_send, messageId={}", message.getId());
+        }
+        return "系统日常消息发送成功";
+    }
+
+    @Override
+    public String sendSystemAnomalyMessage(SystemAnomalySendRequest request) {
+        MessagePO message = new MessagePO();
+        message.setSenderId(0L);
+        message.setReceiverId(request.getPatientId());
+        message.setContent("检测到异常数据: " + request.getAnomalyData().toString()); // 简化
+        message.setContentFormat("PLAIN");
+        message.setType(request.getType());
+        message.setNotifyPopup((byte) 1); // 弹窗
+        message.setNotifySubscribe((byte) 1); // 推送
+        message.setStatus((byte) 0);
+        messageMapper.insert(message);
+
+        // 推送
+        // TODO: pushService.sendSubscribeMessage(request.getPatientId(), message);
+
+        logger.info("[MessageOperation] action=system_anomaly_send, messageId={}", message.getId());
+        return message.getId().toString();
+    }
+
+    @Override
+    public Page<MessageVO> listMessages(int page, int size, Byte status) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        LambdaQueryWrapper<MessagePO> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MessagePO::getReceiverId, currentUserId);
+        if (status != null) {
+            wrapper.eq(MessagePO::getStatus, status);
+        }
+        wrapper.orderByDesc(MessagePO::getCreateTime);
+
+        Page<MessagePO> poPage = messageMapper.selectPage(new Page<>(page, size), wrapper);
+        Page<MessageVO> voPage = new Page<>(page, size, poPage.getTotal());
+
+        List<MessageVO> voList = poPage.getRecords().stream().map(this::convertToVO).collect(java.util.stream.Collectors.toList());
+        voPage.setRecords(voList);
+
+        return voPage;
+    }
+
+    @Override
+    public MessageVO getMessageById(Long id) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        MessagePO message = messageMapper.selectById(id);
+        if (message == null || !message.getReceiverId().equals(currentUserId)) {
+            throw new CustomException(ErrorCode.PARAMETER_ERROR.getCode(), "消息不存在或无权限");
+        }
+        return convertToVO(message);
+    }
+
+    @Override
+    public void markAsRead(Long id) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        MessagePO message = messageMapper.selectById(id);
+        if (message == null || !message.getReceiverId().equals(currentUserId)) {
+            throw new CustomException(ErrorCode.PARAMETER_ERROR.getCode(), "消息不存在或无权限");
+        }
+        message.setStatus((byte) 1);
+        message.setReadTime(LocalDateTime.now());
+        messageMapper.updateById(message);
+        logger.info("[MessageOperation] userId={}, action=read, messageId={}", currentUserId, id);
+    }
+
+    @Override
+    public void deleteMessage(Long id) {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        MessagePO message = messageMapper.selectById(id);
+        if (message == null || !message.getReceiverId().equals(currentUserId)) {
+            throw new CustomException(ErrorCode.PARAMETER_ERROR.getCode(), "消息不存在或无权限");
+        }
+        messageMapper.deleteById(id);
+        logger.info("[MessageOperation] userId={}, action=delete, messageId={}", currentUserId, id);
+    }
+
+    @Override
+    public Integer getUnreadCount() {
+        Long currentUserId = SecurityUtils.getCurrentUserId();
+        LambdaQueryWrapper<MessagePO> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(MessagePO::getReceiverId, currentUserId);
+        wrapper.eq(MessagePO::getStatus, (byte) 0);
+        return Math.toIntExact(messageMapper.selectCount(wrapper));
+    }
+
+    private MessageVO convertToVO(MessagePO po) {
+        MessageVO vo = new MessageVO();
+        BeanUtils.copyProperties(po, vo);
+        vo.setId(po.getId().toString());
+        vo.setSenderId(po.getSenderId().toString());
+        vo.setReceiverId(po.getReceiverId().toString());
+        vo.setNotifyPopup(po.getNotifyPopup() != 0);
+        vo.setNotifySubscribe(po.getNotifySubscribe() != 0);
+
+        // 获取发送者姓名
+        if (po.getSenderId() != 0) {
+            UserInfo user = userInfoMapper.selectById(po.getSenderId());
+            if (user != null) {
+                vo.setSenderName(user.getNickname());
+            }
+        } else {
+            vo.setSenderName("系统");
+        }
+
+        return vo;
+    }
+
+    @Override
+    public DoctorPatientMessagesVO getPatientAndFamilyMessages(Long patientId) {
+        DoctorPatientMessagesVO result = new DoctorPatientMessagesVO();
+
+        // 获取患者的消息
+        LambdaQueryWrapper<MessagePO> patientWrapper = new LambdaQueryWrapper<>();
+        patientWrapper.eq(MessagePO::getReceiverId, patientId);
+        patientWrapper.orderByDesc(MessagePO::getCreateTime);
+        List<MessagePO> patientMessagePOs = messageMapper.selectList(patientWrapper);
+        List<MessageVO> patientMessages = patientMessagePOs.stream()
+                .map(this::convertToVO)
+                .collect(java.util.stream.Collectors.toList());
+        result.setPatientMessages(patientMessages);
+
+        // 获取家属的消息
+        List<Long> familyIds = userBindingService.getBoundFamilyIds(patientId);
+        if (!familyIds.isEmpty()) {
+            LambdaQueryWrapper<MessagePO> familyWrapper = new LambdaQueryWrapper<>();
+            familyWrapper.in(MessagePO::getReceiverId, familyIds);
+            familyWrapper.orderByDesc(MessagePO::getCreateTime);
+            List<MessagePO> familyMessagePOs = messageMapper.selectList(familyWrapper);
+            List<MessageVO> familyMessages = familyMessagePOs.stream()
+                    .map(this::convertToVO)
+                    .collect(java.util.stream.Collectors.toList());
+            result.setFamilyMessages(familyMessages);
+        } else {
+            result.setFamilyMessages(java.util.Collections.emptyList());
+        }
+
+        return result;
+    }
+}

+ 12 - 0
src/main/java/work/baiyun/chronicdiseaseapp/service/impl/UserBindingServiceImpl.java

@@ -259,4 +259,16 @@ public class UserBindingServiceImpl implements UserBindingService {
                 .map(UserBinding::getPatientUserId)
                 .collect(Collectors.toList());
     }
+
+    @Override
+    public List<Long> getBoundFamilyIds(Long patientUserId) {
+        QueryWrapper<UserBinding> queryWrapper = new QueryWrapper<>();
+        queryWrapper.eq("patient_user_id", patientUserId)
+                .eq("binding_type", UserBindingType.FAMILY)
+                .eq("status", 1); // 只查询有效的绑定关系
+        List<UserBinding> bindings = userBindingMapper.selectList(queryWrapper);
+        return bindings.stream()
+                .map(UserBinding::getBoundUserId)
+                .collect(Collectors.toList());
+    }
 }